diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 068d2685a..000000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -dist/ -src/shared/components/svg/* -src/i18n/formatters.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index ecda93be7..000000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,82 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - 'plugin:react/recommended', - 'plugin:react/jsx-runtime', - 'plugin:prettier/recommended', - 'plugin:import/recommended', - 'plugin:import/typescript', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, - plugins: ['react-refresh', 'react-hooks', 'simple-import-sort'], - rules: { - 'react-refresh/only-export-components': [ - 'error', - { - allowConstantExport: true, - }, - ], - 'max-len': [ - 'error', - { - code: 90, - comments: 140, - tabWidth: 2, - ignorePattern: '^(import .* |.*LL\\..*|.*d=.*|.*from \')', - ignoreComments: true, - ignoreRegExpLiterals: true, - ignoreTemplateLiterals: true, - }, - ], - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'error', - 'react/prop-types': 'off', - 'react/display-name': 'off', - semi: [ - 'error', - 'always', - { - omitLastInOneLineBlock: false, - }, - ], - 'prettier/prettier': [ - 'error', - { - semi: true, - }, - ], - 'simple-import-sort/imports': 'error', - 'react/react-in-jsx-scope': 'off', - '@typescript-eslint/no-unused-vars': 'error', - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-non-null-assertion': 'error', - 'import/no-unresolved': [ - 'error', - { - ignore: ['@ladle/react', '.md', 'typesafe-i18n/detectors', '@hookform/devtools'], - }, - ], - }, - overrides: [ - { - extends: ['plugin:@typescript-eslint/disable-type-checked'], - files: ['./**/*.js'], - }, - ], - settings: { - react: { - version: '18.2', - }, - }, -}; diff --git a/.github/workflows/build-macos.yaml b/.github/workflows/build-macos.yaml index 984ae01b6..ae6b24352 100644 --- a/.github/workflows/build-macos.yaml +++ b/.github/workflows/build-macos.yaml @@ -11,19 +11,22 @@ on: tags: - v*.*.* +env: + SCCACHE_GHA_ENABLED: "true" + RUSTC_WRAPPER: "sccache" + SQLX_OFFLINE: "1" + jobs: build-macos: runs-on: - self-hosted - macOS + - native env: APPLE_SIGNING_IDENTITY: "Apple Distribution: defguard sp. z o.o. (82GZ7KN29J)" APPLE_SIGNING_IDENTITY_INSTALLER: "3rd Party Mac Developer Installer: defguard sp. z o.o. (82GZ7KN29J)" - APPLE_PROVIDER_SHORT_NAME: "82GZ7KN29J" - APPLE_ID: "kamil@defguard.net" - APPLE_TEAM_ID: "82GZ7KN29J" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive @@ -32,42 +35,96 @@ jobs: VERSION=$(echo ${GITHUB_REF_NAME#v} | cut -d '-' -f1) echo Version: $VERSION echo "VERSION=$VERSION" >> ${GITHUB_ENV} + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + echo "DEFGUARD_CLIENT_BUILD_VERSION=${GITHUB_REF_NAME#v}" >> ${GITHUB_ENV} + fi - uses: actions/setup-node@v6 with: - node-version: 25 + node-version-file: new-ui/.nvmrc - - uses: pnpm/action-setup@v5 + - uses: pnpm/action-setup@v6 with: - cache: true - version: 10 + run_install: false + version: 11 - - name: Install deps - run: pnpm install --frozen-lockfile + - name: Get pnpm store directory + run: | + STORE_PATH=$(pnpm store path --silent) + mkdir -p "$STORE_PATH" + echo "STORE_PATH=$STORE_PATH" >> ${GITHUB_ENV} - - uses: dtolnay/rust-toolchain@stable + - name: Restore pnpm store cache + uses: actions/cache@v5 + with: + path: ${{ env.STORE_PATH }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('new-ui/pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Install Node dependencies for New UI + run: | + cd new-ui + pnpm install --no-frozen-lockfile + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin,x86_64-apple-darwin + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Install tauri-cli + run: cargo install tauri-cli --locked + - name: Set build number run: | - sed -i '' "s,@BUILD_NUMBER@,${{ github.run_number }}," src-tauri/tauri.conf.json sed -i '' "s,@BUILD_NUMBER@,${{ github.run_number }}," swift/extension/VPNExtension.xcodeproj/project.pbxproj + sed -i '' "s,@BUILD_NUMBER@,${{ github.run_number }}," src-tauri/tauri.macos.conf.json + + - name: Build new UI + run: | + cd new-ui + pnpm build - name: Unlock keychain - run: security -v unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" login.keychain + run: | + security unlock-keychain -p "${{ secrets.BUILD_KEYCHAIN_PASSWORD }}" build.keychain - name: Build app - uses: tauri-apps/tauri-action@v0 + uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - args: --target universal-apple-darwin + tauriScript: cargo tauri + args: --ignore-version-mismatches --config src-tauri/tauri.app.conf.json --target universal-apple-darwin - name: Build installation package run: | - security -v unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" login.keychain - xcrun productbuild --sign "${{ env.APPLE_SIGNING_IDENTITY_INSTALLER }}" --component "src-tauri/target/universal-apple-darwin/release/bundle/macos/Defguard.app" /Applications defguard-client.pkg - xcrun altool --upload-app --type macos --file defguard-client.pkg --apiKey ${{ secrets.APPLE_API_KEY }} --apiIssuer ${{ secrets.APPLE_API_ISSUER }} - # xcrun notarytool submit --wait --apple-id ${{ env.APPLE_ID }} --password ${{ secrets.NOTARYTOOL_APP_SPECIFIC_PASSWORD }} --team-id ${{ env.APPLE_TEAM_ID }} defguard-client.pkg - # xcrun stapler staple defguard-client.pkg + xcrun productbuild --keychain build.keychain \ + --sign "${{ env.APPLE_SIGNING_IDENTITY_INSTALLER }}" \ + --component "src-tauri/target/universal-apple-darwin/release/bundle/macos/Defguard.app" \ + /Applications defguard-client.pkg + xcrun altool --api-key ${{ secrets.APPLE_API_KEY }} \ + --api-issuer ${{ secrets.APPLE_API_ISSUER }} \ + --upload-app --platform macos --file defguard-client.pkg --wait + + - name: Upload What's New + env: + APP_ID: "6754601166" + run: | + UPLOAD_DIR=$(mktemp -d) + mkdir -p "${UPLOAD_DIR}/beta-${APP_ID}/upload/MACOS" + git log -1 --pretty='"whatsNew" = "%B";' > "${UPLOAD_DIR}/beta-${APP_ID}/upload/MACOS/en-US.txt" + RETRIES=0 + until [ ${RETRIES} -gt 6 ] + do + xcrun altool --api-key ${{ secrets.APPLE_API_KEY }} --api-issuer ${{ secrets.APPLE_API_ISSUER }} \ + --apple-id ${APP_ID} --bundle-version ${{ github.run_number }} \--bundle-short-version-string ${VERSION} \ + --platform macos --beta-app-store-text "${UPLOAD_DIR}" --upload && break + echo "Waiting for app ${APP_ID} build ${{ github.run_number }} version ${VERSION}" + sleep 10 + ((RETRIES++)) + done + rm -f -r "${UPLOAD_DIR}" diff --git a/.github/workflows/cross-platform-check.yml b/.github/workflows/cross-platform-check.yml new file mode 100644 index 000000000..dab4ad067 --- /dev/null +++ b/.github/workflows/cross-platform-check.yml @@ -0,0 +1,63 @@ +name: Cross-platform check + +on: + push: + branches: + - main + - dev + - "release/**" + paths-ignore: + - "*.md" + - "LICENSE" + pull_request: + branches: + - main + - dev + - "release/**" + paths-ignore: + - "*.md" + - "LICENSE" + +env: + CARGO_TERM_COLOR: always + SQLX_OFFLINE: "1" + SCCACHE_GHA_ENABLED: "true" + RUSTC_WRAPPER: "sccache" + +jobs: + check: + strategy: + fail-fast: false + matrix: + os: + - macOS + - Windows + + runs-on: + - self-hosted + - ${{ matrix.os }} + + defaults: + run: + working-directory: ./src-tauri + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Install protoc + if: matrix.os != 'Windows' + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check compilation + run: cargo check --workspace --all-targets diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 000000000..8c33f3a9f --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,150 @@ +name: E2E tests + +on: + workflow_dispatch: + push: + branches: [main, dev, 'release/**'] + paths-ignore: ['*.md', 'LICENSE'] + pull_request: + branches: [main, dev, 'release/**'] + paths-ignore: ['*.md', 'LICENSE'] + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + runs-on: [self-hosted, Linux, X64] + timeout-minutes: 90 + container: + image: ubuntu:24.04 + options: --privileged + env: + DEBIAN_FRONTEND: noninteractive + CLIENT_BINARY: ${{ github.workspace }}/src-tauri/target/release/defguard-client + NATIVE_DRIVER: /usr/bin/WebKitWebDriver + SQLX_OFFLINE: '1' + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: 'true' + DEFGUARD_CLIENT_WELCOME_SKIP: '1' + # Fix for: repository path is not owned by current user. + VERGEN_GIT_SHA: 'e2e' + VERGEN_IDEMPOTENT: '1' + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install -y git curl ca-certificates build-essential \ + libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ + librsvg2-dev patchelf libssl-dev libxdo-dev protobuf-compiler \ + libprotobuf-dev webkit2gtk-driver xvfb wireguard-tools iproute2 \ + iputils-ping procps xclip desktop-file-utils xdg-utils + + - uses: actions/checkout@v7 + with: + submodules: recursive + + - uses: actions/setup-node@v6 + with: + node-version-file: new-ui/.nvmrc + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.11 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + STORE_PATH=$(pnpm store path --silent) + mkdir -p "$STORE_PATH" + echo "STORE_PATH=$STORE_PATH" >> $GITHUB_ENV + + - name: Restore pnpm store cache + uses: actions/cache@v5 + with: + path: ${{ env.STORE_PATH }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('new-ui/pnpm-lock.yaml', 'e2e/pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Install tauri-driver + run: cargo install tauri-driver --locked + + - name: Install tauri-cli + run: cargo install tauri-cli --locked + + - name: Build new-ui + run: pnpm install --no-frozen-lockfile && pnpm build + working-directory: new-ui + + - name: Build client + run: cargo tauri build + + - name: Prepare e2e environment + run: | + cp e2e/.env.example e2e/.env + echo "CORE_URL=${{ secrets.E2E_CORE_URL }}" >> e2e/.env + echo "PROXY_URL=${{ secrets.E2E_PROXY_URL }}" >> e2e/.env + echo "CORE_ADMIN_PASSWORD=${{ secrets.E2E_CORE_ADMIN_PASSWORD }}" >> e2e/.env + echo "NETWORK_ENDPOINT=${{ secrets.E2E_NETWORK_ENDPOINT }}" >> e2e/.env + + - name: Check deployment is reachable + run: curl -sf --max-time 15 "${{ secrets.E2E_CORE_URL }}/api/v1/health" + + - name: Stub resolvconf + run: | + printf '#!/bin/sh\ncat >/dev/null 2>&1 || true\nexit 0\n' > /usr/local/sbin/resolvconf + chmod +x /usr/local/sbin/resolvconf + + - name: Start defguard-service + run: | + groupadd -f defguard + modprobe wireguard || true + setsid src-tauri/target/release/defguard-service < /dev/null & + for _ in $(seq 1 40); do [ -S /var/run/defguard.socket ] && break; sleep 0.5; done + test -S /var/run/defguard.socket + + - name: Install e2e dependencies + run: pnpm install --no-frozen-lockfile + working-directory: e2e + + - name: Provision core (network + gateway check) + run: pnpm provision + working-directory: e2e + + - name: Run e2e tests + run: xvfb-run -a pnpm test + working-directory: e2e + + lint-e2e: + runs-on: + - codebuild-defguard-client-runner-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v6 + with: + node-version-file: new-ui/.nvmrc + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.11 + run_install: false + + - name: Install e2e dependencies + run: pnpm install --no-frozen-lockfile + working-directory: e2e + + - name: Lint e2e + run: pnpm lint + working-directory: e2e diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 508a6ff87..1fa1ea547 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -24,38 +24,47 @@ jobs: - codebuild-defguard-client-runner-${{ github.run_id }}-${{ github.run_attempt }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive - uses: actions/setup-node@v6 with: - node-version: '24' + node-version-file: new-ui/.nvmrc - - uses: pnpm/action-setup@v5 + - uses: pnpm/action-setup@v6 with: - version: 10 + version: 11.11 run_install: false - name: Get pnpm store directory shell: bash run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + STORE_PATH=$(pnpm store path --silent) + mkdir -p "$STORE_PATH" + echo "STORE_PATH=$STORE_PATH" >> $GITHUB_ENV - - uses: actions/cache@v5 - name: Setup pnpm cache + - name: Restore pnpm store cache + uses: actions/cache@v5 with: path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-lint-store-${{ hashFiles('**/pnpm-lock.yaml') }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('new-ui/pnpm-lock.yaml') }} restore-keys: | - ${{ runner.os }}-pnpm-lint-store- + pnpm-store-${{ runner.os }}- - - name: Install deps - run: pnpm install --frozen-lockfile + # Change to '--frozen-lockfile' once this gets fixed: + # https://github.com/pnpm/action-setup/issues/40 + - name: Install Node dependencies for new UI + run: | + cd new-ui + pnpm install --no-frozen-lockfile - - name: Run Biome and Prettier Lint - run: pnpm lint + - name: Run Biome and Prettier Lint for new UI + run: | + cd new-ui + pnpm lint - # TODO: Restore when it works again: https://github.com/pnpm/pnpm/issues/11265 - # - name: Audit - # run: pnpm audit --prod + - name: Audit new UI + run: | + cd new-ui + pnpm audit --prod diff --git a/.github/workflows/posture.yaml b/.github/workflows/posture.yaml new file mode 100644 index 000000000..87347948e --- /dev/null +++ b/.github/workflows/posture.yaml @@ -0,0 +1,169 @@ +name: "Test posture checks gathering" +on: + push: + branches: + - main + - dev + - 'release/**' + paths-ignore: + - '*.md' + - 'LICENSE' + +env: + CARGO_TERM_COLOR: always + RUSTC_WRAPPER: "sccache" + SCCACHE_GHA_ENABLED: "true" + SQLX_OFFLINE: "1" + +jobs: + + test-linux-postures-unencrypted: + if: false + name: Linux postures - unencrypted + runs-on: + - codebuild-defguard-posture-linux-runner-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + apt-get update + apt-get install -y --no-install-recommends build-essential libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libssl-dev libxdo-dev unzip protobuf-compiler libprotobuf-dev rpm cryptsetup-bin util-linux + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Run tests + working-directory: src-tauri/ + run: | + cargo test -p defguard-client-posture --locked --lib inspector::tests::ci::linux::setup1 -- --ignored + + test-linux-postures-encrypted: + name: Linux postures - encrypted + runs-on: + - self-hosted + - Linux + - virtual + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + apt-get update + apt-get install -y --no-install-recommends build-essential libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libssl-dev libxdo-dev unzip protobuf-compiler libprotobuf-dev rpm cryptsetup-bin util-linux + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Run tests + working-directory: src-tauri/ + run: | + cargo test -p defguard-client-posture --locked --lib inspector::tests::ci::linux::setup2 -- --ignored + + test-windows-postures-setup1: + name: Windows postures - setup1 + runs-on: + - self-hosted + - X64 + - Windows + - noad + steps: + + - uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Run tests + working-directory: src-tauri/ + shell: cmd + run: | + cargo test -p defguard-client-posture --locked --lib inspector::tests::ci::windows::setup1 -- --ignored + + test-windows-postures-setup2: + name: Windows postures - setup2 + runs-on: + - self-hosted + - X64 + - Windows + - ad + steps: + + - uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Run tests + working-directory: src-tauri/ + shell: cmd + run: | + cargo test -p defguard-client-posture --locked --lib inspector::tests::ci::windows::setup2 -- --ignored + + test-macos-postures-unencrypted: + name: macOS postures - unencrypted + runs-on: + - self-hosted + - macOS + - native + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Run tests + working-directory: src-tauri/ + run: | + cargo test -p defguard-client-posture --locked --lib inspector::tests::ci::macos::setup1 -- --ignored + + test-macos-postures-encrypted: + name: macOS postures - encrypted + runs-on: + - self-hosted + - macOS + - virtual + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Run tests + working-directory: src-tauri/ + run: | + cargo test -p defguard-client-posture --locked --lib inspector::tests::ci::macos::setup2 -- --ignored diff --git a/.github/workflows/release-macos.yaml b/.github/workflows/release-macos.yaml new file mode 100644 index 000000000..732eb5faa --- /dev/null +++ b/.github/workflows/release-macos.yaml @@ -0,0 +1,109 @@ +name: Build macOS dmg +on: + workflow_call: + inputs: + upload_url: + description: 'Upload URL for release' + required: true + type: string + +env: + SCCACHE_GHA_ENABLED: "true" + RUSTC_WRAPPER: "sccache" + SQLX_OFFLINE: "1" + +jobs: + build-macos-dmg: + runs-on: + - self-hosted + - macOS + - native + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Write release version + run: | + VERSION=$(echo ${GITHUB_REF_NAME#v} | cut -d '-' -f1) + echo Version: $VERSION + echo "VERSION=$VERSION" >> ${GITHUB_ENV} + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + echo "DEFGUARD_CLIENT_BUILD_VERSION=${GITHUB_REF_NAME#v}" >> ${GITHUB_ENV} + fi + + - uses: actions/setup-node@v6 + with: + node-version-file: new-ui/.nvmrc + + - uses: pnpm/action-setup@v6 + with: + run_install: false + version: 11.11 + + - name: Get pnpm store directory + run: | + STORE_PATH=$(pnpm store path --silent) + mkdir -p "$STORE_PATH" + echo "STORE_PATH=$STORE_PATH" >> ${GITHUB_ENV} + + - name: Restore pnpm store cache + uses: actions/cache@v5 + with: + path: ${{ env.STORE_PATH }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('new-ui/pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Install Node dependencies for New UI + run: | + cd new-ui + pnpm install --no-frozen-lockfile + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Install tauri-cli + run: cargo install tauri-cli --locked + + - name: Set build number + run: | + sed -i '' "s,@BUILD_NUMBER@,${{ github.run_number }}," swift/extension/VPNExtension.xcodeproj/project.pbxproj + sed -i '' "s,@BUILD_NUMBER@,${{ github.run_number }}," src-tauri/tauri.macos.conf.json + + - name: Build new UI + run: | + cd new-ui + pnpm build + + - name: Unlock keychain + run: | + security unlock-keychain -p "${{ secrets.BUILD_KEYCHAIN_PASSWORD }}" build.keychain + + - name: Build app + uses: tauri-apps/tauri-action@v1 + env: + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_SIGNING_IDENTITY: "Developer ID Application: defguard sp. z o.o. (82GZ7KN29J)" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # https://github.com/tauri-apps/tauri-action/issues/1003 + TAURI_BUNDLER_DMG_IGNORE_CI: "false" + with: + tauriScript: cargo tauri + args: --ignore-version-mismatches --config src-tauri/tauri.dmg.conf.json --target universal-apple-darwin + + - name: Upload DMG + uses: shogo82148/actions-upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ inputs.upload_url }} + asset_path: src-tauri/target/universal-apple-darwin/release/bundle/dmg/Defguard_${{ env.VERSION }}_universal.dmg + asset_content_type: application/x-apple-diskimage + overwrite: true diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 39924ac2c..c9d5c7f55 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,6 +3,8 @@ on: push: tags: - v*.*.* +env: + SQLX_OFFLINE: "1" jobs: create-release: @@ -13,7 +15,7 @@ jobs: steps: - name: Create GitHub release id: release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: draft: true generate_release_notes: true @@ -24,6 +26,7 @@ jobs: uses: ./.github/workflows/sbom.yml with: upload_url: ${{ needs.create-release.outputs.upload_url }} + ubuntu-22-04-build: needs: - create-release @@ -50,50 +53,74 @@ jobs: RUSTUP_HOME: /root/.rustup CARGO_HOME: /root/.cargo steps: - - name: git install + - name: Install system dependencies run: | apt-get update - apt-get install -y git curl ca-certificates + apt-get install -y git curl ca-certificates libatomic1 build-essential libgtk-3-dev \ + libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libssl-dev \ + libxdo-dev unzip protobuf-compiler libprotobuf-dev rpm git config --global --add safe.directory '*' - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive - - uses: pnpm/action-setup@v5 - with: - version: 10 - run_install: false + - uses: actions/setup-node@v6 with: - node-version: "24" + node-version-file: new-ui/.nvmrc + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.11 + run_install: false + - name: Get pnpm store directory run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> ${GITHUB_ENV} + STORE_PATH=$(pnpm store path --silent) + mkdir -p "$STORE_PATH" + echo "STORE_PATH=$STORE_PATH" >> ${GITHUB_ENV} + + - name: Restore pnpm store cache + uses: actions/cache@v5 + with: + path: ${{ env.STORE_PATH }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('new-ui/pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + - name: Write release version run: | VERSION=$(echo ${GITHUB_REF_NAME#v} | cut -d '-' -f1) echo Version: $VERSION echo "VERSION=$VERSION" >> ${GITHUB_ENV} - - uses: actions/cache@v5 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-build-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-build-store- - - name: Install Node dependencies - run: pnpm install --frozen-lockfile - - uses: dtolnay/rust-toolchain@stable - - name: Install dependencies + echo "DEFGUARD_CLIENT_BUILD_VERSION=${GITHUB_REF_NAME#v}" >> ${GITHUB_ENV} + + - name: Install Node dependencies for new UI run: | - apt-get install -y build-essential libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libssl-dev libxdo-dev unzip protobuf-compiler libprotobuf-dev rpm + cd new-ui + pnpm install --no-frozen-lockfile + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install tauri-cli + run: cargo install tauri-cli --locked + + - name: Build new UI + run: | + cd new-ui + pnpm build + - name: Build packages - uses: tauri-apps/tauri-action@v0.5.23 + uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - args: "--bundles deb" + tauriScript: cargo tauri + args: --ignore-version-mismatches --bundles deb + - name: Upload DEB - uses: actions/upload-release-asset@v1 + uses: shogo82148/actions-upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -101,21 +128,23 @@ jobs: asset_path: src-tauri/target/release/bundle/deb/defguard-client_${{ env.VERSION }}_${{ matrix.deb_arch }}.deb asset_name: defguard-client${{ env.VERSION }}_${{ matrix.deb_arch }}_ubuntu-22-04-lts.deb asset_content_type: application/octet-stream + - name: Rename dg binary run: mv src-tauri/target/release/dg dg-linux-${{ env.VERSION }}_${{ matrix.deb_arch }} + - name: Build dg deb uses: defGuard/fpm-action@main with: fpm_args: "dg-linux-${{ env.VERSION }}_${{ matrix.deb_arch }}=/usr/sbin/dg dg.service=/usr/lib/systemd/system/dg.service src-tauri/cli/.env=/etc/defguard/dg.conf" fpm_opts: "--architecture ${{ matrix.binary_arch }} --debug --output-type deb --version ${{ env.VERSION }} --package dg-linux-${{ env.VERSION }}_${{ matrix.deb_arch }}_ubuntu-22-04-lts.deb" + - name: Upload DEB - uses: actions/upload-release-asset@v1 + uses: shogo82148/actions-upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: dg-linux-${{ env.VERSION }}_${{ matrix.deb_arch }}_ubuntu-22-04-lts.deb - asset_name: dg-linux-${{ env.VERSION }}_${{ matrix.deb_arch }}_ubuntu-22-04-lts.deb asset_content_type: application/octet-stream build-linux: @@ -139,7 +168,7 @@ jobs: deb_arch: amd64 binary_arch: x86_64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive - name: Write release version @@ -147,37 +176,61 @@ jobs: VERSION=$(echo ${GITHUB_REF_NAME#v} | cut -d '-' -f1) echo Version: $VERSION echo "VERSION=$VERSION" >> ${GITHUB_ENV} + echo "DEFGUARD_CLIENT_BUILD_VERSION=${GITHUB_REF_NAME#v}" >> ${GITHUB_ENV} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ + librsvg2-dev patchelf libssl-dev libxdo-dev unzip protobuf-compiler libprotobuf-dev rpm - uses: actions/setup-node@v6 with: - node-version: "24" - - uses: pnpm/action-setup@v5 + node-version-file: new-ui/.nvmrc + + - uses: pnpm/action-setup@v6 with: - version: 10 + version: 11.11 run_install: false + - name: Get pnpm store directory shell: bash run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> ${GITHUB_ENV} - - uses: actions/cache@v5 - name: Setup pnpm cache + STORE_PATH=$(pnpm store path --silent) + mkdir -p "$STORE_PATH" + echo "STORE_PATH=$STORE_PATH" >> ${GITHUB_ENV} + + - name: Restore pnpm store cache + uses: actions/cache@v5 with: path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-build-store-${{ hashFiles('**/pnpm-lock.yaml') }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('new-ui/pnpm-lock.yaml') }} restore-keys: | - ${{ runner.os }}-pnpm-build-store- - - name: Install Node dependencies - run: pnpm install --frozen-lockfile - - uses: dtolnay/rust-toolchain@stable - - name: Install Linux dependencies + pnpm-store-${{ runner.os }}- + + - name: Install Node dependencies for new UI run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libssl-dev libxdo-dev unzip protobuf-compiler libprotobuf-dev rpm + cd new-ui + pnpm install --no-frozen-lockfile + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install tauri-cli + run: cargo install tauri-cli --locked + + - name: Build new UI + run: | + cd new-ui + pnpm build + - name: Build packages - uses: tauri-apps/tauri-action@v0.5.23 # .24 seems broken, TODO: update when fixed + uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - args: "--bundles deb,rpm" + tauriScript: cargo tauri + args: --ignore-version-mismatches --bundles deb,rpm + - name: Calculate DEB SHA256 id: calculate-sha256 if: matrix.deb_arch == 'amd64' @@ -187,201 +240,253 @@ jobs: echo "DEB SHA256: $DEB_SHA256" echo "DEB_SHA256=$DEB_SHA256" >> ${GITHUB_ENV} echo "deb_sha256_${{ matrix.deb_arch }}=$DEB_SHA256" >> ${GITHUB_OUTPUT} + - name: Upload RPM - uses: actions/upload-release-asset@v1 + uses: shogo82148/actions-upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: src-tauri/target/release/bundle/rpm/defguard-client-${{ env.VERSION }}-1.${{ matrix.binary_arch }}.rpm - asset_name: defguard-client-${{ env.VERSION }}-1.${{ matrix.binary_arch }}.rpm asset_content_type: application/octet-stream + - name: Upload DEB - uses: actions/upload-release-asset@v1 + uses: shogo82148/actions-upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: src-tauri/target/release/bundle/deb/defguard-client_${{ env.VERSION }}_${{ matrix.deb_arch }}.deb - asset_name: defguard-client_${{ env.VERSION }}_${{ matrix.deb_arch }}.deb asset_content_type: application/octet-stream - - name: Rename client binary - run: mv src-tauri/target/release/defguard-client defguard-client-linux-${{ matrix.binary_arch }}-${{ github.ref_name }} - - name: Tar client binary - uses: a7ul/tar-action@v1.2.0 - with: - command: c - files: | + + - name: Rename and tar client binary + run: | + mv src-tauri/target/release/defguard-client defguard-client-linux-${{ matrix.binary_arch }}-${{ github.ref_name }} + tar -zcf defguard-client-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz \ defguard-client-linux-${{ matrix.binary_arch }}-${{ github.ref_name }} - outPath: defguard-client-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz + - name: Upload client archive - uses: actions/upload-release-asset@v1 + uses: shogo82148/actions-upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: defguard-client-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz - asset_name: defguard-client-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz asset_content_type: application/octet-stream - - name: Rename daemon binary - run: mv src-tauri/target/release/defguard-service defguard-service-linux-${{ matrix.binary_arch }}-${{ github.ref_name }} - - name: Tar daemon binary - uses: a7ul/tar-action@v1.2.0 - with: - command: c - files: | + + - name: Rename and tar daemon binary + run: | + mv src-tauri/target/release/defguard-service defguard-service-linux-${{ matrix.binary_arch }}-${{ github.ref_name }} + tar -zcf defguard-service-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz \ defguard-service-linux-${{ matrix.binary_arch }}-${{ github.ref_name }} - outPath: defguard-service-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz + - name: Upload daemon archive - uses: actions/upload-release-asset@v1 + uses: shogo82148/actions-upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: defguard-service-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz - asset_name: defguard-service-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz asset_content_type: application/octet-stream - - name: Rename dg binary - run: mv src-tauri/target/release/dg dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }} - - name: Tar dg binary - uses: a7ul/tar-action@v1.2.0 - with: - command: c - files: | + - name: Rename and tar dg binary + run: | + mv src-tauri/target/release/dg dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }} + tar -zcf dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz \ dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }} - outPath: dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz + - name: Upload dg archive - uses: actions/upload-release-asset@v1 + uses: shogo82148/actions-upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz - asset_name: dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.tar.gz asset_content_type: application/octet-stream + - name: Build dg deb uses: defGuard/fpm-action@main with: fpm_args: "dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}=/usr/sbin/dg dg.service=/usr/lib/systemd/system/dg.service src-tauri/cli/.env=/etc/defguard/dg.conf" fpm_opts: "--architecture ${{ matrix.binary_arch }} --debug --output-type deb --version ${{ env.VERSION }} --package dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.deb" + - name: Upload DEB - uses: actions/upload-release-asset@v1.0.2 + uses: shogo82148/actions-upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.deb - asset_name: dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.deb asset_content_type: application/octet-stream + - name: Build dg rpm uses: defGuard/fpm-action@main with: fpm_args: "dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}=/usr/sbin/dg dg.service=/usr/lib/systemd/system/dg.service src-tauri/cli/.env=/etc/defguard/dg.conf" fpm_opts: "--architecture ${{ matrix.binary_arch }} --debug --output-type rpm --version ${{ env.VERSION }} --package dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.rpm" + - name: Upload RPM - uses: actions/upload-release-asset@v1.0.2 + uses: shogo82148/actions-upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.rpm - asset_name: dg-linux-${{ matrix.binary_arch }}-${{ github.ref_name }}.rpm asset_content_type: application/octet-stream - # Builds Windows MSI and uploads it as artifact - build-windows: + # Dedicated dg build for AlmaLinux 9 (and the RHEL 9 family: Rocky 9, RHEL 9). + # The default dg RPM is built against a newer glibc/OpenSSL and fails to run on + # Alma 9, so dg is compiled inside an almalinux:9 container to link Alma's + # glibc 2.34 and OpenSSL 3, then packaged with fpm. x86_64 only. + build-dg-alma9: needs: - create-release - strategy: - fail-fast: false - matrix: - windows_runner: - - windows-latest - - windows-11-arm - include: - - windows_runner: windows-latest - cpu: x64 - - windows_runner: windows-11-arm - cpu: arm64 - runs-on: ${{ matrix.windows_runner }} - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - - name: Write release version - run: | - $env:VERSION=echo ($env:GITHUB_REF_NAME.Substring(1) -Split "-")[0] - echo Version: $env:VERSION - echo "VERSION=$env:VERSION" >> $env:GITHUB_ENV - - uses: actions/setup-node@v6 - with: - node-version: "24" - - uses: pnpm/action-setup@v5 - with: - version: 10 - run_install: false - - name: Get pnpm store directory - shell: bash - run: echo "STORE_PATH=$(pnpm store path --silent)" >> ${GITHUB_ENV} - - uses: actions/cache@v5 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-build-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-build-store- - - name: Install deps - run: pnpm install --frozen-lockfile - - uses: dtolnay/rust-toolchain@stable - - name: Install Protoc - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build packages - uses: tauri-apps/tauri-action@v0.5.23 # 0.5.24 - 0.6.1 give: Error: Could not find workspace directory, but version and/or name specifies to use workspace package - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload unsigned bundle - uses: actions/upload-artifact@v4 - with: - name: unsigned-bundle-${{ matrix.cpu }} - path: src-tauri/target/release/bundle/msi/Defguard_${{ env.VERSION }}_${{ matrix.cpu }}_en-US.msi - - # Signs the MSI and uploads it as release asset - sign-bundle: - needs: - - create-release - - build-windows - strategy: - fail-fast: false - matrix: - # Match CPUs from build-windows above. - cpu: - - x64 - - arm64 runs-on: - self-hosted - Linux - X64 + container: + image: almalinux:9 + env: + HOME: /root + RUSTUP_HOME: /root/.rustup + CARGO_HOME: /root/.cargo + SQLX_OFFLINE: "true" steps: + - name: Install build prerequisites + run: | + dnf -y install git gcc gcc-c++ make openssl-devel perl pkgconfig unzip + git config --global --add safe.directory '*' + - uses: actions/checkout@v7 + with: + submodules: recursive - name: Write release version run: | VERSION=$(echo ${GITHUB_REF_NAME#v} | cut -d '-' -f1) echo Version: $VERSION echo "VERSION=$VERSION" >> ${GITHUB_ENV} - - name: Download unsigned bundle - uses: actions/download-artifact@v4 + - name: Install protoc + run: | + PB_REL='https://github.com/protocolbuffers/protobuf/releases' + PB_VERSION='3.20.0' + curl -LO $PB_REL/download/v$PB_VERSION/protoc-$PB_VERSION-linux-x86_64.zip + unzip -o protoc-$PB_VERSION-linux-x86_64.zip bin/protoc 'include/google/*' -d /usr/local + echo "PROTOC=/usr/local/bin/protoc" >> ${GITHUB_ENV} + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + - name: Build dg (AlmaLinux 9) + run: cargo build --release --manifest-path src-tauri/Cargo.toml -p defguard-dg --bin dg + - name: Rename dg binary + run: mv src-tauri/target/release/dg dg-linux-x86_64-${{ github.ref_name }} + - name: Build dg rpm + uses: defGuard/fpm-action@main with: - name: unsigned-bundle-${{ matrix.cpu }} - - name: Sign bundle - run: osslsigncode sign -pkcs11module /srv/codesign/certum/sc30pkcs11-3.0.6.72-MS.so -pkcs11cert ${{ secrets.CODESIGN_KEYID }} -key ${{ secrets.CODESIGN_KEYID }} -pass ${{ secrets.CODESIGN_PIN }} -h sha256 -t http://time.certum.pl/ -in Defguard_${{ env.VERSION }}_${{ matrix.cpu }}_en-US.msi -out Defguard-signed.msi - - name: Upload installer asset - uses: actions/upload-release-asset@v1 + fpm_args: "dg-linux-x86_64-${{ github.ref_name }}=/usr/sbin/dg dg.service=/usr/lib/systemd/system/dg.service src-tauri/cli/.env=/etc/defguard/dg.conf" + fpm_opts: "--architecture x86_64 --debug --output-type rpm --version ${{ env.VERSION }} --no-auto-depends --depends openssl-libs --depends wireguard-tools --package dg-linux-x86_64-${{ github.ref_name }}-el9.rpm" + - name: Upload RPM + uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: Defguard-signed.msi - asset_name: Defguard_${{ env.VERSION }}_${{ matrix.cpu }}_en-US.msi + asset_path: dg-linux-x86_64-${{ github.ref_name }}-el9.rpm + asset_name: dg-linux-x86_64-${{ github.ref_name }}-el9.rpm asset_content_type: application/octet-stream + + build-macos: + needs: + - create-release + uses: ./.github/workflows/release-macos.yaml + secrets: inherit + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + + # Builds Windows MSI and uploads it as artifact + # build-windows: + # needs: + # - create-release + # strategy: + # fail-fast: false + # matrix: + # windows_runner: + # - windows-latest + # - windows-11-arm + # include: + # - windows_runner: windows-latest + # cpu: x64 + # - windows_runner: windows-11-arm + # cpu: arm64 + # runs-on: ${{ matrix.windows_runner }} + # steps: + # - uses: actions/checkout@v7 + # with: + # submodules: recursive + # - name: Write release version + # run: | + # $env:VERSION=echo ($env:GITHUB_REF_NAME.Substring(1) -Split "-")[0] + # echo Version: $env:VERSION + # echo "VERSION=$env:VERSION" >> $env:GITHUB_ENV + # - uses: actions/setup-node@v6 + # with: + # node-version: 26 + # - uses: pnpm/action-setup@v6 + # with: + # version: 11 + # run_install: false + # - name: Get pnpm store directory + # shell: bash + # run: echo "STORE_PATH=$(pnpm store path --silent)" >> ${GITHUB_ENV} + # - name: Install deps + # run: pnpm install --frozen-lockfile + # - uses: dtolnay/rust-toolchain@stable + # - name: Install Protoc + # uses: arduino/setup-protoc@v3 + # with: + # repo-token: ${{ secrets.GITHUB_TOKEN }} + # - name: Build packages + # uses: tauri-apps/tauri-action@v1 + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # - name: Upload unsigned bundle + # uses: actions/upload-artifact@v4 + # with: + # name: unsigned-bundle-${{ matrix.cpu }} + # path: src-tauri/target/release/bundle/msi/Defguard_${{ env.VERSION }}_${{ matrix.cpu }}_en-US.msi + + # Signs the MSI and uploads it as release asset + # sign-bundle: + # needs: + # - create-release + # - build-windows + # strategy: + # fail-fast: false + # matrix: + # # Match CPUs from build-windows above. + # cpu: + # - x64 + # - arm64 + # runs-on: + # - self-hosted + # - Linux + # - X64 + # steps: + # - name: Write release version + # run: | + # VERSION=$(echo ${GITHUB_REF_NAME#v} | cut -d '-' -f1) + # echo Version: $VERSION + # echo "VERSION=$VERSION" >> ${GITHUB_ENV} + # - name: Download unsigned bundle + # uses: actions/download-artifact@v4 + # with: + # name: unsigned-bundle-${{ matrix.cpu }} + # - name: Sign bundle + # run: osslsigncode sign -pkcs11module /srv/codesign/certum/sc30pkcs11-3.0.6.72-MS.so -pkcs11cert ${{ secrets.CODESIGN_KEYID }} -key ${{ secrets.CODESIGN_KEYID }} -pass ${{ secrets.CODESIGN_PIN }} -h sha256 -t http://time.certum.pl/ -in Defguard_${{ env.VERSION }}_${{ matrix.cpu }}_en-US.msi -out Defguard-signed.msi + # - name: Upload installer asset + # uses: shogo82148/actions-upload-release-asset@v1 + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # with: + # upload_url: ${{ needs.create-release.outputs.upload_url }} + # asset_path: Defguard-signed.msi + # asset_name: Defguard_${{ env.VERSION }}_${{ matrix.cpu }}_en-US.msi + # asset_content_type: application/octet-stream diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 64db1184b..c0279f2cc 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -27,12 +27,12 @@ jobs: echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive - name: Create SBOM with Trivy - uses: aquasecurity/trivy-action@0.35.0 + uses: aquasecurity/trivy-action@v0.36.0 env: TRIVY_SHOW_SUPPRESSED: 1 TRIVY_IGNOREFILE: "./.trivyignore.yaml" @@ -45,7 +45,7 @@ jobs: scanners: "vuln" - name: Create security advisory file with Trivy - uses: aquasecurity/trivy-action@0.35.0 + uses: aquasecurity/trivy-action@v0.36.0 env: TRIVY_SHOW_SUPPRESSED: 1 TRIVY_IGNOREFILE: "./.trivyignore.yaml" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38dbb2846..f65c31ee3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,7 @@ on: env: CARGO_TERM_COLOR: always + SQLX_OFFLINE: "1" # sccache SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" @@ -39,12 +40,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive - name: Scan code with Trivy - uses: aquasecurity/trivy-action@0.35.0 + uses: aquasecurity/trivy-action@v0.36.0 env: TRIVY_SHOW_SUPPRESSED: 1 TRIVY_IGNOREFILE: "./.trivyignore.yaml" @@ -57,7 +58,7 @@ jobs: scanners: "vuln" - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 + uses: mozilla-actions/sccache-action@v0.0.10 - name: Install required packages run: | @@ -67,22 +68,22 @@ jobs: - name: Check format run: | rustup component add rustfmt - cargo fmt -- --check + cargo fmt --all -- --check - name: Run clippy linter run: | mkdir ../dist rustup component add clippy - cargo clippy --all-targets --all-features -- -D warnings + cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Install cargo extensions uses: taiki-e/install-action@v2 with: - tool: cargo-deny + tool: cargo-deny,cargo-nextest - name: Run cargo deny working-directory: ./src-tauri - run: cargo deny check + run: cargo deny check --hide-inclusion-graph - name: Run tests - run: cargo test --locked --no-fail-fast + run: cargo nextest run --workspace --locked --no-fail-fast diff --git a/.github/workflows/update-pnpm-hash.yaml b/.github/workflows/update-pnpm-hash.yaml new file mode 100644 index 000000000..c7b935ef1 --- /dev/null +++ b/.github/workflows/update-pnpm-hash.yaml @@ -0,0 +1,127 @@ +name: Update pnpm deps Nix hash + +on: + pull_request: + paths: + - new-ui/pnpm-lock.yaml + - new-ui/.nvmrc + - nix/package.nix + - nix/versions.nix + +concurrency: + group: pnpm-hash-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + update-pnpm-hash: + runs-on: + - codebuild-defguard-client-runner-${{ github.run_id }}-${{ github.run_attempt }} + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + submodules: recursive + + - uses: cachix/install-nix-action@v31 + with: + install_options: --no-daemon + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Compute correct pnpm deps hashes + id: hash + run: | + set -euo pipefail + + FAKE="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + SYSTEM=$(nix eval --impure --raw --expr 'builtins.currentSystem') + + # Helper: extract the hash for a given attribute, swap in FAKE, + # build it, capture the real hash, and write it back. + update_hash() { + local attr="$1" # e.g. "newUiPnpmDeps" + local label="$2" # human-readable label for logging + + # Extract the current hash for this attribute. + # Match the line containing "hash = " that follows the + # attribute definition block (${attr} ... = fetchPnpmDeps). + local CURRENT + CURRENT=$(awk "/^[[:space:]]*${attr} = fetchPnpmDeps/,/^[[:space:]]*};[[:space:]]*\$/" nix/package.nix \ + | sed -n 's/^[[:space:]]*hash = "\(sha256-[^"]*\)".*/\1/p' | head -1) + + if [ -z "$CURRENT" ]; then + echo "::error::Could not extract current hash for ${attr} from nix/package.nix" + exit 1 + fi + echo "${label} current hash: ${CURRENT}" + echo "${attr}_current=${CURRENT}" >> "$GITHUB_OUTPUT" + + # Swap in the fake hash. + sed -i "/^[[:space:]]*${attr} = fetchPnpmDeps/,/^[[:space:]]*};[[:space:]]*\$/{s|hash = \"${CURRENT}\"|hash = \"${FAKE}\"|}" nix/package.nix + + # Build only this fixed-output derivation. + echo "building ${attr} for ${SYSTEM}..." + local BUILD_LOG + BUILD_LOG=$(nix build --no-link --no-write-lock-file \ + ".#packages.${SYSTEM}.default.${attr}" 2>&1 || true) + + # Nix prints "got: sha256-..." in the hash mismatch error. + local NEW + NEW=$(printf '%s' "$BUILD_LOG" | sed -n 's/.*got:[[:space:]]*\(sha256-[^[:space:]]*\).*/\1/p' | head -1) + if [ -z "$NEW" ]; then + echo "::error::Could not extract the correct hash for ${attr} from nix output." + echo "Full build log:" + printf '%s\n' "$BUILD_LOG" + exit 1 + fi + + echo "${label} new hash: ${NEW}" + echo "${attr}_new=${NEW}" >> "$GITHUB_OUTPUT" + + # Write the correct hash back. + sed -i "/^[[:space:]]*${attr} = fetchPnpmDeps/,/^[[:space:]]*};[[:space:]]*\$/{s|hash = \"${FAKE}\"|hash = \"${NEW}\"|}" nix/package.nix + + # Track whether this hash changed. + if [ "$CURRENT" != "$NEW" ]; then + echo "${attr}_changed=true" >> "$GITHUB_OUTPUT" + else + echo "${attr}_changed=false" >> "$GITHUB_OUTPUT" + fi + } + + update_hash "newUiPnpmDeps" "pnpm new-ui" + + - name: Commit updated hashes + if: steps.hash.outputs.newUiPnpmDeps_changed == 'true' + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const content = fs.readFileSync('nix/package.nix', 'utf8'); + const encoded = Buffer.from(content).toString('base64'); + + const headline = 'chore(nix): update new-ui pnpm deps hash'; + + await github.graphql(` + mutation CreateCommit($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { + commit { url } + } + } + `, { + input: { + branch: { + repositoryNameWithOwner: `${context.repo.owner}/${context.repo.repo}`, + branchName: context.payload.pull_request.head.ref, + }, + message: { headline }, + fileChanges: { + additions: [{ path: 'nix/package.nix', contents: encoded }], + }, + expectedHeadOid: context.payload.pull_request.head.sha, + }, + }); diff --git a/.github/workflows/update-repositories.yml b/.github/workflows/update-repositories.yml index 8ba67a24d..9f610a2e6 100644 --- a/.github/workflows/update-repositories.yml +++ b/.github/workflows/update-repositories.yml @@ -14,7 +14,7 @@ jobs: amd64_sha: ${{ steps.get_sha.outputs.AMD64_SHA }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive @@ -46,9 +46,9 @@ jobs: - name: Upload DEB to APT repository run: | if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then - component="pre-release" + component="pre-release-2.0" else - component="release" + component="release-2.0" fi for deb_file in debs/*.deb; do diff --git a/.gitignore b/.gitignore index 3f17dab15..95eef0d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ src-tauri/gen/ # nix stuff result + +target diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2f5838ac5..000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v20.5 diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 13a310746..000000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -/src/i18n/*.ts -/src/i18n/*.tsx diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 9a50f221f..000000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "tabWidth": 2, - "singleQuote": true, - "useTabs": false, - "printWidth": 90 -} diff --git a/.trivyignore.yaml b/.trivyignore.yaml index 67f4f5026..0f163d54b 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -1,4 +1,4 @@ vulnerabilities: - id: GHSA-wrw7-89jp-8q8g - expired_at: 2026-05-16 - statement: 'glib is a transitive dependency of Tauri which we cannot update ourselves. Waiting for tauri to finish migration to gtk4-rs: https://github.com/tauri-apps/tauri/issues/12563' + expired_at: 2026-09-30 + statement: "glib is a transitive dependency of Tauri which we cannot update ourselves. Waiting for tauri to finish migration to gtk4-rs: https://github.com/tauri-apps/tauri/issues/12563" diff --git a/.typesafe-i18n.json b/.typesafe-i18n.json deleted file mode 100644 index b3224e1e2..000000000 --- a/.typesafe-i18n.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "adapter": "react", - "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json" -} \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index e071cb42e..caf5ae033 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,8 +1,8 @@ # Dual license info The code in this repository is available under a dual licensing model: -1. Open Source License: The code, except for the contents of the "src-tauri/src/enterprise/" directory, is licensed under the AGPL license (this license). This applies to the open core components of the software. -2. Enterprise License: All code in this repository (including within the "src-tauri/src/enterprise/" directory) is licensed under a separate Enterprise License (see file src/enterprise/LICENSE.md). +1. Open Source License: The code, except for the contents of the "src-tauri/enterprise/" directory, is licensed under the AGPL license (this license). This applies to the open core components of the software. +2. Enterprise License: All code in this repository (including within the "src-tauri/enterprise/" directory) is licensed under a separate Enterprise License (see file src-tauri/enterprise/LICENSE.md). # GNU AFFERO GENERAL PUBLIC LICENSE diff --git a/README.md b/README.md index fa1e4a5f6..01ca15698 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,14 @@ Make sure you have [protoc](https://grpc.io/docs/protoc-installation/) available ### Install pnpm and node deps ```bash +cd new-ui pnpm install ``` ### Sqlx and local database file To work with sqlx on a local db file, you'll have to set `DATABASE_URL` env variable. -It's best to set it to absolute path since `pnpm tauri dev` runs with weird paths. +It's best to set it to absolute path since `cargo tauri dev` runs with weird paths. Init the file with: @@ -56,14 +57,19 @@ Then keep the `$DATABASE_URL` set during development (use direnv etc.) ### Dev server command +Run the new UI's dev server and the Tauri app in parallel (see `justfile`'s `dev` recipe for a one-liner): + ```bash -pnpm tauri dev +cd new-ui && pnpm dev +# in another terminal +cargo tauri dev ``` ### Build command ```bash -pnpm tauri build +cd new-ui && pnpm build +cargo tauri build ``` Built packages are available after in `src-tauri/target/release/bundle`. @@ -117,24 +123,33 @@ For details, see: ## Failed to bundle project -`pnpm tauri build` may fail with error: `Error failed to bundle project: error running appimage.sh`. To +`cargo tauri build` may fail with error: `Error failed to bundle project: error running appimage.sh`. To fix this set the NO_STRIP environment variable: ``` -NO_STRIP=1 pnpm tauri build +NO_STRIP=1 cargo tauri build ``` ## Blank screen -The app launches but the window is blank. Set the `WEBKIT_DISABLE_DMABUF_RENDERER` environment variable: +On Linux NVIDIA setups, the app automatically applies the WebKitGTK DMA-BUF workaround +`WEBKIT_DISABLE_DMABUF_RENDERER=1` before the webview starts. On NVIDIA + Wayland setups, +it also applies `__NV_DISABLE_EXPLICIT_SYNC=1`. If the app still launches with a blank window, +set the DMA-BUF workaround manually: ``` WEBKIT_DISABLE_DMABUF_RENDERER=1 defguard-client ``` -## Failed to run `pnpm tauri dev` +As a last resort for resize crashes or persistent rendering issues, disable accelerated compositing: + +``` +WEBKIT_DISABLE_COMPOSITING_MODE=1 defguard-client +``` + +## Failed to run `cargo tauri dev` -`pnpm tauri dev` command may result in the following error: +`cargo tauri dev` command may result in the following error if `new-ui`'s node_modules are stale: ``` Error [ERR_REQUIRE_ESM]: require() of ES Module /home/jck/workspace/work/teonite/defguard/client/node_modules/.pnpm/path-type@5.0.0/node_modules/path-type/index.js from /home/jck/workspace/work/teonite/defguard/client/node_modules/.pnpm/read-pkg@3.0.0/node_modules/read-pkg/index.js not supported. @@ -150,4 +165,4 @@ Node.js v22.7.0  ELIFECYCLE  Command failed with exit code 1. ``` -To fix this remove node_modules and rerun `pnpm install`. +To fix this remove `new-ui/node_modules` and rerun `pnpm install` in `new-ui`. diff --git a/biome.json b/biome.json deleted file mode 100644 index 92a029b6f..000000000 --- a/biome.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "ignoreUnknown": false, - "includes": [ - "src/**", - "!src/i18n/*.ts", - "!src/i18n/*.tsx", - "!src/i18n/i18n-util", - "!dist" - ] - }, - "formatter": { - "enabled": true, - "formatWithErrors": false, - "indentStyle": "space", - "indentWidth": 2, - "lineEnding": "lf", - "lineWidth": 90, - "attributePosition": "auto", - "bracketSameLine": false, - "bracketSpacing": true, - "expand": "auto", - "useEditorconfig": true, - "includes": [ - "./src/**" - ] - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "a11y": "off", - "complexity": { - "noBannedTypes": "error", - "noUselessTypeConstraint": "error" - }, - "correctness": { - "useUniqueElementIds": "off", - "noChildrenProp": "error", - "noPrecisionLoss": "error", - "noUnusedVariables": "error", - "useExhaustiveDependencies": "error", - "useHookAtTopLevel": "error", - "useJsxKeyInIterable": "error" - }, - "security": { - "noDangerouslySetInnerHtmlWithChildren": "error" - }, - "style": { - "noNamespace": "error", - "noNonNullAssertion": "error", - "useArrayLiterals": "error", - "useAsConstAssertion": "error", - "useBlockStatements": "off", - "useLiteralEnumMembers": "off" - }, - "suspicious": { - "noCommentText": "error", - "noDuplicateJsxProps": "error", - "noExplicitAny": "error", - "noExtraNonNullAssertion": "error", - "noMisleadingInstantiator": "error", - "noUnsafeDeclarationMerging": "error", - "noArrayIndexKey": "off" - } - }, - "includes": [ - "src/**" - ] - }, - "javascript": { - "formatter": { - "jsxQuoteStyle": "double", - "quoteProperties": "asNeeded", - "trailingCommas": "all", - "semicolons": "always", - "arrowParentheses": "always", - "bracketSameLine": false, - "quoteStyle": "single", - "attributePosition": "auto", - "bracketSpacing": true - } - }, - "html": { - "formatter": { - "selfCloseVoidElements": "always" - } - }, - "overrides": [ - { - "includes": [ - "**/*.js" - ] - } - ], - "assist": { - "enabled": true, - "actions": { - "source": { - "organizeImports": "on" - } - } - } -} diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 000000000..914a830c8 --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,11 @@ +CORE_URL= +PROXY_URL= +CORE_ADMIN_USER=admin +CORE_ADMIN_PASSWORD= +TEST_USERNAME=e2e_test_user +GATEWAY_VPN_IP=10.10.10.1 +NETWORK_ENDPOINT= +NETWORK_NAME=e2e +NETWORK_ADDRESS=10.10.10.1/24 +NETWORK_PORT=50051 +NETWORK_ALLOWED_IPS=10.10.10.0/24 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..b4ad22963 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +logs/ +.env diff --git a/e2e/biome.json b/e2e/biome.json new file mode 100644 index 000000000..d7e8f5ab9 --- /dev/null +++ b/e2e/biome.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "root": false, + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": ["helpers/**", "tests/**", "scripts/**", "wdio.conf.ts"] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 90, + "bracketSpacing": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "quoteProperties": "asNeeded", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSameLine": false, + "bracketSpacing": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/e2e/helpers/client.ts b/e2e/helpers/client.ts new file mode 100644 index 000000000..2d3bc5c99 --- /dev/null +++ b/e2e/helpers/client.ts @@ -0,0 +1,23 @@ +import { $, browser } from "@wdio/globals"; +import { switchToFullView } from "./windows.js"; + +type TauriWindow = { + __TAURI_INTERNALS__: { + invoke: (cmd: string, args?: unknown) => Promise; + }; +}; + +export const resetInstances = async () => { + await switchToFullView(); + await $('a[href="/full/add"]').click(); + await $("#add-page-view").waitForDisplayed(); + await browser.execute(async () => { + const { invoke } = (window as unknown as TauriWindow).__TAURI_INTERNALS__; + const instances = (await invoke("all_instances")) as Array<{ id: number }>; + await Promise.all( + instances.map((instance) => + invoke("delete_instance", { instanceId: instance.id }), + ), + ); + }); +}; diff --git a/e2e/helpers/clipboard.ts b/e2e/helpers/clipboard.ts new file mode 100644 index 000000000..677432c41 --- /dev/null +++ b/e2e/helpers/clipboard.ts @@ -0,0 +1,9 @@ +import { spawnSync } from "node:child_process"; + +export const readClipboard = (): string => { + const result = spawnSync("xclip", ["-selection", "clipboard", "-o"], { + encoding: "utf8", + timeout: 5_000, + }); + return result.status === 0 ? result.stdout : ""; +}; diff --git a/e2e/helpers/connection.ts b/e2e/helpers/connection.ts new file mode 100644 index 000000000..b26f41ae4 --- /dev/null +++ b/e2e/helpers/connection.ts @@ -0,0 +1,40 @@ +import { $, browser } from "@wdio/globals"; +import { submitTotpCode } from "./mfa.js"; +import { canPingGateway } from "./tunnel.js"; + +export const FULL_MFA_VIEW = "#mfa-totp-view"; +export const TRAY_MFA_VIEW = ".location-card-mfa-totp-view"; + +export const connectAndPing = async (mfaView: string, totpSecret?: string) => { + const button = $(".connect-button"); + await button.waitForClickable(); + await button.click(); + + if (totpSecret) { + const view = $(mfaView); + await view.waitForDisplayed(); + await submitTotpCode( + totpSecret, + mfaView, + async () => { + const verify = view.$("button=Verify"); + await verify.waitForClickable(); + await verify.click(); + }, + async () => !(await view.isDisplayed().catch(() => false)), + ); + } + + await browser.waitUntil(() => canPingGateway(), { + timeout: 30_000, + interval: 2_000, + timeoutMsg: "Could not ping the gateway through the VPN", + }); +}; + +export const disconnect = async () => { + const button = $(".connect-button.connected"); + await button.waitForClickable(); + await button.click(); + await $(".connect-button.disconnected").waitForDisplayed(); +}; diff --git a/e2e/helpers/coreApi.ts b/e2e/helpers/coreApi.ts new file mode 100644 index 000000000..23316bb9a --- /dev/null +++ b/e2e/helpers/coreApi.ts @@ -0,0 +1,203 @@ +const MIN_PEER_DISCONNECT_THRESHOLD_WITH_MFA = 120; + +const requireEnv = (name: string): string => { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable ${name}`); + } + return value; +}; + +const coreUrl = (): string => requireEnv("CORE_URL"); +const proxyUrl = (): string => requireEnv("PROXY_URL"); + +export type LocationMfaMode = "disabled" | "internal" | "external"; + +export interface DeviceConfig { + network_id: number; + network_name: string; + config: string; + address: string[]; + endpoint: string; + allowed_ips: string[]; + pubkey: string; + dns: string | null; + keepalive_interval: number; +} + +export interface AddedUserDevice { + deviceId: number; + configs: DeviceConfig[]; +} + +export interface EnrollmentFixture { + username: string; + enrollmentToken: string; + enrollmentUrl: string; + ephemeral: boolean; +} + +export class CoreApi { + private cookie = ""; + + private async request( + method: string, + apiPath: string, + body?: unknown, + ): Promise { + const response = await fetch(`${coreUrl()}${apiPath}`, { + method, + redirect: "manual", + headers: { + "Content-Type": "application/json", + ...(this.cookie ? { Cookie: this.cookie } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (response.status >= 300 && response.status < 400) { + throw new Error( + `Core API ${method} ${apiPath} redirected — check CORE_URL`, + ); + } + if (!response.ok) { + throw new Error( + `Core API ${method} ${apiPath} failed: ${response.status} ${await response.text()}`, + ); + } + return response; + } + + async login(): Promise { + const response = await this.request("POST", "/api/v1/auth", { + username: process.env.CORE_ADMIN_USER ?? "admin", + password: requireEnv("CORE_ADMIN_PASSWORD"), + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) { + throw new Error("Core API login did not return a session cookie"); + } + this.cookie = setCookie.split(";")[0]; + } + + async userExists(username: string): Promise { + const response = await fetch(`${coreUrl()}/api/v1/user/${username}`, { + redirect: "manual", + headers: this.cookie ? { Cookie: this.cookie } : {}, + }); + return response.ok; + } + + async createUser(username: string): Promise { + await this.request("POST", "/api/v1/user", { + username, + first_name: "E2E", + last_name: "Test", + email: `${username}@e2e.test`, + }); + } + + async deleteUser(username: string): Promise { + await this.request("DELETE", `/api/v1/user/${username}`); + } + + async listNetworks(): Promise< + Array<{ id: number; location_mfa_mode: LocationMfaMode }> + > { + const response = await this.request("GET", "/api/v1/network"); + return (await response.json()) as Array<{ + id: number; + location_mfa_mode: LocationMfaMode; + }>; + } + + async addUserDevice(name: string, pubkey: string): Promise { + const username = process.env.CORE_ADMIN_USER ?? "admin"; + const response = await this.request("POST", `/api/v1/device/${username}`, { + name, + wireguard_pubkey: pubkey, + }); + const data = (await response.json()) as { + configs: DeviceConfig[]; + device: { id: number }; + }; + return { deviceId: data.device.id, configs: data.configs }; + } + + async deleteDevice(deviceId: number): Promise { + await this.request("DELETE", `/api/v1/device/${deviceId}`); + } + + async setLocationMfaMode( + networkId: number, + mode: LocationMfaMode, + ): Promise { + const current = (await ( + await this.request("GET", `/api/v1/network/${networkId}`) + ).json()) as Record; + const previous = current.location_mfa_mode as LocationMfaMode; + if (previous === mode) { + return previous; + } + const joinList = (value: unknown): string => + Array.isArray(value) ? value.join(",") : ((value as string | null) ?? ""); + await this.request("PUT", `/api/v1/network/${networkId}`, { + name: current.name, + address: joinList(current.address), + endpoint: current.endpoint, + port: current.port, + allowed_ips: joinList(current.allowed_ips) || null, + dns: (current.dns as string | null) ?? null, + mtu: current.mtu, + fwmark: current.fwmark, + allow_all_groups: current.allow_all_groups, + allowed_groups: current.allowed_groups ?? [], + keepalive_interval: current.keepalive_interval, + peer_disconnect_threshold: Math.max( + Number(current.peer_disconnect_threshold ?? 0), + MIN_PEER_DISCONNECT_THRESHOLD_WITH_MFA, + ), + acl_enabled: current.acl_enabled, + acl_default_allow: current.acl_default_allow, + location_mfa_mode: mode, + service_location_mode: current.service_location_mode ?? "disabled", + }); + return previous; + } + + private async startEnrollment( + username: string, + ephemeral: boolean, + ): Promise { + const response = await this.request( + "POST", + `/api/v1/user/${username}/start_enrollment`, + { + send_enrollment_notification: false, + }, + ); + const data = (await response.json()) as { enrollment_token: string }; + return { + username, + enrollmentToken: data.enrollment_token, + enrollmentUrl: proxyUrl(), + ephemeral, + }; + } + + // A user with a pending enrollment, always (re)created so it has not enrolled. + async createEnrollmentFixture(): Promise { + const pinned = process.env.TEST_USERNAME; + const username = pinned ?? `e2e${Math.floor(Math.random() * 1_000_000)}`; + if (await this.userExists(username)) { + await this.deleteUser(username); + } + await this.createUser(username); + return this.startEnrollment(username, !pinned); + } +} + +export const loggedInCoreApi = async (): Promise => { + const api = new CoreApi(); + await api.login(); + return api; +}; diff --git a/e2e/helpers/enrollment.ts b/e2e/helpers/enrollment.ts new file mode 100644 index 000000000..a37d6967c --- /dev/null +++ b/e2e/helpers/enrollment.ts @@ -0,0 +1,59 @@ +import { $, expect } from "@wdio/globals"; +import type { EnrollmentFixture } from "./coreApi.js"; +import { submitTotpCode } from "./mfa.js"; +import { switchToFullView } from "./windows.js"; + +export const password = "E2eTestPassword123!"; + +const clickNext = async () => { + const next = $(".enroll-controls .right button"); + await next.waitForClickable(); + await next.click(); +}; + +export const addInstance = async (fixture: EnrollmentFixture) => { + await switchToFullView(); + const addCard = $("#add-page-view button"); + await addCard.waitForClickable(); + await addCard.click(); + await expect($("#add-instance-view")).toBeDisplayed(); + await $('[data-testid="field-url"]').setValue(fixture.enrollmentUrl); + await $('[data-testid="field-token"]').setValue(fixture.enrollmentToken); + const submit = $("#add-instance-view").$("button=Add Instance"); + await submit.waitForClickable(); + await submit.click(); + await expect($("#welcome-step")).toBeDisplayed(); +}; + +export const setPassword = async () => { + await clickNext(); + await expect($("#password-step")).toBeDisplayed(); + await $('[data-testid="field-password"]').setValue(password); + await $('[data-testid="field-repeat"]').setValue(password); + await clickNext(); +}; + +export const configureTotp = async (): Promise => { + await expect($("#mfa-configuration-step")).toBeDisplayed(); + const secretField = $("#mfa-configuration-step .copy-field .track p"); + await secretField.waitForExist(); + const secret = + ((await secretField.getProperty("textContent")) as string | null)?.trim() ?? + ""; + await submitTotpCode(secret, "#mfa-configuration-step", clickNext, () => + $("#recovery-codes-step") + .isDisplayed() + .catch(() => false), + ); + await $("#recovery-codes-step .checkbox").click(); + const complete = $("#recovery-codes-step").$("button=Complete"); + await complete.waitForClickable(); + await complete.click(); + return secret; +}; + +export const finishEnrollment = async () => { + await expect($("#finish-step")).toBeDisplayed(); + await $("#finish-step").$("button=Got it").click(); + await expect($("#overview-page")).toBeDisplayed(); +}; diff --git a/e2e/helpers/mfa.ts b/e2e/helpers/mfa.ts new file mode 100644 index 000000000..ac6e12e1d --- /dev/null +++ b/e2e/helpers/mfa.ts @@ -0,0 +1,32 @@ +import { $, browser } from "@wdio/globals"; +import { totpCode } from "./totp.js"; + +const MAX_ATTEMPTS = 3; + +const fillCode = async (scope: string, code: string) => { + const input = $(`${scope} .code-input input`); + await input.click(); + await input.setValue(code); +}; + +export const submitTotpCode = async ( + secret: string, + scope: string, + submit: () => Promise, + accepted: () => Promise, +) => { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + await fillCode(scope, totpCode(secret)); + await submit(); + try { + await browser.waitUntil(accepted, { + timeout: 6_000, + interval: 500, + timeoutMsg: "TOTP code was not accepted after several attempts", + }); + return; + } catch (error) { + if (attempt === MAX_ATTEMPTS) throw error; + } + } +}; diff --git a/e2e/helpers/totp.ts b/e2e/helpers/totp.ts new file mode 100644 index 000000000..f6579b6e3 --- /dev/null +++ b/e2e/helpers/totp.ts @@ -0,0 +1,8 @@ +import { Secret, TOTP } from "otpauth"; + +export const totpCode = (base32Secret: string): string => + new TOTP({ + secret: Secret.fromBase32(base32Secret.replace(/\s/g, "").toUpperCase()), + digits: 6, + period: 30, + }).generate(); diff --git a/e2e/helpers/tunnel.ts b/e2e/helpers/tunnel.ts new file mode 100644 index 000000000..674f0bb78 --- /dev/null +++ b/e2e/helpers/tunnel.ts @@ -0,0 +1,17 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const GATEWAY_VPN_IP = process.env.GATEWAY_VPN_IP ?? "10.10.10.1"; + +export const canPingGateway = async ( + target = GATEWAY_VPN_IP, +): Promise => { + try { + await execFileAsync("ping", ["-c", "1", "-W", "5", target]); + return true; + } catch { + return false; + } +}; diff --git a/e2e/helpers/windows.ts b/e2e/helpers/windows.ts new file mode 100644 index 000000000..6cd63f1bf --- /dev/null +++ b/e2e/helpers/windows.ts @@ -0,0 +1,22 @@ +import { $, browser } from "@wdio/globals"; + +export const switchToFullView = async () => { + for (const handle of await browser.getWindowHandles()) { + await browser.switchToWindow(handle); + const url = await browser.getUrl(); + if (url.includes("/full")) { + return; + } + if (url.includes("/compact")) { + await browser.url("tauri://localhost/full/"); + return; + } + } + throw new Error("No full view window found"); +}; + +export const switchToTrayView = async () => { + await switchToFullView(); + await browser.url("tauri://localhost/compact/"); + await $("#compact-locations-page").waitForDisplayed(); +}; diff --git a/e2e/helpers/wireguard.ts b/e2e/helpers/wireguard.ts new file mode 100644 index 000000000..c4cdd2161 --- /dev/null +++ b/e2e/helpers/wireguard.ts @@ -0,0 +1,54 @@ +import { generateKeyPairSync } from "node:crypto"; +import type { CoreApi } from "./coreApi.js"; + +export const generateWireguardKeys = () => { + const { privateKey, publicKey } = generateKeyPairSync("x25519"); + return { + privateKey: privateKey + .export({ type: "pkcs8", format: "der" }) + .subarray(-32) + .toString("base64"), + publicKey: publicKey + .export({ type: "spki", format: "der" }) + .subarray(-32) + .toString("base64"), + }; +}; + +export type TunnelConfig = { + name: string; + deviceId: number; + prvkey: string; + pubkey: string; + address: string; + serverPubkey: string; + allowedIps: string; + endpoint: string; + dns: string; + keepalive: string; +}; + +export const provisionTunnel = async ( + core: CoreApi, + networkId: number, + name: string, +): Promise => { + const keys = generateWireguardKeys(); + const { deviceId, configs } = await core.addUserDevice(name, keys.publicKey); + const config = configs.find((item) => item.network_id === networkId); + if (!config) { + throw new Error(`Device ${name} was not added to location ${networkId}`); + } + return { + name, + deviceId, + prvkey: keys.privateKey, + pubkey: keys.publicKey, + address: config.address.join(","), + serverPubkey: config.pubkey, + allowedIps: config.allowed_ips.join(","), + endpoint: config.endpoint, + dns: config.dns ?? "", + keepalive: String(config.keepalive_interval), + }; +}; diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..c60286a44 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,25 @@ +{ + "name": "e2e", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "test": "wdio run ./wdio.conf.ts", + "provision": "node scripts/provision.mjs", + "fix": "biome check --fix", + "lint": "biome check" + }, + "devDependencies": { + "@biomejs/biome": "2.4.16", + "@types/node": "^25.9.2", + "@wdio/cli": "^9.30.0", + "@wdio/globals": "^9.29.1", + "@wdio/local-runner": "^9.30.0", + "@wdio/mocha-framework": "^9.30.0", + "@wdio/spec-reporter": "^9.29.1", + "@wdio/types": "^9.29.1", + "otpauth": "^9.4.1", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + } +} diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml new file mode 100644 index 000000000..5cd770fe6 --- /dev/null +++ b/e2e/pnpm-lock.yaml @@ -0,0 +1,4362 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: 2.4.16 + version: 2.4.16 + '@types/node': + specifier: ^25.9.2 + version: 25.9.2 + '@wdio/cli': + specifier: ^9.30.0 + version: 9.30.0(@types/node@25.9.2)(expect-webdriverio@5.7.0)(supports-color@8.1.1) + '@wdio/globals': + specifier: ^9.29.1 + version: 9.29.1(expect-webdriverio@5.7.0)(webdriverio@9.30.0(supports-color@8.1.1)) + '@wdio/local-runner': + specifier: ^9.30.0 + version: 9.30.0(@wdio/globals@9.29.1)(supports-color@8.1.1)(webdriverio@9.30.0(supports-color@8.1.1)) + '@wdio/mocha-framework': + specifier: ^9.30.0 + version: 9.30.0(supports-color@8.1.1) + '@wdio/spec-reporter': + specifier: ^9.29.1 + version: 9.29.1 + '@wdio/types': + specifier: ^9.29.1 + version: 9.29.1 + otpauth: + specifier: ^9.4.1 + version: 9.5.1 + tsx: + specifier: ^4.20.6 + version: 4.22.4 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.4.16': + resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.16': + resolution: {integrity: sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.16': + resolution: {integrity: sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.16': + resolution: {integrity: sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.16': + resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.16': + resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.16': + resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.16': + resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.16': + resolution: {integrity: sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.4.1': + resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.4.0': + resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.4.1': + resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@promptbook/utils@0.69.5': + resolution: {integrity: sha512-xm5Ti/Hp3o4xHrsK9Yy3MS6KbDxYbq485hDsFvxqaNA7equHLPdo8H8faTitTeb14QCDfLW4iwCxdVYu5sn6YQ==} + + '@puppeteer/browsers@2.13.2': + resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==} + engines: {node: '>=18'} + hasBin: true + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/mocha@10.0.10': + resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} + + '@types/node@20.19.42': + resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + + '@types/sinonjs__fake-timers@8.1.5': + resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/which@2.0.2': + resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + + '@wdio/cli@9.30.0': + resolution: {integrity: sha512-e5IqOvfjnyMfSYM6c++qe66kwMw24TA11b/sYDa3oPsHps06JaRzFqM4U0HZIUa+9QpNheat87VYJWrsRCKv1g==} + engines: {node: '>=18.20.0'} + hasBin: true + + '@wdio/config@9.30.0': + resolution: {integrity: sha512-pH/Y1F4QVIsx33xyli8+9HCr4HRuOeUk8j/lqNBuuUzbvduYRCLmNkZqxRsLnRth8wrlPdzA8Hq+PPLwJWrceA==} + engines: {node: '>=18.20.0'} + + '@wdio/dot-reporter@9.29.1': + resolution: {integrity: sha512-5UVgxKHVoJfJVSg3VH9n0yPkstHzX65g5zRBtNX51hqXmSPRE9kSLGq53Lo91UTORc0og3i0UT93zZqEQhpzjA==} + engines: {node: '>=18.20.0'} + + '@wdio/globals@9.29.1': + resolution: {integrity: sha512-F96BKppx4HGD64v+s57TM4K4zaxqUCg2RXHk6sjB2xrSa7P+d2VYV28ID2ddF7iSodVfPlpa1i2/jy8jMGrU8w==} + engines: {node: '>=18.20.0'} + peerDependencies: + expect-webdriverio: ^5.6.5 + webdriverio: ^9.0.0 + + '@wdio/local-runner@9.30.0': + resolution: {integrity: sha512-7W+vjAsccGXuy2g83vsc5zKwZSkmnEZDEzsORGhorJAWJPd3NwZ8LyyudBxWvSS4sLbJB0Ix4wx70lA8YoiQMA==} + engines: {node: '>=18.20.0'} + + '@wdio/logger@9.29.1': + resolution: {integrity: sha512-0ZAEIo6PNyMIJPlOGkIgyOJUjcd0pC8/QHlVAAe1c91/IcjZ1X+k0yidXHaboJdN7dq1XPUacmhRdtua0U5EZg==} + engines: {node: '>=18.20.0'} + + '@wdio/mocha-framework@9.30.0': + resolution: {integrity: sha512-NhxHeIFd0iHpb8sud3ahOilE/bgm5MBBzSq+xSKW+hTUYdmeyy4w+KX5p/+AQ0dsUEIToaA4xjZRUTCwY+65Wg==} + engines: {node: '>=18.20.0'} + + '@wdio/protocols@9.30.0': + resolution: {integrity: sha512-VDKCTw3GVdqUw1+fXAY4FUOSt+a3/r7T2O34d5HAaZLfI6QHacsU7/N0Sv3w/QgWU6yj3WkXElXq1oSWF1XcBw==} + + '@wdio/repl@9.16.2': + resolution: {integrity: sha512-FLTF0VL6+o5BSTCO7yLSXocm3kUnu31zYwzdsz4n9s5YWt83sCtzGZlZpt7TaTzb3jVUfxuHNQDTb8UMkCu0lQ==} + engines: {node: '>=18.20.0'} + + '@wdio/reporter@9.29.1': + resolution: {integrity: sha512-CKAcVy9BGwvufokMcl3H+yNcvD11Klku/1BPz8bKSRv7/KzueXR06s1cPbJH3tv9r9zxPErxgdVMpulN8I0qAg==} + engines: {node: '>=18.20.0'} + + '@wdio/runner@9.30.0': + resolution: {integrity: sha512-gvUExYXZtziIM4TmWMd98cvVUt2jTQ54k8gIYxBJ8w8N6BQfrko08IKWrVjoNOanJzLMHjqtCWWx4DXR3JtozQ==} + engines: {node: '>=18.20.0'} + peerDependencies: + expect-webdriverio: ^5.6.5 + webdriverio: ^9.0.0 + + '@wdio/spec-reporter@9.29.1': + resolution: {integrity: sha512-iqlHW/qGDRGmxQKN7XyCHxfKphTbPNnniV3yxNXZRSGHCCQok5KFKOnj4fpUCKEyD5jtPAmP6Y0qc1UyR3Dc3Q==} + engines: {node: '>=18.20.0'} + + '@wdio/types@9.29.1': + resolution: {integrity: sha512-jp8jgMv6TS35G96YzHZxw3PVN0Dz6xQ6tnMAicndAJ8Jt9AIXb0ywIse4TjaFywakO3dLoEhlyA3ZtR26vmt+w==} + engines: {node: '>=18.20.0'} + + '@wdio/utils@9.30.0': + resolution: {integrity: sha512-MhTx8jKOtspogeMUmASFsmL6vRetwCpvfcAFHauYZ62UzPjFwHThIXorzoG90mrcS9Yl+c8Sj9BfX+bdxwXfsg==} + engines: {node: '>=18.20.0'} + + '@wdio/xvfb@9.30.0': + resolution: {integrity: sha512-v2vkweVbf1nI3lqRjVMbRyWFqHxPrDE95sLq0IZLjZ8fnXtWWIF61ZxfUhLdrMKB6s3gXnOyZuWgEJFD7iDBGQ==} + engines: {node: '>=18.20.0'} + + '@zip.js/zip.js@2.8.34': + resolution: {integrity: sha512-+6a3lyqq69rpseLbvDPiVIWsZ/HdTGAAD6afFtug6ECPDGttb2dHnPC6cJgdPofYkzL9OvXizegq+DQVfL2rnA==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.2: + resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.1: + resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==} + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + + bare-url@2.4.6: + resolution: {integrity: sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + + create-wdio@9.30.0: + resolution: {integrity: sha512-rmlMhGxT+s+mnAt+GWUhwf/h9CalN1Qy/LXE6u7IUkw2HdpcimYZOFCQ15bujE+Bm0/rfH9jFz9Tn08xtln4nw==} + engines: {node: '>=18.20.0'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-shorthand-properties@1.1.2: + resolution: {integrity: sha512-C2AugXIpRGQTxaCW0N7n5jD/p5irUmCrwl03TrnMFBHDbdq44CFWR2zO7rK9xPN4Eo3pUxC4vQzQgbIpzrD1PQ==} + + css-value@0.0.1: + resolution: {integrity: sha512-FUV3xaJ63buRLgHrLQVlVgQnQdR4yqdLGaDu7g8CQcWjInDfM9plBTPI9FRfpahju1UBSaMckeb2/46ApS/V1Q==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + + decamelize@6.0.1: + resolution: {integrity: sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + easy-table@1.2.0: + resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==} + + edge-paths@3.0.5: + resolution: {integrity: sha512-sB7vSrDnFa4ezWQk9nZ/n0FdpdUuC6R1EOrlU3DL+bovcNFK28rqu2emmAUjujYEJTWIgQGqgVVWUZXMnc8iWg==} + engines: {node: '>=14.0.0'} + + edgedriver@6.3.0: + resolution: {integrity: sha512-ggEQL+oEyIcM4nP2QC3AtCQ04o4kDNefRM3hja0odvlPSnsaxiruMxEZ93v3gDCKWYW6BXUr51PPradb+3nffw==} + engines: {node: '>=20.0.0'} + hasBin: true + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + exit-hook@4.0.0: + resolution: {integrity: sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==} + engines: {node: '>=18'} + + expect-webdriverio@5.7.0: + resolution: {integrity: sha512-jLOTrJoPBC3Wtd83ryHMbRcEajMGtAnn6OWtSnoJoDLt16nHvxVdjcI4Bc0n+1KAOr8DvQtlJ55f0M48kJtEpw==} + engines: {node: '>=20'} + peerDependencies: + '@wdio/globals': ^9.0.0 + '@wdio/logger': ^9.0.0 + webdriverio: ^9.0.0 + + expect@30.4.1: + resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-deep-equal@2.0.1: + resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-xml-builder@1.3.0: + resolution: {integrity: sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==} + + fast-xml-parser@5.10.1: + resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} + hasBin: true + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + geckodriver@6.1.1: + resolution: {integrity: sha512-/AcCyc9o9o6hUbudaSJM2iOtXbxSLqQPOb4GrPvEN40cjraUeaX/j5kH3mSgwiroyMn7qzx2wM61AQ2XY8j3sA==} + engines: {node: '>=20.0.0'} + hasBin: true + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-port@7.2.0: + resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} + engines: {node: '>=16'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + hosted-git-info@8.1.0: + resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==} + engines: {node: ^18.17.0 || >=20.5.0} + + htmlfy@0.8.1: + resolution: {integrity: sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inquirer@12.11.1: + resolution: {integrity: sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + ip-address@10.3.1: + resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} + engines: {node: '>= 12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-unsafe@2.0.0: + resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.4.1: + resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.4.1: + resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-regex-util@30.4.0: + resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.4.1: + resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-parse-even-better-errors@3.0.2: + resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lines-and-columns@2.0.4: + resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-app@2.5.0: + resolution: {integrity: sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.flattendeep@4.4.0: + resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.zip@4.2.0: + resolution: {integrity: sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + loglevel-plugin-prefix@0.8.4: + resolution: {integrity: sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==} + + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mocha@10.8.2: + resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==} + engines: {node: '>= 14.0.0'} + hasBin: true + + modern-tar@0.7.7: + resolution: {integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==} + engines: {node: '>=18.0.0'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + normalize-package-data@7.0.1: + resolution: {integrity: sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA==} + engines: {node: ^18.17.0 || >=20.5.0} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + otpauth@9.5.1: + resolution: {integrity: sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-json@7.1.1: + resolution: {integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==} + engines: {node: '>=16'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.7: + resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} + + read-pkg-up@10.1.0: + resolution: {integrity: sha512-aNtBq4jR8NawpKJQldrQcSW9y/d+KWH4v24HWkHljOZ7H0av+YTGANBzRh9A5pw7v/bLVsLVPpOhJ7gHNVy8lA==} + engines: {node: '>=16'} + + read-pkg@8.1.0: + resolution: {integrity: sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==} + engines: {node: '>=16'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + recursive-readdir@2.2.3: + resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==} + engines: {node: '>=6.0.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resq@1.11.0: + resolution: {integrity: sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + rgb2hex@0.2.5: + resolution: {integrity: sha512-22MOP1Rh7sAo1BZpDG6R5RFYzR2lYEgwq7HEmyW2qcsOqR2lQKmn+O//xV3YG/0rrhMC6KVX2hU+ZXuaw9a5bw==} + + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safaridriver@1.0.1: + resolution: {integrity: sha512-jkg4434cYgtrIF2AeY/X0Wmd2W73cK5qIEFE3hDrrQenJH/2SDJIXGvPAigfvQTcE9+H31zkiNHbUqcihEiMRA==} + engines: {node: '>=18.0.0'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@12.0.0: + resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==} + engines: {node: '>=18'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spacetrim@0.11.59: + resolution: {integrity: sha512-lLYsktklSRKprreOm7NXReW8YiX2VBjbgmXYEziOoGf/qsJqAEACaDvoTtUOycwjpaSh+bT8eu0KrJn7UNxiCg==} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + + streamx@2.27.0: + resolution: {integrity: sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tar-fs@3.1.3: + resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-fest@3.13.1: + resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} + engines: {node: '>=14.16'} + + type-fest@4.26.0: + resolution: {integrity: sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==} + engines: {node: '>=16'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + + undici@7.27.2: + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + engines: {node: '>=20.18.1'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + + userhome@1.0.1: + resolution: {integrity: sha512-5cnLm4gseXjAclKowC4IjByaGsjtAoV6PrOQOljplNB54ReUYJP8HdAFq2muHinSDAh09PPX/uXDPfdxRHvuSA==} + engines: {node: '>= 0.8.0'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + wait-port@1.1.0: + resolution: {integrity: sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==} + engines: {node: '>=10'} + hasBin: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webdriver@9.30.0: + resolution: {integrity: sha512-DisY5E8p5zAZyl8pee08p4Vlc91c94Am9oswZaNCgZH5Ewm//FoCZ+tHNppENqooCYjfa/upATbFqGOLuXQkrw==} + engines: {node: '>=18.20.0'} + + webdriverio@9.30.0: + resolution: {integrity: sha512-cmV/uSbCvnJ99QejGPVGlzb9MjiSYUts8VjdESnDKvA7JvRMIJK0l807Js7HDK/g6KG7j+D2apsFHGlT7W5UeA==} + engines: {node: '>=18.20.0'} + peerDependencies: + puppeteer-core: '>=22.x || <=24.x' + peerDependenciesMeta: + puppeteer-core: + optional: true + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + workerpool@6.5.1: + resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@biomejs/biome@2.4.16': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.16 + '@biomejs/cli-darwin-x64': 2.4.16 + '@biomejs/cli-linux-arm64': 2.4.16 + '@biomejs/cli-linux-arm64-musl': 2.4.16 + '@biomejs/cli-linux-x64': 2.4.16 + '@biomejs/cli-linux-x64-musl': 2.4.16 + '@biomejs/cli-win32-arm64': 2.4.16 + '@biomejs/cli-win32-x64': 2.4.16 + + '@biomejs/cli-darwin-arm64@2.4.16': + optional: true + + '@biomejs/cli-darwin-x64@2.4.16': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.16': + optional: true + + '@biomejs/cli-linux-arm64@2.4.16': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.16': + optional: true + + '@biomejs/cli-linux-x64@2.4.16': + optional: true + + '@biomejs/cli-win32-arm64@2.4.16': + optional: true + + '@biomejs/cli-win32-x64@2.4.16': + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@25.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/confirm@5.1.21(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/core@10.3.2(@types/node@25.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.2) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/editor@4.2.23(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/external-editor': 1.0.3(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/expand@4.0.23(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/external-editor@1.0.3(@types/node@25.9.2)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/number@3.0.23(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/password@4.0.23(@types/node@25.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/prompts@7.10.1(@types/node@25.9.2)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.9.2) + '@inquirer/confirm': 5.1.21(@types/node@25.9.2) + '@inquirer/editor': 4.2.23(@types/node@25.9.2) + '@inquirer/expand': 4.0.23(@types/node@25.9.2) + '@inquirer/input': 4.3.1(@types/node@25.9.2) + '@inquirer/number': 3.0.23(@types/node@25.9.2) + '@inquirer/password': 4.0.23(@types/node@25.9.2) + '@inquirer/rawlist': 4.1.11(@types/node@25.9.2) + '@inquirer/search': 3.2.2(@types/node@25.9.2) + '@inquirer/select': 4.4.2(@types/node@25.9.2) + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/rawlist@4.1.11(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/search@3.2.2(@types/node@25.9.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/select@4.4.2(@types/node@25.9.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.9.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.9.2 + + '@inquirer/type@3.0.10(@types/node@25.9.2)': + optionalDependencies: + '@types/node': 25.9.2 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jest/diff-sequences@30.4.0': {} + + '@jest/expect-utils@30.4.1': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/get-type@30.1.0': {} + + '@jest/pattern@30.4.0': + dependencies: + '@types/node': 25.9.2 + jest-regex-util: 30.4.0 + + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.49 + + '@jest/types@30.4.1': + dependencies: + '@jest/pattern': 30.4.0 + '@jest/schemas': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.9.2 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@noble/hashes@2.2.0': {} + + '@nodable/entities@3.0.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@promptbook/utils@0.69.5': + dependencies: + spacetrim: 0.11.59 + + '@puppeteer/browsers@2.13.2(supports-color@8.1.1)': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + extract-zip: 2.0.1(supports-color@8.1.1) + progress: 2.0.3 + proxy-agent: 6.5.0(supports-color@8.1.1) + semver: 7.8.5 + tar-fs: 3.1.3 + yargs: 17.7.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@sec-ant/readable-stream@0.4.1': {} + + '@sinclair/typebox@0.34.49': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@tootallnate/quickjs-emscripten@0.23.0': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/mocha@10.0.10': {} + + '@types/node@20.19.42': + dependencies: + undici-types: 6.21.0 + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/node@25.9.2': + dependencies: + undici-types: 7.24.6 + + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + optional: true + + '@types/normalize-package-data@2.4.4': {} + + '@types/sinonjs__fake-timers@8.1.5': {} + + '@types/stack-utils@2.0.3': {} + + '@types/which@2.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.2 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 25.9.5 + optional: true + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@wdio/cli@9.30.0(@types/node@25.9.2)(expect-webdriverio@5.7.0)(supports-color@8.1.1)': + dependencies: + '@vitest/snapshot': 2.1.9 + '@wdio/config': 9.30.0(supports-color@8.1.1) + '@wdio/globals': 9.29.1(expect-webdriverio@5.7.0)(webdriverio@9.30.0(supports-color@8.1.1)) + '@wdio/logger': 9.29.1 + '@wdio/protocols': 9.30.0 + '@wdio/types': 9.29.1 + '@wdio/utils': 9.30.0(supports-color@8.1.1) + async-exit-hook: 2.0.1 + chalk: 5.6.2 + chokidar: 4.0.3 + create-wdio: 9.30.0(@types/node@25.9.2) + dotenv: 17.4.2 + import-meta-resolve: 4.2.0 + lodash.flattendeep: 4.4.0 + lodash.pickby: 4.6.0 + lodash.union: 4.6.0 + read-pkg-up: 10.1.0 + tsx: 4.23.1 + webdriverio: 9.30.0(supports-color@8.1.1) + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - bare-abort-controller + - bare-buffer + - bufferutil + - expect-webdriverio + - puppeteer-core + - react-native-b4a + - supports-color + - utf-8-validate + + '@wdio/config@9.30.0(supports-color@8.1.1)': + dependencies: + '@wdio/logger': 9.29.1 + '@wdio/types': 9.29.1 + '@wdio/utils': 9.30.0(supports-color@8.1.1) + deepmerge-ts: 7.1.5 + glob: 10.5.0 + import-meta-resolve: 4.2.0 + jiti: 2.7.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@wdio/dot-reporter@9.29.1': + dependencies: + '@wdio/reporter': 9.29.1 + '@wdio/types': 9.29.1 + chalk: 5.6.2 + + '@wdio/globals@9.29.1(expect-webdriverio@5.7.0)(webdriverio@9.30.0(supports-color@8.1.1))': + dependencies: + expect-webdriverio: 5.7.0(@wdio/globals@9.29.1)(@wdio/logger@9.29.1)(webdriverio@9.30.0(supports-color@8.1.1)) + webdriverio: 9.30.0(supports-color@8.1.1) + + '@wdio/local-runner@9.30.0(@wdio/globals@9.29.1)(supports-color@8.1.1)(webdriverio@9.30.0(supports-color@8.1.1))': + dependencies: + '@types/node': 20.19.43 + '@wdio/logger': 9.29.1 + '@wdio/repl': 9.16.2 + '@wdio/runner': 9.30.0(expect-webdriverio@5.7.0)(supports-color@8.1.1)(webdriverio@9.30.0(supports-color@8.1.1)) + '@wdio/types': 9.29.1 + '@wdio/xvfb': 9.30.0 + exit-hook: 4.0.0 + expect-webdriverio: 5.7.0(@wdio/globals@9.29.1)(@wdio/logger@9.29.1)(webdriverio@9.30.0(supports-color@8.1.1)) + split2: 4.2.0 + stream-buffers: 3.0.3 + transitivePeerDependencies: + - '@wdio/globals' + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + - webdriverio + + '@wdio/logger@9.29.1': + dependencies: + chalk: 5.6.2 + loglevel: 1.9.2 + loglevel-plugin-prefix: 0.8.4 + safe-regex2: 5.1.1 + strip-ansi: 7.2.0 + + '@wdio/mocha-framework@9.30.0(supports-color@8.1.1)': + dependencies: + '@types/mocha': 10.0.10 + '@types/node': 20.19.43 + '@wdio/logger': 9.29.1 + '@wdio/types': 9.29.1 + '@wdio/utils': 9.30.0(supports-color@8.1.1) + mocha: 10.8.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@wdio/protocols@9.30.0': {} + + '@wdio/repl@9.16.2': + dependencies: + '@types/node': 20.19.43 + + '@wdio/reporter@9.29.1': + dependencies: + '@types/node': 20.19.43 + '@wdio/logger': 9.29.1 + '@wdio/types': 9.29.1 + diff: 8.0.4 + object-inspect: 1.13.4 + + '@wdio/runner@9.30.0(expect-webdriverio@5.7.0)(supports-color@8.1.1)(webdriverio@9.30.0(supports-color@8.1.1))': + dependencies: + '@types/node': 20.19.43 + '@wdio/config': 9.30.0(supports-color@8.1.1) + '@wdio/dot-reporter': 9.29.1 + '@wdio/globals': 9.29.1(expect-webdriverio@5.7.0)(webdriverio@9.30.0(supports-color@8.1.1)) + '@wdio/logger': 9.29.1 + '@wdio/types': 9.29.1 + '@wdio/utils': 9.30.0(supports-color@8.1.1) + deepmerge-ts: 7.1.5 + expect-webdriverio: 5.7.0(@wdio/globals@9.29.1)(@wdio/logger@9.29.1)(webdriverio@9.30.0(supports-color@8.1.1)) + webdriver: 9.30.0(supports-color@8.1.1) + webdriverio: 9.30.0(supports-color@8.1.1) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + '@wdio/spec-reporter@9.29.1': + dependencies: + '@wdio/reporter': 9.29.1 + '@wdio/types': 9.29.1 + chalk: 5.6.2 + easy-table: 1.2.0 + pretty-ms: 9.3.0 + + '@wdio/types@9.29.1': + dependencies: + '@types/node': 20.19.43 + + '@wdio/utils@9.30.0(supports-color@8.1.1)': + dependencies: + '@puppeteer/browsers': 2.13.2(supports-color@8.1.1) + '@wdio/logger': 9.29.1 + '@wdio/types': 9.29.1 + decamelize: 6.0.1 + deepmerge-ts: 7.1.5 + edgedriver: 6.3.0(supports-color@8.1.1) + geckodriver: 6.1.1(supports-color@8.1.1) + get-port: 7.2.0 + import-meta-resolve: 4.2.0 + locate-app: 2.5.0 + mitt: 3.0.1 + safaridriver: 1.0.1 + split2: 4.2.0 + wait-port: 1.1.0(supports-color@8.1.1) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@wdio/xvfb@9.30.0': + dependencies: + '@wdio/logger': 9.29.1 + + '@zip.js/zip.js@2.8.34': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + anynum@1.0.1: {} + + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.0 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + + async-exit-hook@2.0.1: {} + + async@3.2.6: {} + + b4a@1.8.1: {} + + balanced-match@1.0.2: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.2: + dependencies: + bare-events: 2.9.1 + bare-path: 3.0.1 + bare-stream: 2.13.1(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-fs@4.7.4: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.6 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-os@3.9.1: {} + + bare-path@3.0.1: + dependencies: + bare-os: 3.9.1 + + bare-path@3.1.1: + optional: true + + bare-stream@2.13.1(bare-events@2.9.1): + dependencies: + streamx: 2.27.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + optional: true + + bare-url@2.4.5: + dependencies: + bare-path: 3.0.1 + + bare-url@2.4.6: + dependencies: + bare-path: 3.1.1 + optional: true + + base64-js@1.5.1: {} + + basic-ftp@5.3.1: {} + + binary-extensions@2.3.0: {} + + boolbase@1.0.0: {} + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-stdout@1.3.1: {} + + buffer-crc32@0.2.13: {} + + buffer-crc32@1.0.0: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + camelcase@6.3.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chardet@2.2.0: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.27.2 + whatwg-mimetype: 4.0.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + ci-info@4.4.0: {} + + cli-width@4.1.0: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: + optional: true + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@14.0.3: {} + + commander@9.5.0: {} + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + + create-wdio@9.30.0(@types/node@25.9.2): + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + cross-spawn: 7.0.6 + ejs: 3.1.10 + execa: 9.6.1 + import-meta-resolve: 4.2.0 + inquirer: 12.11.1(@types/node@25.9.2) + normalize-package-data: 7.0.1 + read-pkg-up: 10.1.0 + recursive-readdir: 2.2.3 + semver: 7.8.5 + type-fest: 4.41.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-shorthand-properties@1.1.2: {} + + css-value@0.0.1: {} + + css-what@6.2.2: {} + + data-uri-to-buffer@6.0.2: {} + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@4.0.0: {} + + decamelize@6.0.1: {} + + deep-eql@5.0.2: {} + + deepmerge-ts@7.1.5: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + optional: true + + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + + diff@5.2.2: {} + + diff@8.0.4: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dotenv@17.4.2: {} + + eastasianwidth@0.2.0: {} + + easy-table@1.2.0: + dependencies: + ansi-regex: 5.0.1 + optionalDependencies: + wcwidth: 1.0.1 + + edge-paths@3.0.5: + dependencies: + '@types/which': 2.0.2 + which: 2.0.2 + + edgedriver@6.3.0(supports-color@8.1.1): + dependencies: + '@wdio/logger': 9.29.1 + '@zip.js/zip.js': 2.8.34 + decamelize: 6.0.1 + edge-paths: 3.0.5 + fast-xml-parser: 5.10.1 + http-proxy-agent: 7.0.2(supports-color@8.1.1) + https-proxy-agent: 7.0.6(supports-color@8.1.1) + which: 6.0.1 + transitivePeerDependencies: + - supports-color + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + + exit-hook@4.0.0: {} + + expect-webdriverio@5.7.0(@wdio/globals@9.29.1)(@wdio/logger@9.29.1)(webdriverio@9.30.0(supports-color@8.1.1)): + dependencies: + '@vitest/snapshot': 4.1.8 + '@wdio/globals': 9.29.1(expect-webdriverio@5.7.0)(webdriverio@9.30.0(supports-color@8.1.1)) + '@wdio/logger': 9.29.1 + deep-eql: 5.0.2 + expect: 30.4.1 + jest-matcher-utils: 30.4.1 + webdriverio: 9.30.0(supports-color@8.1.1) + + expect@30.4.1: + dependencies: + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + + extract-zip@2.0.1(supports-color@8.1.1): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@2.0.1: {} + + fast-fifo@1.3.2: {} + + fast-xml-builder@1.3.0: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.10.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.0 + is-unsafe: 2.0.0 + path-expression-matcher: 1.6.2 + strnum: 2.4.1 + xml-naming: 0.3.0 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-up@6.3.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + + flat@5.0.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + geckodriver@6.1.1(supports-color@8.1.1): + dependencies: + '@wdio/logger': 9.29.1 + '@zip.js/zip.js': 2.8.34 + decamelize: 6.0.1 + http-proxy-agent: 7.0.2(supports-color@8.1.1) + https-proxy-agent: 7.0.6(supports-color@8.1.1) + modern-tar: 0.7.7 + transitivePeerDependencies: + - supports-color + + get-caller-file@2.0.5: {} + + get-port@7.2.0: {} + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + get-uri@6.0.5(supports-color@8.1.1): + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + + graceful-fs@4.2.11: {} + + grapheme-splitter@1.0.4: {} + + has-flag@4.0.0: {} + + he@1.2.0: {} + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + hosted-git-info@8.1.0: + dependencies: + lru-cache: 10.4.3 + + htmlfy@0.8.1: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + http-proxy-agent@7.0.2(supports-color@8.1.1): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6(supports-color@8.1.1): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + human-signals@8.0.1: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immediate@3.0.6: {} + + import-meta-resolve@4.2.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inquirer@12.11.1(@types/node@25.9.2): + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@25.9.2) + '@inquirer/prompts': 7.10.1(@types/node@25.9.2) + '@inquirer/type': 3.0.10(@types/node@25.9.2) + mute-stream: 2.0.0 + run-async: 4.0.6 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 25.9.2 + + ip-address@10.3.1: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-plain-obj@4.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@0.1.0: {} + + is-unicode-supported@2.1.0: {} + + is-unsafe@2.0.0: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + isexe@4.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + + jest-message-util@30.4.1: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.4.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.4.1 + picomatch: 4.0.4 + pretty-format: 30.4.1 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.9.2 + jest-util: 30.4.1 + + jest-regex-util@30.4.0: {} + + jest-util@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.9.2 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.4 + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@3.0.2: {} + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lines-and-columns@2.0.4: {} + + locate-app@2.5.0: + dependencies: + '@promptbook/utils': 0.69.5 + type-fest: 4.26.0 + userhome: 1.0.1 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.clonedeep@4.5.0: {} + + lodash.flattendeep@4.4.0: {} + + lodash.pickby@4.6.0: {} + + lodash.union@4.6.0: {} + + lodash.zip@4.2.0: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + loglevel-plugin-prefix@0.8.4: {} + + loglevel@1.9.2: {} + + lru-cache@10.4.3: {} + + lru-cache@7.18.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.2 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minipass@7.1.3: {} + + mitt@3.0.1: {} + + mocha@10.8.2: + dependencies: + ansi-colors: 4.1.3 + browser-stdout: 1.3.1 + chokidar: 3.6.0 + debug: 4.4.3(supports-color@8.1.1) + diff: 5.2.2 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 8.1.0 + he: 1.2.0 + js-yaml: 4.3.0 + log-symbols: 4.1.0 + minimatch: 5.1.9 + ms: 2.1.3 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.5.1 + yargs: 16.2.2 + yargs-parser: 20.2.9 + yargs-unparser: 2.0.0 + + modern-tar@0.7.7: {} + + ms@2.1.3: {} + + mute-stream@2.0.0: {} + + netmask@2.1.1: {} + + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + + normalize-package-data@7.0.1: + dependencies: + hosted-git-info: 8.1.0 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + + normalize-path@3.0.0: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-inspect@1.13.4: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + otpauth@9.5.1: + dependencies: + '@noble/hashes': 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + pac-proxy-agent@7.2.0(supports-color@8.1.1): + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + get-uri: 6.0.5(supports-color@8.1.1) + http-proxy-agent: 7.0.2(supports-color@8.1.1) + https-proxy-agent: 7.0.6(supports-color@8.1.1) + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + + package-json-from-dist@1.0.1: {} + + pako@1.0.11: {} + + parse-json@7.1.1: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 3.0.2 + lines-and-columns: 2.0.4 + type-fest: 3.13.1 + + parse-ms@4.0.0: {} + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-exists@5.0.0: {} + + path-expression-matcher@1.6.2: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.7 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + progress@2.0.3: {} + + proxy-agent@6.5.0(supports-color@8.1.1): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + http-proxy-agent: 7.0.2(supports-color@8.1.1) + https-proxy-agent: 7.0.6(supports-color@8.1.1) + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0(supports-color@8.1.1) + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + query-selector-shadow-dom@1.0.1: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + react-is@18.3.1: {} + + react-is@19.2.7: {} + + read-pkg-up@10.1.0: + dependencies: + find-up: 6.3.0 + read-pkg: 8.1.0 + type-fest: 4.41.0 + + read-pkg@8.1.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 7.1.1 + type-fest: 4.41.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + recursive-readdir@2.2.3: + dependencies: + minimatch: 3.1.5 + + require-directory@2.1.1: {} + + resq@1.11.0: + dependencies: + fast-deep-equal: 2.0.1 + + ret@0.5.0: {} + + rgb2hex@0.2.5: {} + + run-async@4.0.6: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safaridriver@1.0.1: {} + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safer-buffer@2.1.2: {} + + semver@7.8.5: {} + + serialize-error@12.0.0: + dependencies: + type-fest: 4.41.0 + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + setimmediate@1.0.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5(supports-color@8.1.1): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.3.1 + smart-buffer: 4.2.0 + + source-map@0.6.1: + optional: true + + spacetrim@0.11.59: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@4.2.0: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stream-buffers@3.0.3: {} + + streamx@2.27.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@4.0.0: {} + + strip-json-comments@3.1.1: {} + + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tar-fs@3.1.3: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.4 + bare-path: 3.1.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.2 + fast-fifo: 1.3.2 + streamx: 2.27.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.27.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + tinyrainbow@1.2.0: {} + + tinyrainbow@3.1.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tslib@2.8.1: {} + + tsx@4.22.4: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-fest@3.13.1: {} + + type-fest@4.26.0: {} + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici-types@7.24.6: {} + + undici@6.28.0: {} + + undici@7.27.2: {} + + unicorn-magic@0.3.0: {} + + urlpattern-polyfill@10.1.0: {} + + userhome@1.0.1: {} + + util-deprecate@1.0.2: {} + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + wait-port@1.1.0(supports-color@8.1.1): + dependencies: + chalk: 4.1.2 + commander: 9.5.0 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + optional: true + + webdriver@9.30.0(supports-color@8.1.1): + dependencies: + '@types/node': 20.19.42 + '@types/ws': 8.18.1 + '@wdio/config': 9.30.0(supports-color@8.1.1) + '@wdio/logger': 9.29.1 + '@wdio/protocols': 9.30.0 + '@wdio/types': 9.29.1 + '@wdio/utils': 9.30.0(supports-color@8.1.1) + deepmerge-ts: 7.1.5 + https-proxy-agent: 7.0.6(supports-color@8.1.1) + undici: 6.28.0 + ws: 8.21.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + webdriverio@9.30.0(supports-color@8.1.1): + dependencies: + '@types/node': 20.19.42 + '@types/sinonjs__fake-timers': 8.1.5 + '@wdio/config': 9.30.0(supports-color@8.1.1) + '@wdio/logger': 9.29.1 + '@wdio/protocols': 9.30.0 + '@wdio/repl': 9.16.2 + '@wdio/types': 9.29.1 + '@wdio/utils': 9.30.0(supports-color@8.1.1) + archiver: 7.0.1 + aria-query: 5.3.2 + cheerio: 1.2.0 + css-shorthand-properties: 1.1.2 + css-value: 0.0.1 + grapheme-splitter: 1.0.4 + htmlfy: 0.8.1 + is-plain-obj: 4.1.0 + jszip: 3.10.1 + lodash.clonedeep: 4.5.0 + lodash.zip: 4.2.0 + query-selector-shadow-dom: 1.0.1 + resq: 1.11.0 + rgb2hex: 0.2.5 + serialize-error: 12.0.0 + urlpattern-polyfill: 10.1.0 + webdriver: 9.30.0(supports-color@8.1.1) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + + workerpool@6.5.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + xml-naming@0.3.0: {} + + y18n@5.0.8: {} + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + + yocto-queue@1.2.2: {} + + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.2.0: {} + + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 diff --git a/e2e/pnpm-workspace.yaml b/e2e/pnpm-workspace.yaml new file mode 100644 index 000000000..97481469e --- /dev/null +++ b/e2e/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: true + edgedriver: true + geckodriver: true diff --git a/e2e/scripts/provision.mjs b/e2e/scripts/provision.mjs new file mode 100644 index 000000000..07d0011d8 --- /dev/null +++ b/e2e/scripts/provision.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const envFile = path.resolve(import.meta.dirname, "../.env"); +if (fs.existsSync(envFile)) { + process.loadEnvFile(envFile); +} + +const CORE_URL = requireEnv("CORE_URL"); +const ADMIN_USER = process.env.CORE_ADMIN_USER ?? "admin"; +const ADMIN_PASSWORD = requireEnv("CORE_ADMIN_PASSWORD"); + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + console.error(`Missing required environment variable ${name}.`); + process.exit(1); + } + return value; +} + +const NETWORK = { + name: process.env.NETWORK_NAME ?? "e2e", + address: process.env.NETWORK_ADDRESS ?? "10.10.10.1/24", + endpoint: requireEnv("NETWORK_ENDPOINT"), + port: Number(process.env.NETWORK_PORT ?? 50051), + allowed_ips: process.env.NETWORK_ALLOWED_IPS ?? "10.10.10.0/24", + dns: null, + mtu: 1420, + fwmark: 0, + allow_all_groups: true, + allowed_groups: [], + keepalive_interval: 25, + peer_disconnect_threshold: 300, + acl_enabled: false, + acl_default_allow: false, + location_mfa_mode: "disabled", + service_location_mode: "disabled", +}; + +async function waitForCore() { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + try { + const res = await fetch(`${CORE_URL}/api/v1/health`); + if (res.status === 200) return; + } catch { + // not up yet + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + throw new Error(`Core did not become healthy at ${CORE_URL} within 120s`); +} + +let cookie = ""; + +async function api(method, apiPath, body) { + const res = await fetch(`${CORE_URL}${apiPath}`, { + method, + redirect: "manual", + headers: { + "Content-Type": "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (res.status >= 300 && res.status < 400) { + throw new Error( + `${method} ${apiPath} redirected (${res.status}) to ${res.headers.get("location")} — check CORE_URL`, + ); + } + if (!res.ok) { + const allow = res.headers.get("allow"); + throw new Error( + `${method} ${apiPath} failed: ${res.status}` + + (allow ? ` (Allow: ${allow})` : "") + + ` ${await res.text()}`, + ); + } + return res; +} + +console.log(`Waiting for core at ${CORE_URL}...`); +await waitForCore(); + +console.log("Logging in as admin..."); +let loginRes; +try { + loginRes = await api("POST", "/api/v1/auth", { + username: ADMIN_USER, + password: ADMIN_PASSWORD, + }); +} catch (error) { + if (String(error).includes("405")) { + console.error(`\nCore at ${CORE_URL} is still in initial-setup mode`); + process.exit(1); + } + throw error; +} +const setCookie = loginRes.headers.get("set-cookie"); +if (!setCookie) throw new Error("Login did not return a session cookie"); +cookie = setCookie.split(";")[0]; + +const existing = await (await api("GET", "/api/v1/network")).json(); +let network = existing.find((n) => n.name === NETWORK.name); +if (network) { + console.log(`Network "${NETWORK.name}" already exists with id ${network.id}`); +} else { + network = await (await api("POST", "/api/v1/network", NETWORK)).json(); + console.log(`Network created with id ${network.id}`); +} + +const gateways = await ( + await api("GET", `/api/v1/network/${network.id}/gateways`) +).json(); +if (gateways.length === 0) { + process.exit(1); +} +console.log(`Gateway connected: ${gateways[0].name}`); diff --git a/e2e/tests/enrollment.spec.ts b/e2e/tests/enrollment.spec.ts new file mode 100644 index 000000000..7bb5787e2 --- /dev/null +++ b/e2e/tests/enrollment.spec.ts @@ -0,0 +1,73 @@ +import { $, expect } from "@wdio/globals"; +import { resetInstances } from "../helpers/client.js"; +import { + connectAndPing, + disconnect, + FULL_MFA_VIEW, + TRAY_MFA_VIEW, +} from "../helpers/connection.js"; +import { + type CoreApi, + type EnrollmentFixture, + type LocationMfaMode, + loggedInCoreApi, +} from "../helpers/coreApi.js"; +import { + addInstance, + configureTotp, + finishEnrollment, + setPassword, +} from "../helpers/enrollment.js"; +import { switchToTrayView } from "../helpers/windows.js"; + +describe("enrollment", () => { + let core: CoreApi; + let networkId: number; + let previousMfaMode: LocationMfaMode; + let fixture: EnrollmentFixture; + + beforeEach(async () => { + core = await loggedInCoreApi(); + networkId = (await core.listNetworks())[0].id; + }); + + afterEach(async () => { + await core.setLocationMfaMode(networkId, previousMfaMode); + await resetInstances(); + if (fixture?.ephemeral) { + await core.deleteUser(fixture.username); + } + }); + + it("enrolls a user without MFA and connects from the full and tray views", async () => { + previousMfaMode = await core.setLocationMfaMode(networkId, "disabled"); + fixture = await core.createEnrollmentFixture(); + + await addInstance(fixture); + await setPassword(); + await expect($("#mfa-configuration-step")).not.toBeDisplayed(); + await finishEnrollment(); + + await connectAndPing(FULL_MFA_VIEW); + await disconnect(); + + await switchToTrayView(); + await connectAndPing(TRAY_MFA_VIEW); + }); + + it("enrolls a user with TOTP MFA and connects from the full and tray views", async () => { + previousMfaMode = await core.setLocationMfaMode(networkId, "internal"); + fixture = await core.createEnrollmentFixture(); + + await addInstance(fixture); + await setPassword(); + const secret = await configureTotp(); + await finishEnrollment(); + + await connectAndPing(FULL_MFA_VIEW, secret); + await disconnect(); + + await switchToTrayView(); + await connectAndPing(TRAY_MFA_VIEW, secret); + }); +}); diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts new file mode 100644 index 000000000..f64648989 --- /dev/null +++ b/e2e/tests/logs.spec.ts @@ -0,0 +1,42 @@ +import { $, browser, expect } from "@wdio/globals"; +import { readClipboard } from "../helpers/clipboard.js"; +import { switchToFullView } from "../helpers/windows.js"; + +const openActionsMenu = async () => { + const actions = $("#log-page-view").$("button=Actions"); + await actions.waitForClickable(); + await actions.click(); +}; + +describe("logs", () => { + beforeEach(async () => { + await switchToFullView(); + const logLink = $('a[href="/full/log"]'); + await logLink.waitForClickable(); + await logLink.click(); + await expect($("#log-page-view")).toBeDisplayed(); + await $("#log-page-view .log-container p").waitForExist({ + timeout: 15_000, + }); + }); + + it("copies logs to the clipboard", async () => { + const firstLine = $("#log-page-view .log-container p"); + const sample = ( + (await firstLine.getProperty("textContent")) as string + ).trim(); + await openActionsMenu(); + const copy = $(".menu-item*=Copy to Clipboard"); + await copy.waitForClickable(); + await copy.click(); + await browser.waitUntil(() => readClipboard().includes(sample), { + timeout: 10_000, + timeoutMsg: "Clipboard does not contain the logs after copying", + }); + }); + + it("offers a logs download", async () => { + await openActionsMenu(); + await expect($(".menu-item*=Download")).toBeDisplayed(); + }); +}); diff --git a/e2e/tests/wireguard_tunnel.spec.ts b/e2e/tests/wireguard_tunnel.spec.ts new file mode 100644 index 000000000..637bd106b --- /dev/null +++ b/e2e/tests/wireguard_tunnel.spec.ts @@ -0,0 +1,203 @@ +import { $, browser, expect } from "@wdio/globals"; +import { + connectAndPing, + disconnect, + FULL_MFA_VIEW, + TRAY_MFA_VIEW, +} from "../helpers/connection.js"; +import { + type CoreApi, + type LocationMfaMode, + loggedInCoreApi, +} from "../helpers/coreApi.js"; +import { switchToFullView, switchToTrayView } from "../helpers/windows.js"; +import { provisionTunnel, type TunnelConfig } from "../helpers/wireguard.js"; + +const field = (name: string) => $(`[data-testid="field-${name}"]`); + +const clearField = async (name: string) => { + const input = field(name); + await input.waitForClickable(); + await input.click(); + await browser.keys(["Control", "a"]); + await browser.keys(["Backspace"]); + await expect(input).toHaveValue(""); +}; + +const setField = async (name: string, value: string) => { + await clearField(name); + await field(name).addValue(value); + await expect(field(name)).toHaveValue(value); +}; + +const continueStep = async (stepId: string) => { + const button = $(stepId).$("button=Continue"); + await button.waitForClickable(); + await button.click(); +}; + +const selectTunnel = async (name: string) => { + await switchToFullView(); + const overviewLink = $('a[href="/full/overview"]'); + await overviewLink.waitForClickable(); + await overviewLink.click(); + await $("#overview-page").waitForDisplayed(); + const item = $(".overview-selection").$(`button=${name}`); + await item.waitForClickable(); + await item.click(); +}; + +const openTunnelAction = async (action: string) => { + const actions = $(".overview-header-actions"); + await actions.waitForClickable(); + await actions.click(); + const item = $(`.menu-item*=${action}`); + await item.waitForClickable(); + await item.click(); +}; + +const openTunnelEditModal = async () => { + await openTunnelAction("Edit"); + await $("#update-tunnel-modal").waitForDisplayed(); +}; + +const detailsRow = (label: string) => + $("#location-details-page").$(`.row*=${label}`); + +const openTunnelDetails = async () => { + const info = $("#overview-page .info-btn"); + await info.waitForClickable(); + await info.click(); + await $("#location-details-page").waitForDisplayed(); +}; + +const closeTunnelDetails = async () => { + const back = $("#location-details-page").$("button=Back"); + await back.waitForClickable(); + await back.click(); + await $("#overview-page").waitForDisplayed(); +}; + +const deleteTunnel = async (name: string) => { + await selectTunnel(name); + await openTunnelAction("Delete"); + const confirm = $("#confirm-modal").$("button=Delete tunnel"); + await confirm.waitForClickable(); + await confirm.click(); + await $("#confirm-modal").waitForDisplayed({ reverse: true }); +}; + +const submitTunnelEdit = async () => { + const update = $("#update-tunnel-modal").$("button=Update"); + await update.waitForClickable(); + await update.click(); + await $("#update-tunnel-modal").waitForDisplayed({ reverse: true }); +}; + +describe("WireGuard tunnel", () => { + let core: CoreApi; + let networkId: number; + let previousMfaMode: LocationMfaMode; + let config: TunnelConfig; + + before(async () => { + core = await loggedInCoreApi(); + networkId = (await core.listNetworks())[0].id; + previousMfaMode = await core.setLocationMfaMode(networkId, "disabled"); + config = await provisionTunnel(core, networkId, `e2e-tunnel-${Date.now()}`); + }); + + after(async () => { + await core.setLocationMfaMode(networkId, previousMfaMode); + await deleteTunnel(config.name); + await core.deleteDevice(config.deviceId); + }); + + it("adds a tunnel from a core-provisioned config", async () => { + await switchToFullView(); + await $("#add-page-view").$("button=Add tunnel").click(); + await $("#add-tunnel-page").$("button=Add tunnel").click(); + + await expect($("#general-info-step")).toBeDisplayed(); + await setField("name", config.name); + await setField("address", config.address); + await continueStep("#general-info-step"); + + await expect($("#keys-step")).toBeDisplayed(); + await setField("prvkey", config.prvkey); + await setField("pubkey", config.pubkey); + await continueStep("#keys-step"); + + await expect($("#vpn-server-step")).toBeDisplayed(); + await setField("server_pubkey", config.serverPubkey); + await setField("endpoint", config.endpoint); + await setField("allowed_ips", config.allowedIps); + await setField("dns", config.dns || "1.1.1.1"); + await clearField("dns"); + await expect($("#vpn-server-step")).not.toHaveText("Invalid input", { + containing: true, + }); + await continueStep("#vpn-server-step"); + + await expect($("#advanced-settings-step")).toBeDisplayed(); + await continueStep("#advanced-settings-step"); + + await expect($("#finish-step")).toBeDisplayed(); + await expect($("#finish-step")).toHaveText("added successfully", { + containing: true, + }); + + await $("#finish-step").$("button=Got it").click(); + await expect($("#overview-page")).toHaveText(config.name, { + containing: true, + }); + }); + + it("edits an existing tunnel and clears its optional fields", async () => { + const dns = config.dns || "8.8.8.8"; + await selectTunnel(config.name); + + await openTunnelEditModal(); + await setField("dns", dns); + await setField("post_up", "echo up"); + await submitTunnelEdit(); + + await openTunnelDetails(); + await expect(detailsRow("DNS servers")).toHaveText(dns, { + containing: true, + }); + await closeTunnelDetails(); + + await openTunnelEditModal(); + await expect(field("dns")).toHaveValue(dns); + await expect(field("post_up")).toHaveValue("echo up"); + + await clearField("dns"); + await clearField("post_up"); + await expect($("#update-tunnel-modal")).not.toHaveText("Invalid input", { + containing: true, + }); + await submitTunnelEdit(); + + await openTunnelEditModal(); + await expect(field("dns")).toHaveValue(""); + await expect(field("post_up")).toHaveValue(""); + + await $("#update-tunnel-modal").$("button=Cancel").click(); + await $("#update-tunnel-modal").waitForDisplayed({ reverse: true }); + }); + + it("connects the tunnel and pings the gateway from the full view", async () => { + await selectTunnel(config.name); + + await connectAndPing(FULL_MFA_VIEW); + await disconnect(); + }); + + it("connects the tunnel and pings the gateway from the tray view", async () => { + await switchToTrayView(); + + await connectAndPing(TRAY_MFA_VIEW); + await disconnect(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 000000000..a1b5497b6 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node", "@wdio/globals/types", "@wdio/mocha-framework"] + }, + "include": ["**/*.ts"] +} diff --git a/e2e/wdio.conf.ts b/e2e/wdio.conf.ts new file mode 100644 index 000000000..bf8c3cb5e --- /dev/null +++ b/e2e/wdio.conf.ts @@ -0,0 +1,131 @@ +import { type ChildProcess, spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const here = import.meta.dirname; + +const DRIVER_PORT = 4444; +const DRIVER_READY_DELAY_MS = 1_000; +const TEST_TIMEOUT_MS = 120_000; +const WAIT_FOR_TIMEOUT_MS = 15_000; + +const envFile = path.resolve(here, ".env"); +if (fs.existsSync(envFile)) { + process.loadEnvFile(envFile); +} + +const clientBinary = + process.env.CLIENT_BINARY ?? + path.resolve(here, "../src-tauri/target/release/defguard-client"); +const tauriDriverBinary = + process.env.TAURI_DRIVER ?? + path.join(os.homedir(), ".cargo", "bin", "tauri-driver"); +const nativeDriver = process.env.NATIVE_DRIVER; + +let tauriDriver: ChildProcess | undefined; +let dataDir: string | undefined; + +const killLeftoverClients = () => { + spawnSync("pkill", ["-f", clientBinary]); +}; + +const cleanupWireguardInterfaces = () => { + const listed = spawnSync("ip", ["-j", "link", "show", "type", "wireguard"], { + encoding: "utf8", + }); + const links = JSON.parse(listed.stdout || "[]") as Array<{ ifname: string }>; + for (const { ifname } of links) { + if (!/^wg\d+$/.test(ifname)) continue; + if (spawnSync("ip", ["link", "delete", ifname]).status !== 0) { + spawnSync("sudo", ["-n", "ip", "link", "delete", ifname]); + } + } +}; + +const cleanup = () => { + tauriDriver?.kill(); + tauriDriver = undefined; + killLeftoverClients(); + cleanupWireguardInterfaces(); +}; + +process.on("exit", cleanup); +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + cleanup(); + process.exit(130); + }); +} + +export const config: WebdriverIO.Config = { + runner: "local", + hostname: "127.0.0.1", + port: DRIVER_PORT, + logLevel: "info", + specs: ["./tests/**/*.spec.ts"], + maxInstances: 1, + capabilities: [ + { + "wdio:maxInstances": 1, + "tauri:options": { application: clientBinary }, + } as WebdriverIO.Capabilities, + ], + reporters: ["spec"], + framework: "mocha", + mochaOpts: { ui: "bdd", timeout: TEST_TIMEOUT_MS }, + waitforTimeout: WAIT_FOR_TIMEOUT_MS, + connectionRetryTimeout: 120_000, + connectionRetryCount: 2, + + onPrepare: () => { + if (!fs.existsSync(clientBinary)) { + throw new Error( + `Client binary not found at ${clientBinary}. Build it with ` + + "`pnpm tauri build` or set CLIENT_BINARY.", + ); + } + }, + + beforeSession: () => { + killLeftoverClients(); + cleanupWireguardInterfaces(); + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "defguard-e2e-")); + tauriDriver = spawn( + tauriDriverBinary, + nativeDriver ? ["--native-driver", nativeDriver] : [], + { + stdio: ["ignore", "inherit", "inherit"], + env: { + ...process.env, + DEFGUARD_CLIENT_WELCOME_SKIP: "1", + XDG_DATA_HOME: path.join(dataDir, "share"), + XDG_CONFIG_HOME: path.join(dataDir, "config"), + XDG_CACHE_HOME: path.join(dataDir, "cache"), + }, + }, + ); + tauriDriver.on("error", (error) => { + console.error("tauri-driver failed to start:", error); + process.exit(1); + }); + return new Promise((resolve) => setTimeout(resolve, DRIVER_READY_DELAY_MS)); + }, + + afterTest: () => cleanupWireguardInterfaces(), + + afterSession: () => { + cleanup(); + if (dataDir) { + fs.rmSync(dataDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); + dataDir = undefined; + } + }, + + onComplete: cleanup, +}; diff --git a/flake.lock b/flake.lock index 21b6ba820..35dd08b55 100644 --- a/flake.lock +++ b/flake.lock @@ -12,6 +12,21 @@ }, "parent": [] }, + "crane": { + "locked": { + "lastModified": 1785284101, + "narHash": "sha256-ghcXEpYEM4a7pbEkoqbn8c0ptJJqgGzuFiG3T6W5g4I=", + "owner": "ipetkov", + "repo": "crane", + "rev": "756d6d07c3818ea95d1e2cdac63fa7d02fe3e61b", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "defguard-ui": { "flake": false, "locked": { @@ -44,12 +59,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1771423170, - "narHash": "sha256-K7Dg9TQ0mOcAtWTO/FX/FaprtWQ8BmEXTpLIaNRhEwU=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "bcc4a9d9533c033d806a46b37dc444f9b0da49dd", - "type": "github" + "lastModified": 1785141334, + "narHash": "sha256-jIYqF10/p99GhQYHFnT7XgCRWavkL4Iie1DEyIIZSc0=", + "rev": "38a4887411571457d700c51c64a6e49ead2ed5ab", + "type": "tarball", + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1042399.38a488741157/nixexprs.tar.xz" }, "original": { "id": "nixpkgs", @@ -87,6 +101,7 @@ "root": { "inputs": { "boringtun": "boringtun", + "crane": "crane", "defguard-ui": "defguard-ui", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", @@ -99,11 +114,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1771816254, - "narHash": "sha256-vkp3iTF6QmHMvL+34DI93IiMPjS2lqcMlA1fl7nXVsQ=", + "lastModified": 1785302874, + "narHash": "sha256-fpKEww3TJoo1ANHO2q918ei+ayOrp0YEQAO1DuBLOB4=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "085bdbf5dde5477538e4c87d1684b6c6df56c0ad", + "rev": "b99d48435bc3e34309d2c7ae6f7d45e77a156c38", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 2621b4c07..8da372235 100644 --- a/flake.nix +++ b/flake.nix @@ -3,6 +3,7 @@ nixpkgs.url = "nixpkgs"; flake-utils.url = "github:numtide/flake-utils"; rust-overlay.url = "github:oxalica/rust-overlay"; + crane.url = "github:ipetkov/crane"; # let git manage submodules self.submodules = true; @@ -25,26 +26,55 @@ nixpkgs, flake-utils, rust-overlay, + crane, ... }: flake-utils.lib.eachDefaultSystem (system: let - # add rust overlay - pkgs = import nixpkgs { + # Plain nixpkgs — used for packages and checks. + pkgs = import nixpkgs {inherit system;}; + + # nixpkgs with rust-overlay — only needed for the dev shell, which uses + # pkgs.rust-bin to get a customised Rust toolchain. + devPkgs = import nixpkgs { inherit system; overlays = [rust-overlay.overlays.default]; }; + + craneLib = crane.mkLib pkgs; + + defguard-client = pkgs.callPackage ./nix/package.nix { + inherit pkgs craneLib; + }; in { devShells.default = import ./nix/shell.nix { - inherit pkgs; + pkgs = devPkgs; + inherit crane; }; - packages.default = pkgs.callPackage ./nix/package.nix { - inherit pkgs; + packages = { + default = defguard-client; + inherit defguard-client; + defguard-service = + pkgs.runCommand "defguard-service" { + nativeBuildInputs = [pkgs.makeWrapper]; + } '' + mkdir -p $out/bin + cp ${defguard-client}/bin/defguard-service $out/bin/ + ''; + dg = + pkgs.runCommand "dg" { + nativeBuildInputs = [pkgs.makeWrapper]; + } '' + mkdir -p $out/bin + cp ${defguard-client}/bin/dg $out/bin/ + ''; }; + checks.default = defguard-client; + formatter = pkgs.alejandra; }) // { - nixosModules.default = import ./nix/nixos-module.nix; + nixosModules.default = import ./nix/nixos-module.nix {mkCraneLib = crane.mkLib;}; }; } diff --git a/index.html b/index.html deleted file mode 100644 index 91ff3d134..000000000 --- a/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - main window - - - -
-
-
- - - diff --git a/justfile b/justfile new file mode 100644 index 000000000..934ff907c --- /dev/null +++ b/justfile @@ -0,0 +1,11 @@ +set windows-shell := ["powershell.exe", "-c"] + +dev: + npx concurrently \ + -n "NEW,TAURI" \ + "cd new-ui && pnpm dev" \ + "cargo tauri dev" + +build: + cd new-ui; pnpm build + cargo tauri build --config .\src-tauri\tauri.local.conf.json diff --git a/new-ui/.gitignore b/new-ui/.gitignore new file mode 100644 index 000000000..07e023ab3 --- /dev/null +++ b/new-ui/.gitignore @@ -0,0 +1,25 @@ +# 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? +.tanstack diff --git a/new-ui/.nvmrc b/new-ui/.nvmrc new file mode 100644 index 000000000..6f4247a62 --- /dev/null +++ b/new-ui/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/new-ui/.prettierignore b/new-ui/.prettierignore new file mode 100644 index 000000000..402ea0086 --- /dev/null +++ b/new-ui/.prettierignore @@ -0,0 +1,3 @@ +/src/**/*.tsx +/src/**/*.ts +/src/**/*.js diff --git a/new-ui/.prettierrc b/new-ui/.prettierrc new file mode 100644 index 000000000..71a0f3291 --- /dev/null +++ b/new-ui/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "tabWidth": 2, + "singleQuote": true, + "useTabs": false, + "printWidth": 90, + "endOfLine": "lf" +} diff --git a/new-ui/.stylelintrc.json b/new-ui/.stylelintrc.json new file mode 100644 index 000000000..01a6bcd5c --- /dev/null +++ b/new-ui/.stylelintrc.json @@ -0,0 +1,11 @@ +{ + "extends": ["stylelint-config-standard-scss"], + "plugins": ["stylelint-scss"], + "rules": { + "at-rule-no-unknown": null, + "scss/at-rule-no-unknown": true, + "custom-property-empty-line-before": null, + "value-keyword-case": null, + "selector-class-pattern": null + } +} diff --git a/new-ui/README.md b/new-ui/README.md new file mode 100644 index 000000000..7dbf7ebf3 --- /dev/null +++ b/new-ui/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/new-ui/biome.json b/new-ui/biome.json new file mode 100644 index 000000000..13c5e22ac --- /dev/null +++ b/new-ui/biome.json @@ -0,0 +1,77 @@ +{ + "root": true, + "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": [ + "src/**", + "!src/messages", + "!src/paraglide/**/*.js", + "!src/routeTree.gen.ts", + "!src/**/*.scss" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "attributePosition": "auto", + "bracketSameLine": false, + "bracketSpacing": true, + "expand": "auto", + "lineEnding": "lf", + "lineWidth": 90, + "indentStyle": "space", + "useEditorconfig": true + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "a11y": "off", + "correctness": { + "useUniqueElementIds": "off" + }, + "style": { + "useLiteralEnumMembers": "off", + "useBlockStatements": "off" + }, + "suspicious": { + "noArrayIndexKey": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always", + "attributePosition": "auto", + "bracketSameLine": false, + "bracketSpacing": true + } + }, + "css": { + "linter": { + "enabled": false + }, + "formatter": { + "enabled": false + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/new-ui/index.html b/new-ui/index.html new file mode 100644 index 000000000..5c1990e19 --- /dev/null +++ b/new-ui/index.html @@ -0,0 +1,17 @@ + + + + + + + Defguard + + + +
+
+
+ + + + diff --git a/new-ui/package.json b/new-ui/package.json new file mode 100644 index 000000000..a19a5ba1d --- /dev/null +++ b/new-ui/package.json @@ -0,0 +1,70 @@ +{ + "name": "new-ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "biome": "biome", + "lint": "biome check ./src/ && prettier src/**/*.scss --check --log-level error && stylelint \"src/**/*.scss\" -c ./.stylelintrc.json --fix && tsc -b", + "fix": "biome check ./src/ --write --unsafe && prettier src/**/*.scss -w --log-level silent", + "tsc": "tsc", + "preview": "vite preview" + }, + "dependencies": { + "@biomejs/biome": "2.5.7", + "@floating-ui/react": "^0.27.20", + "@stablelib/base64": "^2.0.1", + "@stablelib/x25519": "^2.0.1", + "@tanstack/react-form": "^1.33.5", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-router": "^1.170.29", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-fs": "^2.5.1", + "@tauri-apps/plugin-http": "^2.5.9", + "@tauri-apps/plugin-log": "^2.9.0", + "@tauri-apps/plugin-opener": "^2.5.4", + "@tauri-apps/plugin-os": "^2.3.2", + "@uidotdev/usehooks": "^2.4.1", + "byte-size": "^9.0.1", + "chart.js": "^4.5.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.23", + "motion": "^12.43.0", + "p-timeout": "^7.0.1", + "prettier": "^3.9.6", + "qrcode.react": "^4.2.0", + "radashi": "^12.9.1", + "react": "^19.2.8", + "react-chartjs-2": "^5.3.1", + "react-dom": "^19.2.8", + "react-loading-skeleton": "^3.5.0", + "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "rxjs": "^7.8.2", + "sass": "^1.102.0", + "zod": "^4.4.3", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@tanstack/devtools-vite": "^0.8.3", + "@tanstack/router-plugin": "^1.168.32", + "@types/byte-size": "^8.1.2", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "autoprefixer": "^10.5.4", + "globals": "^17.11.0", + "stylelint": "^17.14.1", + "stylelint-config-standard-scss": "^17.0.0", + "stylelint-scss": "^7.2.0", + "typescript": "~6.0.3", + "vite": "^8.2.1" + } +} diff --git a/new-ui/pnpm-lock.yaml b/new-ui/pnpm-lock.yaml new file mode 100644 index 000000000..4e07e6b86 --- /dev/null +++ b/new-ui/pnpm-lock.yaml @@ -0,0 +1,4274 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@biomejs/biome': + specifier: 2.5.7 + version: 2.5.7 + '@floating-ui/react': + specifier: ^0.27.20 + version: 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@stablelib/base64': + specifier: ^2.0.1 + version: 2.0.1 + '@stablelib/x25519': + specifier: ^2.0.1 + version: 2.0.1 + '@tanstack/react-form': + specifier: ^1.33.5 + version: 1.33.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-query': + specifier: ^5.101.4 + version: 5.101.4(react@19.2.8) + '@tanstack/react-router': + specifier: ^1.170.29 + version: 1.170.29(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tauri-apps/api': + specifier: ^2.11.1 + version: 2.11.1 + '@tauri-apps/plugin-clipboard-manager': + specifier: ^2.3.2 + version: 2.3.2 + '@tauri-apps/plugin-dialog': + specifier: ^2.7.2 + version: 2.7.2 + '@tauri-apps/plugin-fs': + specifier: ^2.5.1 + version: 2.5.1 + '@tauri-apps/plugin-http': + specifier: ^2.5.9 + version: 2.5.9 + '@tauri-apps/plugin-log': + specifier: ^2.9.0 + version: 2.9.0 + '@tauri-apps/plugin-opener': + specifier: ^2.5.4 + version: 2.5.4 + '@tauri-apps/plugin-os': + specifier: ^2.3.2 + version: 2.3.2 + '@uidotdev/usehooks': + specifier: ^2.4.1 + version: 2.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + byte-size: + specifier: ^9.0.1 + version: 9.0.1 + chart.js: + specifier: ^4.5.1 + version: 4.5.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dayjs: + specifier: ^1.11.23 + version: 1.11.23 + motion: + specifier: ^12.43.0 + version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + p-timeout: + specifier: ^7.0.1 + version: 7.0.1 + prettier: + specifier: ^3.9.6 + version: 3.9.6 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.8) + radashi: + specifier: ^12.9.1 + version: 12.9.1 + react: + specifier: ^19.2.8 + version: 19.2.8 + react-chartjs-2: + specifier: ^5.3.1 + version: 5.3.1(chart.js@4.5.1)(react@19.2.8) + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + react-loading-skeleton: + specifier: ^3.5.0 + version: 3.5.0(react@19.2.8) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.18)(react@19.2.8)(supports-color@10.2.2) + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1(supports-color@10.2.2) + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + sass: + specifier: ^1.102.0 + version: 1.102.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + zustand: + specifier: ^5.0.15 + version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + devDependencies: + '@tanstack/devtools-vite': + specifier: ^0.8.3 + version: 0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0)) + '@tanstack/router-plugin': + specifier: ^1.168.32 + version: 1.168.32(@tanstack/react-router@1.170.29(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.2.4)(supports-color@10.2.2)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0)) + '@types/byte-size': + specifier: ^8.1.2 + version: 8.1.2 + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.0.5 + version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0)) + autoprefixer: + specifier: ^10.5.4 + version: 10.5.4(postcss@8.5.26) + globals: + specifier: ^17.11.0 + version: 17.11.0 + stylelint: + specifier: ^17.14.1 + version: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) + stylelint-config-standard-scss: + specifier: ^17.0.0 + version: 17.0.0(postcss@8.5.26)(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)) + stylelint-scss: + specifier: ^7.2.0 + version: 7.2.0(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)) + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.5.7': + resolution: {integrity: sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.7': + resolution: {integrity: sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.7': + resolution: {integrity: sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.7': + resolution: {integrity: sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.7': + resolution: {integrity: sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.7': + resolution: {integrity: sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.7': + resolution: {integrity: sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.7': + resolution: {integrity: sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.7': + resolution: {integrity: sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8': + resolution: {integrity: sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.1': + resolution: {integrity: sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.20': + resolution: {integrity: sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-parser/binding-android-arm-eabi@0.120.0': + resolution: {integrity: sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.120.0': + resolution: {integrity: sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.120.0': + resolution: {integrity: sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.120.0': + resolution: {integrity: sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.120.0': + resolution: {integrity: sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': + resolution: {integrity: sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': + resolution: {integrity: sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': + resolution: {integrity: sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.120.0': + resolution: {integrity: sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': + resolution: {integrity: sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': + resolution: {integrity: sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': + resolution: {integrity: sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': + resolution: {integrity: sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.120.0': + resolution: {integrity: sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.120.0': + resolution: {integrity: sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.120.0': + resolution: {integrity: sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.120.0': + resolution: {integrity: sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.120.0': + resolution: {integrity: sha512-1T0HKGcsz/BKo77t7+89L8Qvu4f9DoleKWHp3C5sJEcbCjDOLx3m9m722bWZTY+hANlUEs+yjlK+lBFsA+vrVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': + resolution: {integrity: sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.120.0': + resolution: {integrity: sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.120.0': + resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@stablelib/base64@2.0.1': + resolution: {integrity: sha512-P2z89A7N1ETt6RxgpVdDT2xlg8cnm3n6td0lY9gyK7EiWK3wdq388yFX/hLknkCC0we05OZAD1rfxlQJUbl5VQ==} + + '@stablelib/binary@2.0.1': + resolution: {integrity: sha512-U9iAO8lXgEDONsA0zPPSgcf3HUBNAqHiJmSHgZz62OvC3Hi2Bhc5kTnQ3S1/L+sthDTHtCMhcEiklmIly6uQ3w==} + + '@stablelib/bytes@2.0.1': + resolution: {integrity: sha512-QIzI6V7nkJA5CjOZ7GoceBd4CIKrJoC471VaI6jh1xPQ2cMhkhQK4HddyzCXOR2y+fBF3/5B2HO3FXXI9C+Xzg==} + + '@stablelib/int@2.0.1': + resolution: {integrity: sha512-Ht63fQp3wz/F8U4AlXEPb7hfJOIILs8Lq55jgtD7KueWtyjhVuzcsGLSTAWtZs3XJDZYdF1WcSKn+kBtbzupww==} + + '@stablelib/keyagreement@2.0.1': + resolution: {integrity: sha512-2+tWBLCMtWlHQ7GqjD5L+lQRyWtun4Lou0IOdTML8zuTuAS0EgihnHFx+4uMZwYU1In40J/WlpyKSLidHfStRQ==} + + '@stablelib/random@2.0.1': + resolution: {integrity: sha512-W6GAtXEEs7r+dSbuBsvoFmlyL3gLxle41tQkjKu17dDWtDdjhVUbtRfRCQcCUeczwkgjQxMPopgwYEvxXtHXGw==} + + '@stablelib/wipe@2.0.1': + resolution: {integrity: sha512-1eU2K9EgOcV4qc9jcP6G72xxZxEm5PfeI5H55l08W95b4oRJaqhmlWRc4xZAm6IVSKhVNxMi66V67hCzzuMTAg==} + + '@stablelib/x25519@2.0.1': + resolution: {integrity: sha512-qi04HS2puHaBf50kM/kes5QcZFGsx8yF0YmCjLCOa/LPmnBaKEKX9ZR82OnnCwMn72YH13R/bBZgr/UP0aPFfA==} + + '@tanstack/devtools-bundler-core@0.1.1': + resolution: {integrity: sha512-2kowecGXNi/FAnwmJKW3WDZ6XuacHDcz4JsMmx43E21G6ZFmoQFuOJCVuv2bFkQZIR1M7+FVLQF5bdS5MQY62Q==} + engines: {node: '>=18'} + + '@tanstack/devtools-client@0.0.8': + resolution: {integrity: sha512-cG3iZkGWCwN330bLBKa8+9r4Of2AXNoz2zUqcsy/4XsD3105ghVBx78cGyvJj9fSclNomPxoqAnDGXXhg1WLvA==} + engines: {node: '>=18'} + + '@tanstack/devtools-event-bus@0.4.2': + resolution: {integrity: sha512-2LHzhwBFlKHCcklsQrGe8TeyjHd4XAF8nuCO6wHmva5fePUkJUULbu6CsCNAlGlCi0KkEsMXZSvRdR4HgMq4yA==} + engines: {node: '>=18'} + + '@tanstack/devtools-event-client@0.4.4': + resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/devtools-event-client@0.5.0': + resolution: {integrity: sha512-H+OH3zC6Vhu/K0NaVfQKknEKawc/+2PT+D3SB3Ox0V8SiMlTo0abbmH2rH0721R2aNYbjdMXA1oENOd8E2UVoA==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/devtools-vite@0.8.3': + resolution: {integrity: sha512-MqqE4/rdQUG55Y8Zux1Jj1I2wIBHdqYgjAJzP1grMUtFqSZ2XIDB7BHEV9UW/vrbhK7ocl4yFgVaJWROv0zeDA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@tanstack/form-core@1.33.5': + resolution: {integrity: sha512-3dfx9MBP0aq5sXKteikG629X9oviptrQj0IFRk9YGcb+lB7Kv5x8S17oOSk1wUWgjQZ4xVJEMbKwOAODymocgA==} + + '@tanstack/history@1.162.1': + resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} + engines: {node: '>=20.19'} + + '@tanstack/pacer-lite@0.1.1': + resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} + engines: {node: '>=18'} + + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@tanstack/react-form@1.33.5': + resolution: {integrity: sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA==} + peerDependencies: + '@tanstack/react-start': '*' + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router@1.170.29': + resolution: {integrity: sha512-xQwakR0ReaUGu3xeXEl2CqzlIXk4i9vxaf/zqdzoELMY0mDZgMOr+xobiA5cQdn8cnovqZgqqUxcwXhsruC+NA==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.11.1': + resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.24': + resolution: {integrity: sha512-+j+8NmV4QOS+ILNLdXFlLTdKPPbBQIy6mXDZ0QWEKYZ0xQOdlnOCXVHPflyGanD2SWSxjqGkw8CsW0CXXP7fLg==} + engines: {node: '>=20.19'} + + '@tanstack/router-generator@1.167.30': + resolution: {integrity: sha512-Tf9TJfdpP0yL3k1D1ke6hR1dvPCgKShTik3HcBRDeTK5eNrp9/X0Q0YApN8thE6pVcXL6a78O3ZE7XN7s7KwNw==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.168.32': + resolution: {integrity: sha512-P+qYIY9b/K+ZGUPNiyLqsd20vN/1abgmSNG0Ch9KTKldag3icR7TlrJgnM2km86yuRdXa4nJ5YT7+JLV5rJRZg==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.170.29 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.162.2': + resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} + + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/plugin-clipboard-manager@2.3.2': + resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + + '@tauri-apps/plugin-dialog@2.7.2': + resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} + + '@tauri-apps/plugin-fs@2.5.1': + resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} + + '@tauri-apps/plugin-http@2.5.9': + resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==} + + '@tauri-apps/plugin-log@2.9.0': + resolution: {integrity: sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw==} + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + + '@tauri-apps/plugin-os@2.3.2': + resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/byte-size@8.1.2': + resolution: {integrity: sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@uidotdev/usehooks@2.4.1': + resolution: {integrity: sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==} + engines: {node: '>=16'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + + '@vitejs/plugin-react@6.0.5': + resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + 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 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + autoprefixer@10.5.4: + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + baseline-browser-mapping@2.11.14: + resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} + engines: {node: '>=6.0.0'} + hasBin: true + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + byte-size@9.0.1: + resolution: {integrity: sha512-YLe9x3rabBrcI0cueCdLS2l5ONUKywcRpTs02B8KP9/Cimhj7o3ZccGrPnRvcbyHMbb7W79/3MUJl7iGgTXKEw==} + engines: {node: '>=12.17'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + electron-to-chromium@1.5.408: + resolution: {integrity: sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + engines: {node: '>=18'} + + globby@16.2.3: + resolution: {integrity: sha512-VZX7TV7jmd/pn71vdnLKtgwy1IWqc3KjI9x1/UtPkwoKk5fKrNLY30ltDe3cAM5xruIN7YuuaulFt133jRrKZg==} + engines: {node: '>=20'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + immutable@5.1.9: + resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isbot@5.2.1: + resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + launch-editor@2.14.1: + resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.43.0: + resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + oxc-parser@0.120.0: + resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} + engines: {node: ^20.19.0 || >=22.12.0} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.5: + resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radashi@12.9.1: + resolution: {integrity: sha512-HCvrL1Ag7qnyH11UiSWQaEIiizJ7kldHjBw63aELoum7C8nQrSLqotLDuKKvoRPtO0w8azCzUQcL3yrU3lBksw==} + engines: {node: '>=16.0.0'} + + react-chartjs-2@5.3.1: + resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==} + peerDependencies: + chart.js: ^4.1.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-loading-skeleton@3.5.0: + resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} + peerDependencies: + react: '>=16.8.0' + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown@1.2.4: + resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + sass@1.102.0: + resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==} + engines: {node: '>=20.19.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.6.2: + resolution: {integrity: sha512-TfxuUjlbBESzUOWdTkTKqvSmav0ABym+itetDXLK6mDz8SmrpdI30aF8RTXE8Bvq+tH/1yIDkvy3W0lfQb1ipQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.2: + resolution: {integrity: sha512-mPT+SD2TrlB6wvte1KkYOYUkubaTbd6pZ/6Kk3C9nxzrHmCZyhxOO7XGAeL7f+yLKZglzGtM9odUVvg/EhO+vQ==} + engines: {node: '>=10'} + + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + stylelint-config-recommended-scss@17.0.1: + resolution: {integrity: sha512-x5DVehzJudcwF0od3sGpgkln2PLLranFE7twwbp7dqDINCyZvwzFkMc6TLhNOvazRiVBJYATQLouJY0xPGB8WA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^17.0.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard-scss@17.0.0: + resolution: {integrity: sha512-uLJS6xgOCBw5EMsDW7Ukji8l28qRoMnkRch15s0qwZpskXvWt9oPzMmcYM307m9GN4MxuWLsQh4I6hU9yI53cQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^17.0.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@40.0.0: + resolution: {integrity: sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-scss@7.2.0: + resolution: {integrity: sha512-6E79Bachv0Iz0gqRUZgdqdXCsiq26DWBWIBNHYtjTmAp3wJu6cp/I37VfW7BPntmh2puF3bY09XWl4HZGrLhzw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^16.8.2 || ^17.0.0 + + stylelint@17.14.1: + resolution: {integrity: sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==} + engines: {node: '>=20.19.0'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-hyperlinks@4.5.0: + resolution: {integrity: sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==} + engines: {node: '>=20'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + 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 + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@biomejs/biome@2.5.7': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.7 + '@biomejs/cli-darwin-x64': 2.5.7 + '@biomejs/cli-linux-arm64': 2.5.7 + '@biomejs/cli-linux-arm64-musl': 2.5.7 + '@biomejs/cli-linux-x64': 2.5.7 + '@biomejs/cli-linux-x64-musl': 2.5.7 + '@biomejs/cli-win32-arm64': 2.5.7 + '@biomejs/cli-win32-x64': 2.5.7 + + '@biomejs/cli-darwin-arm64@2.5.7': + optional: true + + '@biomejs/cli-darwin-x64@2.5.7': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.7': + optional: true + + '@biomejs/cli-linux-arm64@2.5.7': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.7': + optional: true + + '@biomejs/cli-linux-x64@2.5.7': + optional: true + + '@biomejs/cli-win32-arm64@2.5.7': + optional: true + + '@biomejs/cli-win32-x64@2.5.7': + optional: true + + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.1(postcss-selector-parser@7.1.5)': + dependencies: + postcss-selector-parser: 7.1.5 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.5)': + dependencies: + postcss-selector-parser: 7.1.5 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/react@0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@floating-ui/utils': 0.2.12 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tabbable: 6.5.0 + + '@floating-ui/utils@0.2.12': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@kurkle/color@0.3.4': {} + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-parser/binding-android-arm-eabi@0.120.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.120.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.120.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.120.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.120.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.120.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.120.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.120.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.120.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.120.0': + optional: true + + '@oxc-project/types@0.120.0': {} + + '@oxc-project/types@0.144.0': {} + + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + optional: true + + '@rolldown/binding-android-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-x64@1.2.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.4': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@stablelib/base64@2.0.1': {} + + '@stablelib/binary@2.0.1': + dependencies: + '@stablelib/int': 2.0.1 + + '@stablelib/bytes@2.0.1': {} + + '@stablelib/int@2.0.1': {} + + '@stablelib/keyagreement@2.0.1': + dependencies: + '@stablelib/bytes': 2.0.1 + + '@stablelib/random@2.0.1': + dependencies: + '@stablelib/binary': 2.0.1 + '@stablelib/wipe': 2.0.1 + + '@stablelib/wipe@2.0.1': {} + + '@stablelib/x25519@2.0.1': + dependencies: + '@stablelib/keyagreement': 2.0.1 + '@stablelib/random': 2.0.1 + '@stablelib/wipe': 2.0.1 + + '@tanstack/devtools-bundler-core@0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@tanstack/devtools-client': 0.0.8 + '@tanstack/devtools-event-bus': 0.4.2 + chalk: 5.6.2 + launch-editor: 2.14.1 + magic-string: 0.30.21 + oxc-parser: 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + picomatch: 4.0.5 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - bufferutil + - utf-8-validate + + '@tanstack/devtools-client@0.0.8': + dependencies: + '@tanstack/devtools-event-client': 0.5.0 + + '@tanstack/devtools-event-bus@0.4.2': + dependencies: + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tanstack/devtools-event-client@0.4.4': {} + + '@tanstack/devtools-event-client@0.5.0': {} + + '@tanstack/devtools-vite@0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0))': + dependencies: + '@tanstack/devtools-bundler-core': 0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@tanstack/devtools-client': 0.0.8 + '@tanstack/devtools-event-bus': 0.4.2 + chalk: 5.6.2 + vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - bufferutil + - utf-8-validate + + '@tanstack/form-core@1.33.5': + dependencies: + '@tanstack/devtools-event-client': 0.4.4 + '@tanstack/pacer-lite': 0.1.1 + '@tanstack/store': 0.11.1 + + '@tanstack/history@1.162.1': {} + + '@tanstack/pacer-lite@0.1.1': {} + + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-form@1.33.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/form-core': 1.33.5 + '@tanstack/react-store': 0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + transitivePeerDependencies: + - react-dom + + '@tanstack/react-query@5.101.4(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.8 + + '@tanstack/react-router@1.170.29(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/history': 1.162.1 + '@tanstack/react-store': 0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.24 + isbot: 5.2.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@tanstack/react-store@0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/store': 0.11.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + '@tanstack/react-store@0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + '@tanstack/router-core@1.171.24': + dependencies: + '@tanstack/history': 1.162.1 + cookie-es: 3.1.1 + seroval: 1.6.2 + seroval-plugins: 1.6.2(seroval@1.6.2) + + '@tanstack/router-generator@1.167.30(supports-color@10.2.2)': + dependencies: + '@babel/types': 7.29.8 + '@tanstack/router-core': 1.171.24 + '@tanstack/router-utils': 1.162.2(supports-color@10.2.2) + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 + magic-string: 0.30.21 + prettier: 3.9.6 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.168.32(@tanstack/react-router@1.170.29(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.2.4)(supports-color@10.2.2)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + '@tanstack/router-core': 1.171.24 + '@tanstack/router-generator': 1.167.30(supports-color@10.2.2) + '@tanstack/router-utils': 1.162.2(supports-color@10.2.2) + chokidar: 5.0.0 + unplugin: 3.3.0(rolldown@1.2.4)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0)) + zod: 4.4.3 + optionalDependencies: + '@tanstack/react-router': 1.170.29(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - supports-color + - unloader + + '@tanstack/router-utils@1.162.2(supports-color@10.2.2)': + dependencies: + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + ansis: 4.3.1 + babel-dead-code-elimination: 1.0.12(supports-color@10.2.2) + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.17 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.11.1': {} + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-file-routes@1.162.0': {} + + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/plugin-clipboard-manager@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-dialog@2.7.2': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-fs@2.5.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-http@2.5.9': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-log@2.9.0': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-os@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/byte-size@8.1.2': {} + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@uidotdev/usehooks@2.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@ungap/structured-clone@1.3.3': {} + + '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0) + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansis@4.3.1: {} + + argparse@2.0.1: {} + + astral-regex@2.0.0: {} + + autoprefixer@10.5.4(postcss@8.5.26): + dependencies: + browserslist: 4.28.8 + caniuse-lite: 1.0.30001809 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-value-parser: 4.2.0 + + babel-dead-code-elimination@1.0.12(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/parser': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + bail@2.0.2: {} + + baseline-browser-mapping@2.11.14: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.14 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.408 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + byte-size@9.0.1: {} + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001809: {} + + ccount@2.0.1: {} + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + comma-separated-tokens@2.0.3: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + cosmiconfig@9.0.2(typescript@6.0.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.3 + + css-functions-list@3.3.3: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + dayjs@1.11.23: {} + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.4: {} + + electron-to-chromium@1.5.408: {} + + emoji-regex@8.0.0: {} + + entities@6.0.1: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + escalade@3.2.0: {} + + escape-string-regexp@5.0.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.5: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + + fraction.js@5.3.4: {} + + framer-motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 12.43.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-east-asian-width@1.6.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globals@17.11.0: {} + + globby@16.2.3: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.6 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + globjoin@0.1.4: {} + + has-flag@5.0.1: {} + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.3 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.3.3 + unist-util-position: 5.0.0 + + hast-util-to-jsx-runtime@2.3.6(supports-color@10.2.2): + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + hookified@1.15.1: {} + + hookified@2.2.0: {} + + html-tags@5.1.0: {} + + html-url-attributes@3.0.1: {} + + html-void-elements@3.0.0: {} + + ignore@7.0.6: {} + + immutable@5.1.9: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + ini@1.3.8: {} + + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arrayish@0.2.1: {} + + is-decimal@2.0.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-number@7.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + isbot@5.2.1: {} + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + launch-editor@2.14.1: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.10.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + 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 + + lines-and-columns@1.2.4: {} + + lodash.truncate@4.4.2: {} + + longest-streak@3.1.0: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-table@3.0.4: {} + + mathml-tag-names@4.0.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2(supports-color@10.2.2) + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0(supports-color@10.2.2): + dependencies: + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0(supports-color@10.2.2) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-table: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1(supports-color@10.2.2): + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0(supports-color@10.2.2): + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1(supports-color@10.2.2): + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.27.1: {} + + meow@14.1.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2(supports-color@10.2.2): + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@10.2.2) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + motion-dom@12.43.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.53: {} + + normalize-path@3.0.0: {} + + oxc-parser@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + dependencies: + '@oxc-project/types': 0.120.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.120.0 + '@oxc-parser/binding-android-arm64': 0.120.0 + '@oxc-parser/binding-darwin-arm64': 0.120.0 + '@oxc-parser/binding-darwin-x64': 0.120.0 + '@oxc-parser/binding-freebsd-x64': 0.120.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.120.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.120.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.120.0 + '@oxc-parser/binding-linux-arm64-musl': 0.120.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.120.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.120.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.120.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.120.0 + '@oxc-parser/binding-linux-x64-gnu': 0.120.0 + '@oxc-parser/binding-linux-x64-musl': 0.120.0 + '@oxc-parser/binding-openharmony-arm64': 0.120.0 + '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 + '@oxc-parser/binding-win32-x64-msvc': 0.120.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + p-timeout@7.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + + postcss-scss@4.0.9(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + + postcss-selector-parser@7.1.5: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + property-information@7.2.0: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + qrcode.react@4.2.0(react@19.2.8): + dependencies: + react: 19.2.8 + + queue-microtask@1.2.3: {} + + radashi@12.9.1: {} + + react-chartjs-2@5.3.1(chart.js@4.5.1)(react@19.2.8): + dependencies: + chart.js: 4.5.1 + react: 19.2.8 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-loading-skeleton@3.5.0(react@19.2.8): + dependencies: + react: 19.2.8 + + react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8)(supports-color@10.2.2): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 19.2.18 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6(supports-color@10.2.2) + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.8 + remark-parse: 11.0.0(supports-color@10.2.2) + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react@19.2.8: {} + + readdirp@5.1.1: {} + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-sanitize: 5.0.2 + + remark-gfm@4.0.1(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0(supports-color@10.2.2) + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0(supports-color@10.2.2) + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + reusify@1.1.0: {} + + rolldown@1.2.4: + dependencies: + '@oxc-project/types': 0.144.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.4 + '@rolldown/binding-darwin-arm64': 1.2.4 + '@rolldown/binding-darwin-x64': 1.2.4 + '@rolldown/binding-freebsd-x64': 1.2.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 + '@rolldown/binding-linux-arm64-gnu': 1.2.4 + '@rolldown/binding-linux-arm64-musl': 1.2.4 + '@rolldown/binding-linux-ppc64-gnu': 1.2.4 + '@rolldown/binding-linux-s390x-gnu': 1.2.4 + '@rolldown/binding-linux-x64-gnu': 1.2.4 + '@rolldown/binding-linux-x64-musl': 1.2.4 + '@rolldown/binding-openharmony-arm64': 1.2.4 + '@rolldown/binding-win32-arm64-msvc': 1.2.4 + '@rolldown/binding-win32-x64-msvc': 1.2.4 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + sass@1.102.0: + dependencies: + chokidar: 5.0.0 + immutable: 5.1.9 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.6.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + seroval-plugins@1.6.2(seroval@1.6.2): + dependencies: + seroval: 1.6.2 + + seroval@1.6.2: {} + + shell-quote@1.10.0: {} + + signal-exit@4.1.0: {} + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + stylelint-config-recommended-scss@17.0.1(postcss@8.5.26)(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.26) + stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)) + stylelint-scss: 7.2.0(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)) + optionalDependencies: + postcss: 8.5.26 + + stylelint-config-recommended@18.0.0(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)): + dependencies: + stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) + + stylelint-config-standard-scss@17.0.0(postcss@8.5.26)(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)): + dependencies: + stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) + stylelint-config-recommended-scss: 17.0.1(postcss@8.5.26)(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)) + stylelint-config-standard: 40.0.0(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)) + optionalDependencies: + postcss: 8.5.26 + + stylelint-config-standard@40.0.0(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)): + dependencies: + stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)) + + stylelint-scss@7.2.0(stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3)): + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + css-tree: 3.2.1 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.5 + postcss-value-parser: 4.2.0 + stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) + + stylelint@17.14.1(supports-color@10.2.2)(typescript@6.0.3): + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.1(postcss-selector-parser@7.1.5) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.5) + colord: 2.9.3 + cosmiconfig: 9.0.2(typescript@6.0.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 + debug: 4.4.3(supports-color@10.2.2) + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.5 + global-modules: 2.0.0 + globby: 16.2.3 + globjoin: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.6 + import-meta-resolve: 4.2.0 + mathml-tag-names: 4.0.0 + meow: 14.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-safe-parser: 7.0.1(postcss@8.5.26) + postcss-selector-parser: 7.1.5 + postcss-value-parser: 4.2.0 + string-width: 8.2.2 + supports-hyperlinks: 4.5.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 7.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@10.2.2: {} + + supports-hyperlinks@4.5.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + svg-tags@1.0.0: {} + + tabbable@6.5.0: {} + + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: {} + + typescript@6.0.3: {} + + undici-types@8.3.0: {} + + unicorn-magic@0.4.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unplugin@3.3.0(rolldown@1.2.4)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.2.4 + vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0) + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + util-deprecate@1.0.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(sass@1.102.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.2.0 + fsevents: 2.3.3 + jiti: 2.7.0 + sass: 1.102.0 + + web-namespaces@2.0.1: {} + + webpack-virtual-modules@0.6.2: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + ws@8.21.3: {} + + yallist@3.1.1: {} + + zod@4.4.3: {} + + zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + optionalDependencies: + '@types/react': 19.2.18 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + + zwitch@2.0.4: {} diff --git a/new-ui/pnpm-workspace.yaml b/new-ui/pnpm-workspace.yaml new file mode 100644 index 000000000..bf441ee6d --- /dev/null +++ b/new-ui/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +allowBuilds: + '@parcel/watcher': true +minimumReleaseAgeExclude: + - '@napi-rs/wasm-runtime@1.1.5' + - '@types/react@19.2.16' + - react-dom@19.2.7 + - react@19.2.7 + - vite@8.0.16 + - fast-uri@3.1.4 || 3.1.5 + - js-yaml@4.3.1 diff --git a/new-ui/public/favicon.svg b/new-ui/public/favicon.svg new file mode 100644 index 000000000..6893eb132 --- /dev/null +++ b/new-ui/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-ui/public/fonts/geist/Geist-Bold.woff2 b/new-ui/public/fonts/geist/Geist-Bold.woff2 new file mode 100644 index 000000000..46f524f4f Binary files /dev/null and b/new-ui/public/fonts/geist/Geist-Bold.woff2 differ diff --git a/new-ui/public/fonts/geist/Geist-BoldItalic.woff2 b/new-ui/public/fonts/geist/Geist-BoldItalic.woff2 new file mode 100644 index 000000000..240acb99e Binary files /dev/null and b/new-ui/public/fonts/geist/Geist-BoldItalic.woff2 differ diff --git a/new-ui/public/fonts/geist/Geist-Medium.woff2 b/new-ui/public/fonts/geist/Geist-Medium.woff2 new file mode 100644 index 000000000..ef6dbb211 Binary files /dev/null and b/new-ui/public/fonts/geist/Geist-Medium.woff2 differ diff --git a/new-ui/public/fonts/geist/Geist-MediumItalic.woff2 b/new-ui/public/fonts/geist/Geist-MediumItalic.woff2 new file mode 100644 index 000000000..344fedaf9 Binary files /dev/null and b/new-ui/public/fonts/geist/Geist-MediumItalic.woff2 differ diff --git a/new-ui/public/fonts/geist/Geist-Regular.woff2 b/new-ui/public/fonts/geist/Geist-Regular.woff2 new file mode 100644 index 000000000..0db0f1943 Binary files /dev/null and b/new-ui/public/fonts/geist/Geist-Regular.woff2 differ diff --git a/new-ui/public/fonts/geist/Geist-RegularItalic.woff2 b/new-ui/public/fonts/geist/Geist-RegularItalic.woff2 new file mode 100644 index 000000000..33e9948be Binary files /dev/null and b/new-ui/public/fonts/geist/Geist-RegularItalic.woff2 differ diff --git a/new-ui/public/fonts/geist/Geist-SemiBold.woff2 b/new-ui/public/fonts/geist/Geist-SemiBold.woff2 new file mode 100644 index 000000000..838452303 Binary files /dev/null and b/new-ui/public/fonts/geist/Geist-SemiBold.woff2 differ diff --git a/new-ui/public/fonts/geist/Geist-SemiBoldItalic.woff2 b/new-ui/public/fonts/geist/Geist-SemiBoldItalic.woff2 new file mode 100644 index 000000000..2f53ced42 Binary files /dev/null and b/new-ui/public/fonts/geist/Geist-SemiBoldItalic.woff2 differ diff --git a/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-Italic.woff2 b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-Italic.woff2 new file mode 100644 index 000000000..d60c270e8 Binary files /dev/null and b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-Italic.woff2 differ diff --git a/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-Medium.woff2 b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-Medium.woff2 new file mode 100644 index 000000000..669d04cdf Binary files /dev/null and b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-Medium.woff2 differ diff --git a/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-MediumItalic.woff2 b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-MediumItalic.woff2 new file mode 100644 index 000000000..80cfd15e0 Binary files /dev/null and b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-MediumItalic.woff2 differ diff --git a/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-Regular.woff2 b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-Regular.woff2 new file mode 100644 index 000000000..40da42765 Binary files /dev/null and b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-Regular.woff2 differ diff --git a/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-SemiBold.woff2 b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-SemiBold.woff2 new file mode 100644 index 000000000..5ead7b0d6 Binary files /dev/null and b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-SemiBold.woff2 differ diff --git a/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-SemiBoldItalic.woff2 b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-SemiBoldItalic.woff2 new file mode 100644 index 000000000..c5dd294b4 Binary files /dev/null and b/new-ui/public/fonts/jetbrains_mono/JetBrainsMono-SemiBoldItalic.woff2 differ diff --git a/new-ui/public/fonts/source_code_pro/SourceCodePro-Regular.woff2 b/new-ui/public/fonts/source_code_pro/SourceCodePro-Regular.woff2 new file mode 100644 index 000000000..40826f1a6 Binary files /dev/null and b/new-ui/public/fonts/source_code_pro/SourceCodePro-Regular.woff2 differ diff --git a/new-ui/public/icons.svg b/new-ui/public/icons.svg new file mode 100644 index 000000000..e9522193d --- /dev/null +++ b/new-ui/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/new-ui/src/app/App.tsx b/new-ui/src/app/App.tsx new file mode 100644 index 000000000..ec5f23f48 --- /dev/null +++ b/new-ui/src/app/App.tsx @@ -0,0 +1,22 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider } from '@tanstack/react-router'; +import { MainBackground } from '../shared/components/MainBackground/MainBackground'; +import { WindowDecorations } from '../shared/components/WindowDecorations/WindowDecorations'; +import { queryClient } from './query'; +import { router } from './router'; + +function App() { + return ( +
+ + +
+ + + +
+
+ ); +} + +export default App; diff --git a/new-ui/src/app/day.ts b/new-ui/src/app/day.ts new file mode 100644 index 000000000..a4e7a945f --- /dev/null +++ b/new-ui/src/app/day.ts @@ -0,0 +1,12 @@ +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/en'; +import duration from 'dayjs/plugin/duration'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(duration); +dayjs.extend(relativeTime); +dayjs.extend(utc); +dayjs.extend(localizedFormat); +dayjs.locale('en'); diff --git a/new-ui/src/app/query.ts b/new-ui/src/app/query.ts new file mode 100644 index 000000000..98775f699 --- /dev/null +++ b/new-ui/src/app/query.ts @@ -0,0 +1,40 @@ +import { MutationCache, QueryClient, type QueryKey } from '@tanstack/react-query'; + +type InvalidateMeta = { invalidate?: QueryKey[] | QueryKey }; + +let queryClient: QueryClient; + +type RO = readonly unknown[]; + +const isArrayFlat = (arr: RO | readonly RO[]): boolean => + arr.every((item) => !Array.isArray(item)); + +const mutationCache = new MutationCache({ + onSuccess: async (_data, _variables, _context, mutation) => { + const keys = (mutation.meta as InvalidateMeta | undefined)?.invalidate; + if (!Array.isArray(keys) || keys.length === 0) return; + if (isArrayFlat(keys)) { + await queryClient.invalidateQueries({ queryKey: keys }); + } else { + await Promise.all( + keys.map((key) => queryClient.invalidateQueries({ queryKey: key as QueryKey })), + ); + } + }, +}); + +queryClient = new QueryClient({ + mutationCache, + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 10 * 60_000, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, + retry: false, + }, + }, +}); + +export { queryClient }; diff --git a/new-ui/src/app/router.ts b/new-ui/src/app/router.ts new file mode 100644 index 000000000..100bf812f --- /dev/null +++ b/new-ui/src/app/router.ts @@ -0,0 +1,20 @@ +import { createRouter } from '@tanstack/react-router'; +import { routeTree } from '../routeTree.gen'; +import { NotFoundRoute } from '../shared/components/NotFoundRoute/NotFoundRoute'; +import { queryClient } from './query'; + +export const router = createRouter({ + routeTree, + basepath: import.meta.env.BASE_URL, + defaultPreloadStaleTime: 0, + defaultNotFoundComponent: NotFoundRoute, + context: { + queryClient, + }, +}); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} diff --git a/new-ui/src/main.tsx b/new-ui/src/main.tsx new file mode 100644 index 000000000..8d5ec030d --- /dev/null +++ b/new-ui/src/main.tsx @@ -0,0 +1,12 @@ +import './app/day.ts'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './app/App.tsx'; +import './shared/scss/index.scss'; + +// biome-ignore lint/style/noNonNullAssertion: this element is static in index.html +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/new-ui/src/pages/compact/CompactEmptyPage/CompactEmptyPage.tsx b/new-ui/src/pages/compact/CompactEmptyPage/CompactEmptyPage.tsx new file mode 100644 index 000000000..914dd6e50 --- /dev/null +++ b/new-ui/src/pages/compact/CompactEmptyPage/CompactEmptyPage.tsx @@ -0,0 +1,29 @@ +import './style.scss'; +import { Button } from '../../../shared/components/Button/Button'; +import { ButtonSize, ButtonVariant } from '../../../shared/components/Button/types'; +import { Icon, IconKind } from '../../../shared/components/Icon'; +import { WindowHeader } from '../../../shared/components/WindowHeader/WindowHeader'; +import { api } from '../../../shared/rust-api/api'; +import { CompactPage } from '../CompactPage/CompactPage'; + +export const CompactEmptyPage = () => { + return ( + + +
+
+ +

{`You don't have any instances or tunnels yet. Click the button below to open Defguard.`}

+
+
+
+ ); +}; diff --git a/new-ui/src/pages/compact/CompactEmptyPage/style.scss b/new-ui/src/pages/compact/CompactEmptyPage/style.scss new file mode 100644 index 000000000..7fed3b63a --- /dev/null +++ b/new-ui/src/pages/compact/CompactEmptyPage/style.scss @@ -0,0 +1,24 @@ +#compact-empty-page { + .empty-card { + border-radius: 12px; + box-sizing: border-box; + padding: var(--spacing-lg); + background-color: var(--bg-dark-blue-40); + width: 100%; + min-height: 277px; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + + > .icon { + margin-bottom: var(--spacing-lg); + } + + > p { + font: var(--t-body-xs-400); + color: var(--bg-white-100); + padding-bottom: var(--spacing-xl); + } + } +} diff --git a/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx new file mode 100644 index 000000000..1a9e864b8 --- /dev/null +++ b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx @@ -0,0 +1,121 @@ +import './style.scss'; +import { useQuery } from '@tanstack/react-query'; +import { useLoaderData } from '@tanstack/react-router'; +import { useEffect, useMemo } from 'react'; +import { Button } from '../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../shared/components/Button/types'; +import { Controls } from '../../../shared/components/Controls/Controls'; +import { Divider } from '../../../shared/components/Divider/Divider'; +import { LocationCard } from '../../../shared/components/LocationCard/LocationCard'; +import { ScrollContainer } from '../../../shared/components/ScrollContainer/ScrollContainer'; +import { WindowHeader } from '../../../shared/components/WindowHeader/WindowHeader'; +import { useAppData } from '../../../shared/providers/AppDataContext'; +import { api } from '../../../shared/rust-api/api'; +import { + getInstancesQueryOptions, + getLocationsQueryOptions, + getTunnelsQueryOptions, +} from '../../../shared/rust-api/query'; +import { useAppStore } from '../../../shared/store/useAppStore'; +import { ThemeSpacing } from '../../../shared/types'; +import { isPresent } from '../../../shared/utils/isPresent'; +import { CompactPage } from '../CompactPage/CompactPage'; +import { InstanceSwitcher } from './components/InstanceSwitcher'; + +export const CompactLocationsPage = () => { + const { viewSelection: selection, setViewSelection } = useAppData(); + const openLocation = useAppStore((s) => s.expandedLocation); + + const routeData = useLoaderData({ from: '/compact/' }); + + const { data: instances } = useQuery(getInstancesQueryOptions); + const { data: tunnels } = useQuery(getTunnelsQueryOptions); + + const allInstances = instances ?? routeData.instances; + const allTunnels = tunnels ?? routeData.tunnels; + + const queryInstanceId = useMemo(() => { + if (!isPresent(selection)) return allInstances[0]?.id; + if (selection.kind === 'instance') return selection.id; + return ( + allTunnels.find((t) => t.id === selection.id)?.instance_id ?? allInstances[0]?.id + ); + }, [selection, allInstances, allTunnels]); + + const { data: locations } = useQuery(getLocationsQueryOptions(queryInstanceId)); + + const instanceInfo = useMemo(() => { + if (!isPresent(selection)) return allInstances[0]; + if (selection.kind === 'instance') + return allInstances.find((i) => i.id === selection.id); + const tunnel = allTunnels.find((t) => t.id === selection.id); + return tunnel ? allInstances.find((i) => i.id === tunnel.instance_id) : undefined; + }, [selection, allInstances, allTunnels]); + + const displayedLocations = useMemo(() => { + if (!isPresent(selection) || selection.kind === 'instance') { + return locations ?? routeData.locations; + } + const tunnel = allTunnels.find((t) => t.id === selection.id); + return tunnel ? [tunnel] : []; + }, [selection, locations, routeData.locations, allTunnels]); + + useEffect(() => { + if (selection?.kind === 'tunnel') return; + if (selection === null || instanceInfo === undefined) { + if (allInstances.length > 0) { + setViewSelection({ kind: 'instance', id: allInstances[0].id }); + } + } + }, [allInstances, instanceInfo, selection, setViewSelection]); + + return ( + + + +
+ +
+ {displayedLocations.map((location) => { + const isOpen = + location.id === openLocation || displayedLocations.length === 1; + return ( + { + if (isOpen) { + useAppStore.setState({ expandedLocation: null }); + } else { + useAppStore.setState({ expandedLocation: location.id }); + } + }} + /> + ); + })} +
+
+
+
+ + +
+
+ ); +}; diff --git a/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx b/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx new file mode 100644 index 000000000..c182bced3 --- /dev/null +++ b/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx @@ -0,0 +1,87 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { Select } from '../../../../shared/components/Select/Select'; +import type { + SelectOption, + SelectOptionGroup, +} from '../../../../shared/components/Select/types'; +import { useAppData } from '../../../../shared/providers/AppDataContext'; +import { + getInstancesQueryOptions, + getTunnelsQueryOptions, + tunnelsDisabled, +} from '../../../../shared/rust-api/query'; +import type { OverviewViewSelection } from '../../../../shared/rust-api/types'; +import { isPresent } from '../../../../shared/utils/isPresent'; + +export const InstanceSwitcher = () => { + const { viewSelection: selectedInstance, setViewSelection } = useAppData(); + + const { data: tunnels } = useQuery(getTunnelsQueryOptions); + const { data: instances } = useQuery(getInstancesQueryOptions); + + const groups = useMemo((): readonly SelectOptionGroup[] => { + if (!isPresent(instances) || !isPresent(tunnels)) return []; + + const instanceGroup: SelectOptionGroup = { + key: 'instances', + label: 'Instances', + options: instances.map((instance) => ({ + // Instances and tunnels have separate id spaces, so prefix the key with + // the kind to keep it unique across groups (the Select marks the checkmark + // by comparing option keys). + key: `instance-${instance.id}`, + label: instance.name, + value: { kind: 'instance', id: instance.id }, + })), + }; + + const tunnelGroup: SelectOptionGroup | undefined = + !tunnelsDisabled(instances ?? []) && tunnels.length > 0 + ? { + key: 'tunnels', + label: 'Tunnels', + options: tunnels.map((tunnel) => ({ + key: `tunnel-${tunnel.id ?? tunnel.name}`, + label: tunnel.name, + value: { kind: 'tunnel', id: tunnel.id }, + })), + } + : undefined; + + const result: SelectOptionGroup[] = [instanceGroup]; + if (tunnelGroup) result.push(tunnelGroup); + return result; + }, [instances, tunnels]); + + const totalOptions = useMemo( + () => groups.reduce((acc, g) => acc + g.options.length, 0), + [groups], + ); + + const selectedOption = useMemo((): SelectOption | undefined => { + if (!isPresent(selectedInstance)) return undefined; + for (const group of groups) { + const found = group.options.find((o) => { + return ( + o.value.kind === selectedInstance.kind && o.value.id === selectedInstance.id + ); + }); + if (found) return found; + } + return undefined; + }, [selectedInstance, groups]); + + if (!isPresent(instances) || !isPresent(tunnels)) return null; + if (totalOptions <= 1) return null; + + return ( + { + setLevel(option); + logLevelRef.current = option.value; + restartLogWatcher(); + }} + /> + patchConfig({ log_level: option.value })} + /> + + + + { + if (value === null || value === '') return; + patchConfig({ peer_alive_period: Number(value) }); + }} + /> + + + + { + if (value === null || value === '') return; + patchConfig({ mtu: Number(value) }); + }} + /> + + + + + patchConfig({ check_for_updates: !appConfig.check_for_updates }) + } + /> + + + + + patchConfig({ auto_start_openid_mfa: !appConfig.auto_start_openid_mfa }) + } + /> + + +

+ Defguard is made possible by other open-source software.{' '} + +

+ + + ); +}; + +type SettingRowProps = { + title: string; + description?: string; + children: ReactNode; + // Render the title and control on a single line instead of stacked. + inline?: boolean; + divider?: boolean; +}; + +const SettingRow = ({ + title, + description, + children, + inline = false, + divider = true, +}: SettingRowProps) => ( +
+
+

{title}

+ {inline &&
{children}
} +
+ {isPresent(description) && ( + <> + +

{description}

+ + )} + {!inline && ( + <> + +
{children}
+ + )} + {divider && } +
+); diff --git a/new-ui/src/pages/full/SettingsPage/style.scss b/new-ui/src/pages/full/SettingsPage/style.scss new file mode 100644 index 000000000..5a54a9017 --- /dev/null +++ b/new-ui/src/pages/full/SettingsPage/style.scss @@ -0,0 +1,50 @@ +#settings-page-view { + .sections { + display: flex; + flex-direction: column; + + .setting-row { + .head { + display: flex; + align-items: center; + justify-content: space-between; + + .title { + font: var(--t-body-sm-500); + color: var(--fg-white-100); + } + } + + .description { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + } + + .control { + max-width: 321px; + } + } + + .footer { + display: flex; + align-items: center; + gap: var(--spacing-xs); + font: var(--t-body-xs-400); + color: var(--fg-white-60); + + .link { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + font: inherit; + color: var(--fg-white-100); + cursor: pointer; + background: transparent; + border: none; + padding: 0; + + --icon-color: var(--fg-white-100); + } + } + } +} diff --git a/new-ui/src/pages/full/SupportPage/SupportPage.tsx b/new-ui/src/pages/full/SupportPage/SupportPage.tsx new file mode 100644 index 000000000..f2bf520b3 --- /dev/null +++ b/new-ui/src/pages/full/SupportPage/SupportPage.tsx @@ -0,0 +1,101 @@ +import './style.scss'; +import { openUrl } from '@tauri-apps/plugin-opener'; +import type { ReactNode } from 'react'; +import { Button } from '../../../shared/components/Button/Button'; +import type { ButtonProps } from '../../../shared/components/Button/types'; +import { ButtonVariant } from '../../../shared/components/Button/types'; +import { Divider } from '../../../shared/components/Divider/Divider'; +import { FullPageTitle } from '../../../shared/components/FullPageTitle/FullPageTitle'; +import { Icon, IconKind } from '../../../shared/components/Icon'; +import type { IconKindValue } from '../../../shared/components/Icon/icon-types'; +import { SizedBox } from '../../../shared/components/SizedBox/SizedBox'; +import { FullPage } from '../../../shared/layouts/FullPage/FullPage'; +import { ThemeSpacing } from '../../../shared/types'; + +type SupportSectionProps = { + icon: IconKindValue; + title: string; + description: ReactNode; + action?: ButtonProps; + divider?: boolean; +}; + +const SupportSection = ({ + icon, + title, + description, + action, + divider = true, +}: SupportSectionProps) => ( +
+
+ +
+
+

{title}

+ +

{description}

+ {action && ( + <> + +
+
+); + +export const SupportPage = () => { + return ( + + +
+ openUrl('https://docs.defguard.net/'), + }} + /> + + openUrl( + 'https://github.com/DefGuard/client/issues/new?template=02-bug.yml', + ), + }} + /> + + openUrl( + 'https://github.com/DefGuard/client/issues/new?template=01-feature-request.yml', + ), + }} + /> + +
+
+ ); +}; diff --git a/new-ui/src/pages/full/SupportPage/style.scss b/new-ui/src/pages/full/SupportPage/style.scss new file mode 100644 index 000000000..990a35429 --- /dev/null +++ b/new-ui/src/pages/full/SupportPage/style.scss @@ -0,0 +1,25 @@ +#support-page-view { + .sections { + .section { + display: flex; + gap: var(--spacing-lg); + + .icon { + --icon-color: var(--fg-white-60); + } + + .content { + flex: 1; + } + + .title { + font: var(--t-body-sm-500); + } + + .description { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + } + } + } +} diff --git a/new-ui/src/pages/full/TunnelWizardPage/TunnelWizardPage.tsx b/new-ui/src/pages/full/TunnelWizardPage/TunnelWizardPage.tsx new file mode 100644 index 000000000..a3f2cc278 --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/TunnelWizardPage.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from 'react'; +import type { WizardPageStep } from '../../../shared/components/wizard/types'; +import { WizardPage } from '../../../shared/components/wizard/WizardPage/WizardPage'; +import { useTunnelWizardStore } from './hooks/useTunnelWizardStore'; +import { AdvancedSettingsStep } from './steps/AdvancedSettingsStep/AdvancedSettingsStep'; +import { FinishStep } from './steps/FinishStep/FinishStep'; +import { GeneralInformationStep } from './steps/GeneralInformationStep/GeneralInformationStep'; +import { KeysStep } from './steps/KeysStep/KeysStep'; +import { VpnServerStep } from './steps/VpnServerStep/VpnServerStep'; +import { TunnelWizardStep, type TunnelWizardStepValue } from './types'; + +const stepComponents: Record = { + [TunnelWizardStep.GeneralInformation]: , + [TunnelWizardStep.Keys]: , + [TunnelWizardStep.VpnServer]: , + [TunnelWizardStep.AdvancedSettings]: , + [TunnelWizardStep.Finish]: , +}; + +export const TunnelWizardPage = () => { + const { activeStep } = useTunnelWizardStore(); + + const steps: Record = { + [TunnelWizardStep.GeneralInformation]: { + id: TunnelWizardStep.GeneralInformation, + order: 1, + label: 'General Information', + }, + [TunnelWizardStep.Keys]: { + id: TunnelWizardStep.Keys, + order: 2, + label: 'Keys', + }, + [TunnelWizardStep.VpnServer]: { + id: TunnelWizardStep.VpnServer, + order: 3, + label: 'VPN Server', + }, + [TunnelWizardStep.AdvancedSettings]: { + id: TunnelWizardStep.AdvancedSettings, + order: 4, + label: 'Advanced Settings', + }, + [TunnelWizardStep.Finish]: { + id: TunnelWizardStep.Finish, + order: 5, + label: 'Finish', + }, + }; + + return ( + + {stepComponents[activeStep]} + + ); +}; diff --git a/new-ui/src/pages/full/TunnelWizardPage/hooks/useTunnelWizardStore.tsx b/new-ui/src/pages/full/TunnelWizardPage/hooks/useTunnelWizardStore.tsx new file mode 100644 index 000000000..2567a0ac8 --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/hooks/useTunnelWizardStore.tsx @@ -0,0 +1,97 @@ +import { create } from 'zustand'; +import { TunnelWizardStep, type TunnelWizardStepValue } from '../types'; + +type StoreValues = { + activeStep: TunnelWizardStepValue; + tunnelData: { + name: string; + pubkey: string; + prvkey: string; + address: string; + server_pubkey: string; + preshared_key: string; + allowed_ips?: string; + endpoint: string; + dns?: string; + persistent_keep_alive: number; + route_all_traffic: boolean; + pre_up?: string; + post_up?: string; + pre_down?: string; + post_down?: string; + }; +}; + +const defaults: StoreValues = { + activeStep: TunnelWizardStep.GeneralInformation, + tunnelData: { + name: '', + address: '', + endpoint: '', + persistent_keep_alive: 25, + preshared_key: '', + prvkey: '', + pubkey: '', + route_all_traffic: false, + server_pubkey: '', + allowed_ips: '', + dns: '', + post_down: '', + post_up: '', + pre_down: '', + pre_up: '', + }, +}; + +const nextStep = (step: TunnelWizardStepValue): TunnelWizardStepValue => { + switch (step) { + case TunnelWizardStep.GeneralInformation: + return TunnelWizardStep.Keys; + case TunnelWizardStep.Keys: + return TunnelWizardStep.VpnServer; + case TunnelWizardStep.VpnServer: + return TunnelWizardStep.AdvancedSettings; + case TunnelWizardStep.AdvancedSettings: + return TunnelWizardStep.Finish; + default: + return step; + } +}; + +const prevStep = (step: TunnelWizardStepValue): TunnelWizardStepValue => { + switch (step) { + case TunnelWizardStep.Keys: + return TunnelWizardStep.GeneralInformation; + case TunnelWizardStep.VpnServer: + return TunnelWizardStep.Keys; + case TunnelWizardStep.AdvancedSettings: + return TunnelWizardStep.VpnServer; + case TunnelWizardStep.Finish: + return TunnelWizardStep.AdvancedSettings; + default: + return step; + } +}; + +interface Store extends StoreValues { + next: (values?: Partial) => void; + back: (values?: Partial) => void; + reset: () => void; +} + +export const useTunnelWizardStore = create()((set, get) => ({ + ...defaults, + next: (tunnelData) => { + set({ + activeStep: nextStep(get().activeStep), + tunnelData: { ...get().tunnelData, ...tunnelData }, + }); + }, + back: (tunnelData) => { + set({ + activeStep: prevStep(get().activeStep), + tunnelData: { ...get().tunnelData, ...tunnelData }, + }); + }, + reset: () => set(defaults), +})); diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/AdvancedSettingsStep/AdvancedSettingsStep.tsx b/new-ui/src/pages/full/TunnelWizardPage/steps/AdvancedSettingsStep/AdvancedSettingsStep.tsx new file mode 100644 index 000000000..107ca9344 --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/AdvancedSettingsStep/AdvancedSettingsStep.tsx @@ -0,0 +1,112 @@ +import { useMutation } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import z from 'zod'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { Controls } from '../../../../../shared/components/Controls/Controls'; +import { Divider } from '../../../../../shared/components/Divider/Divider'; +import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox'; +import { Split } from '../../../../../shared/components/Split/Split'; +import { useAppForm } from '../../../../../shared/form'; +import { formChangeLogic } from '../../../../../shared/formLogic'; +import { api } from '../../../../../shared/rust-api/api'; +import { ThemeSpacing } from '../../../../../shared/types'; +import { useTunnelWizardStore } from '../../hooks/useTunnelWizardStore'; + +const formSchema = z.object({ + pre_up: z.string(), + post_up: z.string(), + pre_down: z.string(), + post_down: z.string(), +}); + +type FormFields = z.infer; + +export const AdvancedSettingsStep = () => { + const initData = useTunnelWizardStore((s) => s.tunnelData); + + const { mutateAsync } = useMutation({ mutationFn: api.saveTunnel }); + + const defaultValues = useMemo( + (): FormFields => ({ + pre_up: initData.pre_up ?? '', + post_up: initData.post_up ?? '', + pre_down: initData.pre_down ?? '', + post_down: initData.post_down ?? '', + }), + [initData.pre_up, initData.post_up, initData.pre_down, initData.post_down], + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: async ({ value }) => { + const storeValues = useTunnelWizardStore.getState().tunnelData; + const toSend = { ...storeValues, ...value }; + await mutateAsync(toSend); + useTunnelWizardStore.getState().next(); + }, + }); + + return ( +
+
+

Advanced settings (optional)

+ +

+ Define optional shell commands to run before or after the tunnel interface is + brought up or down. Useful for custom routing rules, firewall adjustments, or + other network configuration. +

+
+ +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + + {(field) => } + + + {(field) => } + + + + + + {(field) => } + + + {(field) => } + + + +
+ +
+ + + ); +}; diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/FinishStep/FinishStep.tsx b/new-ui/src/pages/full/TunnelWizardPage/steps/FinishStep/FinishStep.tsx new file mode 100644 index 000000000..ef3a656eb --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/FinishStep/FinishStep.tsx @@ -0,0 +1,40 @@ +import './style.scss'; + +import { useNavigate } from '@tanstack/react-router'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { useTunnelWizardStore } from '../../hooks/useTunnelWizardStore'; +import bannerSrc from './assets/banner.png'; + +export const FinishStep = () => { + const navigate = useNavigate(); + + return ( +
+
+ +
+

Your WireGuard tunnel added successfully

+

You can now connect this device, check its status and view statistics.

+
+
+
+ ); +}; diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/FinishStep/assets/banner.png b/new-ui/src/pages/full/TunnelWizardPage/steps/FinishStep/assets/banner.png new file mode 100644 index 000000000..001974a74 Binary files /dev/null and b/new-ui/src/pages/full/TunnelWizardPage/steps/FinishStep/assets/banner.png differ diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/FinishStep/style.scss b/new-ui/src/pages/full/TunnelWizardPage/steps/FinishStep/style.scss new file mode 100644 index 000000000..acc13e86c --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/FinishStep/style.scss @@ -0,0 +1,28 @@ +#finish-step { + .banner { + box-sizing: border-box; + padding-bottom: var(--spacing-3xl); + } + + h1 { + font: var(--t-h4); + color: var(--fg-white-100); + padding-bottom: var(--spacing-sm); + user-select: none; + } + + > p { + user-select: none; + font: var(--t-small-400); + color: var(--fg-white-70); + padding-bottom: var(--spacing-lg); + } + + > .actions { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-md); + } +} diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/GeneralInformationStep.tsx b/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/GeneralInformationStep.tsx new file mode 100644 index 000000000..8c6954657 --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/GeneralInformationStep.tsx @@ -0,0 +1,147 @@ +import './style.scss'; +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { open } from '@tauri-apps/plugin-dialog'; +import { readFile } from '@tauri-apps/plugin-fs'; +import { useMemo } from 'react'; +import z from 'zod'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { ButtonMenu } from '../../../../../shared/components/ButtonMenu/MenuButton'; +import { Controls } from '../../../../../shared/components/Controls/Controls'; +import { Divider } from '../../../../../shared/components/Divider/Divider'; +import { IconKind } from '../../../../../shared/components/Icon'; +import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox'; +import { useAppForm } from '../../../../../shared/form'; +import { formChangeLogic } from '../../../../../shared/formLogic'; +import { Snackbar } from '../../../../../shared/providers/snackbar/snackbar'; +import { api } from '../../../../../shared/rust-api/api'; +import { ThemeSpacing } from '../../../../../shared/types'; +import { interfaceAddressesSchema } from '../../../../../shared/utils/zod'; +import { useTunnelWizardStore } from '../../hooks/useTunnelWizardStore'; + +const formSchema = z.object({ + name: z.string().trim().min(1, 'Field is required'), + address: interfaceAddressesSchema, +}); + +type FormFields = z.infer; + +export const GeneralInformationStep = () => { + const navigate = useNavigate(); + const initData = useTunnelWizardStore((s) => s.tunnelData); + + const { mutate: importTunnelFile, isPending } = useMutation({ + mutationFn: async () => { + const filePath = await open({ + multiple: false, + directory: false, + filters: [{ name: 'wg-conf', extensions: ['conf', 'txt', 'config'] }], + }); + if (filePath) { + const decoder = new TextDecoder(); + const fileContents = await readFile(filePath); + const fileString = decoder.decode(fileContents); + const config = await api.parseTunnelConfig({ + filename: filePath, + config: fileString, + }); + const current = useTunnelWizardStore.getState().tunnelData; + useTunnelWizardStore.setState({ tunnelData: { ...current, ...config } }); + if (config.name) { + form.setFieldValue('name', config.name); + } + if (config.address) { + form.setFieldValue('address', config.address); + } + Snackbar.default('Config file applied'); + } + }, + }); + + const defaultValues = useMemo( + (): FormFields => ({ + address: initData.address, + name: initData.name, + }), + [initData.address, initData.name], + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useTunnelWizardStore.getState().next(value); + }, + }); + + return ( +
+
+

General information

+ +

{`Upload your config file (optional) and we'll securely extract the connection settings for you. This is the fastest and recommended way to get started.`}

+
+ { + importTunnelFile(); + }, + }, + ], + }, + ]} + /> +
+
+ +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => } + + + + {(field) => } + + +
+ +
+ + + ); +}; diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/style.scss b/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/style.scss new file mode 100644 index 000000000..d4a26a02b --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/GeneralInformationStep/style.scss @@ -0,0 +1,7 @@ +#general-info-step { + header { + .actions { + padding-top: var(--spacing-xl); + } + } +} diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/KeysStep/KeysStep.tsx b/new-ui/src/pages/full/TunnelWizardPage/steps/KeysStep/KeysStep.tsx new file mode 100644 index 000000000..db6119b45 --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/KeysStep/KeysStep.tsx @@ -0,0 +1,117 @@ +import './style.scss'; +import { useMemo } from 'react'; +import z from 'zod'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { ButtonMenu } from '../../../../../shared/components/ButtonMenu/MenuButton'; +import { Controls } from '../../../../../shared/components/Controls/Controls'; +import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox'; +import { useAppForm } from '../../../../../shared/form'; +import { formChangeLogic } from '../../../../../shared/formLogic'; +import { Snackbar } from '../../../../../shared/providers/snackbar/snackbar'; +import { ThemeSpacing } from '../../../../../shared/types'; +import { generateWGKeys } from '../../../../../shared/utils/generateWGKeys'; +import { patternValidWireguardKey } from '../../../../../shared/utils/patterns'; +import { useTunnelWizardStore } from '../../hooks/useTunnelWizardStore'; + +const formSchema = z.object({ + prvkey: z + .string() + .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), + pubkey: z + .string() + .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), +}); + +type FormFields = z.infer; + +export const KeysStep = () => { + const initData = useTunnelWizardStore((s) => s.tunnelData); + + const defaultValues = useMemo( + (): FormFields => ({ + prvkey: initData.prvkey, + pubkey: initData.pubkey, + }), + [initData.prvkey, initData.pubkey], + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useTunnelWizardStore.getState().next(value); + }, + }); + + return ( +
+
+

Keys

+ +

{`Upload your config file (optional) and we'll securely extract the connection settings for you. This is the fastest and recommended way to get started.`}

+
+ +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => } + + + + {(field) => } + + +
+ { + const pair = generateWGKeys(); + form.setFieldValue('prvkey', pair.privateKey); + form.setFieldValue('pubkey', pair.publicKey); + Snackbar.default('New keys set'); + }, + }, + ], + }, + ]} + /> +
+
+
+ +
+ + + ); +}; diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/KeysStep/style.scss b/new-ui/src/pages/full/TunnelWizardPage/steps/KeysStep/style.scss new file mode 100644 index 000000000..385f8b46a --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/KeysStep/style.scss @@ -0,0 +1,9 @@ +#keys-step { + .actions { + width: 100%; + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-end; + } +} diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx b/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx new file mode 100644 index 000000000..1879c07ca --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react'; +import z from 'zod'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { Controls } from '../../../../../shared/components/Controls/Controls'; +import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox'; +import { Split } from '../../../../../shared/components/Split/Split'; +import { useAppForm } from '../../../../../shared/form'; +import { formChangeLogic } from '../../../../../shared/formLogic'; +import { ThemeSpacing } from '../../../../../shared/types'; +import { + allowedIpsSchema, + endpointSchema, + optionalWireguardKeySchema, + wireguardKeySchema, +} from '../../../../../shared/utils/zod'; +import { useTunnelWizardStore } from '../../hooks/useTunnelWizardStore'; + +const formSchema = z.object({ + server_pubkey: wireguardKeySchema, + preshared_key: optionalWireguardKeySchema, + endpoint: endpointSchema, + dns: z.string(), + allowed_ips: allowedIpsSchema, + persistent_keep_alive: z.number().int().min(0), +}); + +type FormFields = z.infer; + +export const VpnServerStep = () => { + const initData = useTunnelWizardStore((s) => s.tunnelData); + + const defaultValues = useMemo( + (): FormFields => ({ + server_pubkey: initData.server_pubkey, + preshared_key: initData.preshared_key, + endpoint: initData.endpoint, + dns: initData.dns ?? '', + allowed_ips: initData.allowed_ips ?? '', + persistent_keep_alive: initData.persistent_keep_alive, + }), + [ + initData.server_pubkey, + initData.preshared_key, + initData.endpoint, + initData.dns, + initData.allowed_ips, + initData.persistent_keep_alive, + ], + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useTunnelWizardStore.getState().next(value); + }, + }); + + return ( +
+
+

VPN Server

+ +

{`Upload your config file (optional) and we'll securely extract the connection settings for you. This is the fastest and recommended way to get started.`}

+
+ +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + + {(field) => } + + + {(field) => ( + + )} + + + + + + {(field) => } + + + {(field) => } + + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + +
+ +
+ + + ); +}; diff --git a/new-ui/src/pages/full/TunnelWizardPage/types.ts b/new-ui/src/pages/full/TunnelWizardPage/types.ts new file mode 100644 index 000000000..15f813138 --- /dev/null +++ b/new-ui/src/pages/full/TunnelWizardPage/types.ts @@ -0,0 +1,10 @@ +export const TunnelWizardStep = { + GeneralInformation: 'general-information', + Keys: 'keys', + VpnServer: 'vpn-server', + AdvancedSettings: 'advanced-settings', + Finish: 'finish', +} as const; + +export type TunnelWizardStepValue = + (typeof TunnelWizardStep)[keyof typeof TunnelWizardStep]; diff --git a/new-ui/src/pages/full/UpdatePage/UpdatePage.tsx b/new-ui/src/pages/full/UpdatePage/UpdatePage.tsx new file mode 100644 index 000000000..40c4b6ae1 --- /dev/null +++ b/new-ui/src/pages/full/UpdatePage/UpdatePage.tsx @@ -0,0 +1,76 @@ +import './style.scss'; +import { useQuery } from '@tanstack/react-query'; +import { openUrl } from '@tauri-apps/plugin-opener'; +import Markdown from 'react-markdown'; +import { Button } from '../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../shared/components/Button/types'; +import { Divider } from '../../../shared/components/Divider/Divider'; +import { IconKind } from '../../../shared/components/Icon'; +import { SizedBox } from '../../../shared/components/SizedBox/SizedBox'; +import { useUpdateAvailable } from '../../../shared/hooks/useUpdateAvailable'; +import { FullPage } from '../../../shared/layouts/FullPage/FullPage'; +import { getLatestAppVersionQueryOptions } from '../../../shared/rust-api/query'; +import type { NewAppVersionInfo } from '../../../shared/rust-api/types'; +import { ThemeSpacing } from '../../../shared/types'; +import { isPresent } from '../../../shared/utils/isPresent'; +import upToDateBannerSrc from './assets/banner_up_to_date.png'; +import updateAvailableBannerSrc from './assets/banner_update_available.png'; + +const UpdateAvailable = ({ info }: { info: NewAppVersionInfo }) => ( + <> +

New {info.version} version is available.

+ + {isPresent(info.summary) &&

{info.summary}

} + +
+
+ {isPresent(info.notes) && ( + <> + +
+ {info.notes} +
+ + )} + +); + +const UpToDate = () => ( + <> +

You're up to date!

+ +

+ You are currently using the latest version of the application. +
+ There are no new updates available at this time. +

+ +); + +export const UpdatePage = () => { + const { data: latest } = useQuery(getLatestAppVersionQueryOptions); + const updateAvailable = useUpdateAvailable(); + + return ( + + + + {updateAvailable && latest ? : } + + ); +}; diff --git a/new-ui/src/pages/full/UpdatePage/assets/banner_up_to_date.png b/new-ui/src/pages/full/UpdatePage/assets/banner_up_to_date.png new file mode 100644 index 000000000..27207b357 Binary files /dev/null and b/new-ui/src/pages/full/UpdatePage/assets/banner_up_to_date.png differ diff --git a/new-ui/src/pages/full/UpdatePage/assets/banner_update_available.png b/new-ui/src/pages/full/UpdatePage/assets/banner_update_available.png new file mode 100644 index 000000000..ba0230f2f Binary files /dev/null and b/new-ui/src/pages/full/UpdatePage/assets/banner_update_available.png differ diff --git a/new-ui/src/pages/full/UpdatePage/style.scss b/new-ui/src/pages/full/UpdatePage/style.scss new file mode 100644 index 000000000..a131f7d57 --- /dev/null +++ b/new-ui/src/pages/full/UpdatePage/style.scss @@ -0,0 +1,48 @@ +#update-page-view { + .banner { + width: 100%; + height: 160px; + object-fit: cover; + border-radius: var(--radius-lg); + } + + .title { + font: var(--t-h4); + } + + .description { + color: var(--c-white-70); + font: var(--t-body-sm-400); + } + + .notes { + display: flex; + flex-direction: column; + row-gap: var(--spacing-sm); + font: var(--t-body-xs-400); + color: var(--c-white-80); + + a { + color: var(--fg-action); + text-decoration: underline; + } + + ul { + display: flex; + flex-direction: column; + row-gap: var(--spacing-xs); + padding-left: var(--spacing-lg); + } + + h1, + h2, + h3 { + color: var(--c-white-100); + } + } + + .actions { + display: flex; + gap: var(--spacing-md); + } +} diff --git a/new-ui/src/pages/playground/PlaygroundIndex.tsx b/new-ui/src/pages/playground/PlaygroundIndex.tsx new file mode 100644 index 000000000..4c2362129 --- /dev/null +++ b/new-ui/src/pages/playground/PlaygroundIndex.tsx @@ -0,0 +1,19 @@ +import './style.scss'; +import { PlaygroundSnackbarTest } from './components/PlaygroundSnackbarTest/PlaygroundSnackbarTest'; +import { PlaygroundTestMenu } from './components/PlaygroundTestMenu/PlaygroundTestMenu'; +import { PlaygroundTestSelect } from './components/PlaygroundTestSelect'; + +export const PlaygroundIndex = () => { + return ( +
+
+
{/* tabs here */}
+
+
+ + + +
+
+ ); +}; diff --git a/new-ui/src/pages/playground/components/PlaygroundCard/PlaygroundCard.tsx b/new-ui/src/pages/playground/components/PlaygroundCard/PlaygroundCard.tsx new file mode 100644 index 000000000..8f8dc7023 --- /dev/null +++ b/new-ui/src/pages/playground/components/PlaygroundCard/PlaygroundCard.tsx @@ -0,0 +1,6 @@ +import './style.scss'; +import type { PropsWithChildren } from 'react'; + +export const PlaygroundCard = ({ children }: PropsWithChildren) => { + return
{children}
; +}; diff --git a/new-ui/src/pages/playground/components/PlaygroundCard/style.scss b/new-ui/src/pages/playground/components/PlaygroundCard/style.scss new file mode 100644 index 000000000..6f4d33b4f --- /dev/null +++ b/new-ui/src/pages/playground/components/PlaygroundCard/style.scss @@ -0,0 +1,12 @@ +.playground-card { + display: flex; + flex-flow: column; + box-sizing: border-box; + padding: var(--spacing-md) var(--spacing-xl); + border-radius: 16px; + border: 1px solid var(--border-default); + align-items: center; + justify-content: center; + min-height: 260px; + width: 100%; +} diff --git a/new-ui/src/pages/playground/components/PlaygroundSnackbarTest/PlaygroundSnackbarTest.tsx b/new-ui/src/pages/playground/components/PlaygroundSnackbarTest/PlaygroundSnackbarTest.tsx new file mode 100644 index 000000000..608ea6ed3 --- /dev/null +++ b/new-ui/src/pages/playground/components/PlaygroundSnackbarTest/PlaygroundSnackbarTest.tsx @@ -0,0 +1,66 @@ +import './style.scss'; +import { useState } from 'react'; +import { Button } from '../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../shared/components/Button/types'; +import { Input } from '../../../../shared/components/Input/Input'; +import { Snackbar } from '../../../../shared/providers/snackbar/snackbar'; +import { PlaygroundCard } from '../PlaygroundCard/PlaygroundCard'; + +const DEFAULT_TIMEOUT = 2; + +export const PlaygroundSnackbarTest = () => { + const [timeout, setTimeout] = useState(DEFAULT_TIMEOUT); + + return ( + +
+

Snackbar

+ setTimeout((v as number) ?? DEFAULT_TIMEOUT)} + /> +
+
+
+
+
+
+ ); +}; diff --git a/new-ui/src/pages/playground/components/PlaygroundSnackbarTest/style.scss b/new-ui/src/pages/playground/components/PlaygroundSnackbarTest/style.scss new file mode 100644 index 000000000..b0e8a6456 --- /dev/null +++ b/new-ui/src/pages/playground/components/PlaygroundSnackbarTest/style.scss @@ -0,0 +1,17 @@ +.playground-snackbar-test { + display: flex; + flex-direction: column; + gap: 12px; + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + } + + .actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } +} diff --git a/new-ui/src/pages/playground/components/PlaygroundTestMenu/PlaygroundTestMenu.tsx b/new-ui/src/pages/playground/components/PlaygroundTestMenu/PlaygroundTestMenu.tsx new file mode 100644 index 000000000..ba0ed4ac3 --- /dev/null +++ b/new-ui/src/pages/playground/components/PlaygroundTestMenu/PlaygroundTestMenu.tsx @@ -0,0 +1,108 @@ +import './style.scss'; +import { useState } from 'react'; +import { Menu } from '../../../../shared/components/Menu/Menu'; +import { PlaygroundCard } from '../PlaygroundCard/PlaygroundCard'; + +export const PlaygroundTestMenu = () => { + const [lastClicked, setLastClicked] = useState(null); + + return ( + +
+

Menu

+ {lastClicked &&

Last clicked: {lastClicked}

} + setLastClicked('Edit') }, + { + text: 'Duplicate', + icon: 'copy', + onClick: () => setLastClicked('Duplicate'), + }, + ], + }, + { + items: [ + { + text: 'Delete', + icon: 'delete', + variant: 'danger', + onClick: () => setLastClicked('Delete'), + }, + ], + }, + ]} + /> +

Menu with disabled & nested

+ setLastClicked('New') }, + { + text: 'Open', + icon: 'edit', + disabled: true, + onClick: () => setLastClicked('Open'), + }, + { + text: 'Export', + icon: 'copy', + items: [ + { + text: 'Export as PDF', + onClick: () => setLastClicked('Export as PDF'), + }, + { + text: 'Export as CSV', + onClick: () => setLastClicked('Export as CSV'), + }, + { + text: 'Export as JSON', + disabled: true, + onClick: () => setLastClicked('Export as JSON'), + }, + ], + }, + ], + }, + { + header: { text: 'Advanced' }, + items: [ + { + text: 'Settings', + icon: 'settings', + items: [ + { text: 'General', onClick: () => setLastClicked('General') }, + { text: 'Security', onClick: () => setLastClicked('Security') }, + ], + }, + { + text: 'Maintenance', + icon: 'protection', + disabled: true, + onClick: () => setLastClicked('Maintenance'), + }, + ], + }, + { + items: [ + { + text: 'Delete', + icon: 'delete', + variant: 'danger', + disabled: true, + onClick: () => setLastClicked('Delete (disabled)'), + }, + ], + }, + ]} + /> +
+
+ ); +}; diff --git a/new-ui/src/pages/playground/components/PlaygroundTestMenu/style.scss b/new-ui/src/pages/playground/components/PlaygroundTestMenu/style.scss new file mode 100644 index 000000000..612c6af41 --- /dev/null +++ b/new-ui/src/pages/playground/components/PlaygroundTestMenu/style.scss @@ -0,0 +1,10 @@ +.playground-test-menu { + display: flex; + flex-direction: column; + gap: 12px; + + .last-clicked { + font-size: 13px; + color: var(--text-secondary); + } +} diff --git a/new-ui/src/pages/playground/components/PlaygroundTestSelect.tsx b/new-ui/src/pages/playground/components/PlaygroundTestSelect.tsx new file mode 100644 index 000000000..c5438222e --- /dev/null +++ b/new-ui/src/pages/playground/components/PlaygroundTestSelect.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { Select } from '../../../shared/components/Select/Select'; +import type { + SelectOption, + SelectOptionGroup, +} from '../../../shared/components/Select/types'; + +type RegionOption = { + code: string; +}; + +const quickOptions: readonly SelectOption[] = [ + { + key: 'all', + label: 'All Regions', + value: { code: 'all' }, + }, +]; + +const groupedOptions: readonly SelectOptionGroup[] = [ + { + key: 'eu', + label: 'Europe', + options: [ + { + key: 'de', + label: 'Germany', + value: { code: 'de' }, + }, + { + key: 'fr', + label: 'France', + value: { code: 'fr' }, + }, + ], + }, + { + key: 'americas', + label: 'Americas', + options: [ + { + key: 'us', + label: 'United States', + value: { code: 'us' }, + }, + { + key: 'ca', + label: 'Canada', + value: { code: 'ca' }, + }, + ], + }, +]; + +export const PlaygroundTestSelect = () => { + const [value, setValue] = useState>(quickOptions[0]); + + return ( + { + inputRefs.current[i] = el; + }} + type="text" + inputMode="numeric" + value={digit} + onFocus={() => setFocusedIndex(i)} + onBlur={() => setFocusedIndex(null)} + onKeyDown={(e) => handleKeyDown(i, e)} + onPaste={handlePaste} + onChange={() => {}} + /> + + ))} + + + + ); +}; diff --git a/new-ui/src/shared/components/CodeInput/style.scss b/new-ui/src/shared/components/CodeInput/style.scss new file mode 100644 index 000000000..d1e7ba993 --- /dev/null +++ b/new-ui/src/shared/components/CodeInput/style.scss @@ -0,0 +1,30 @@ +.code-input > .inputs-grid { + display: flex; + flex-flow: row nowrap; + gap: var(--spacing-md); + justify-content: center; + align-items: center; + + .field-box { + height: 36px; + width: 36px; + min-height: unset; + padding: 0; + cursor: text; + + input { + width: auto; + min-width: 12px; + height: 20px; + text-align: center; + background: transparent; + border: none; + outline: none; + color: var(--fg-white-100); + font: var(--t-input-text-primary); + line-height: 20px; + caret-color: transparent; + cursor: text; + } + } +} diff --git a/new-ui/src/shared/components/ConfirmModal/ConfirmModal.tsx b/new-ui/src/shared/components/ConfirmModal/ConfirmModal.tsx new file mode 100644 index 000000000..e246e31d8 --- /dev/null +++ b/new-ui/src/shared/components/ConfirmModal/ConfirmModal.tsx @@ -0,0 +1,75 @@ +import './style.scss'; +import { useMutation } from '@tanstack/react-query'; +import { useShallow } from 'zustand/shallow'; +import { useConfirmModal } from '../../hooks/confirmModal/useConfirmModal'; +import { isPresent } from '../../utils/isPresent'; +import { Button } from '../Button/Button'; +import { ButtonVariant } from '../Button/types'; +import { Controls } from '../Controls/Controls'; +import { Modal } from '../Modal/Modal'; +import { RenderMarkdown } from '../RenderMarkdown/RenderMarkdown'; + +export const ConfirmModal = () => { + const [isOpen, title] = useConfirmModal(useShallow((s) => [s.visible, s.title])); + + return ( + { + useConfirmModal.setState({ visible: false }); + }} + afterClose={() => { + useConfirmModal.getState().reset(); + }} + > + + + ); +}; + +const ModalContent = () => { + const content = useConfirmModal((s) => s.content); + + const [cancelProps, submitProps, onSubmit] = useConfirmModal( + useShallow((s) => [s.cancelProps, s.submitProps, s.onSubmit]), + ); + + const { mutate, isPending } = useMutation({ + mutationFn: onSubmit, + onSuccess: () => { + useConfirmModal.setState({ + visible: false, + }); + }, + }); + + return ( + <> + + +
+
+
+ + ); +}; diff --git a/new-ui/src/shared/components/ConfirmModal/style.scss b/new-ui/src/shared/components/ConfirmModal/style.scss new file mode 100644 index 000000000..b2f56efae --- /dev/null +++ b/new-ui/src/shared/components/ConfirmModal/style.scss @@ -0,0 +1,6 @@ +#confirm-modal .markdown-render { + p { + font: var(--t-body-sm-400); + color: var(--fg-white-100); + } +} diff --git a/new-ui/src/shared/components/Controls/Controls.tsx b/new-ui/src/shared/components/Controls/Controls.tsx new file mode 100644 index 000000000..bc0ba3e7d --- /dev/null +++ b/new-ui/src/shared/components/Controls/Controls.tsx @@ -0,0 +1,13 @@ +import type { HTMLProps, PropsWithChildren } from 'react'; +import './style.scss'; +import clsx from 'clsx'; + +type Props = PropsWithChildren & HTMLProps; + +export const Controls = ({ children, className, ...props }: Props) => { + return ( +
+ {children} +
+ ); +}; diff --git a/new-ui/src/shared/components/Controls/style.scss b/new-ui/src/shared/components/Controls/style.scss new file mode 100644 index 000000000..b688d42ba --- /dev/null +++ b/new-ui/src/shared/components/Controls/style.scss @@ -0,0 +1,16 @@ +.controls { + display: flex; + flex-flow: row nowrap; + column-gap: var(--spacing-md); + align-items: center; + justify-content: flex-start; + + .right { + margin-left: auto; + display: flex; + flex-flow: row nowrap; + column-gap: var(--spacing-md); + align-items: center; + justify-content: flex-end; + } +} diff --git a/new-ui/src/shared/components/CopyField/CopyField.tsx b/new-ui/src/shared/components/CopyField/CopyField.tsx new file mode 100644 index 000000000..c77d315f0 --- /dev/null +++ b/new-ui/src/shared/components/CopyField/CopyField.tsx @@ -0,0 +1,90 @@ +import './style.scss'; +import { + autoUpdate, + FloatingPortal, + offset, + shift, + useFloating, +} from '@floating-ui/react'; +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; +import clsx from 'clsx'; +import { type HTMLAttributes, type Ref, useEffect, useState } from 'react'; +import { isPresent } from '../../utils/isPresent'; +import { Icon } from '../Icon'; +import { Tooltip } from '../Tooltip/Tooltip'; + +type Props = { + text: string; + label?: string; + copyTooltip: string; + ref?: Ref; +} & HTMLAttributes; + +export const CopyField = ({ + text, + label, + ref, + className, + copyTooltip, + ...props +}: Props) => { + const [copied, setCopied] = useState(false); + + const { refs, floatingStyles } = useFloating({ + placement: 'top', + whileElementsMounted: autoUpdate, + middleware: [ + offset(15), + shift({ + padding: 4, + }), + ], + }); + + useEffect(() => { + if (copied) { + const clearCopied = () => { + setCopied(false); + }; + const timeout = setTimeout(clearCopied, 1500); + return () => { + clearTimeout(timeout); + }; + } + }, [copied]); + + return ( + <> +
+
+ {isPresent(label) && ( +
+

{label}

+
+ )} +
+

{text}

+ +
+
+
+ {copied && ( + + +

{copyTooltip}

+
+
+ )} + + ); +}; diff --git a/new-ui/src/shared/components/CopyField/style.scss b/new-ui/src/shared/components/CopyField/style.scss new file mode 100644 index 000000000..c44c7c302 --- /dev/null +++ b/new-ui/src/shared/components/CopyField/style.scss @@ -0,0 +1,55 @@ +.copy-field { + & > .inner { + width: 100%; + + .label-track { + padding-bottom: var(--spacing-xs); + user-select: none; + + p { + font: var(--t-input-title); + color: var(--fg-neutral); + } + } + + .track { + width: 100%; + border: 1px solid var(--border-default); + box-sizing: border-box; + padding: var(--input-spacing-sm) var(--input-spacing-lg); + display: grid; + grid-template-columns: auto 20px; + grid-template-rows: 1fr; + column-gap: var(--input-spacing-sm); + align-items: center; + overflow: hidden; + border-radius: var(--input-border-radius); + background-color: var(--fg-white-10); + + p { + font: var(--t-input-text-primary); + color: var(--fg-white-50); + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + min-width: 0; + } + + button { + padding: 0; + margin: 0; + border: none; + background-color: transparent; + width: 20px; + height: 20px; + cursor: pointer; + user-select: none; + + .icon[data-kind='check-filled'] { + --icon-color: var(--fg-success); + } + } + } + } +} diff --git a/new-ui/src/shared/components/DetailsFold/DetailsFold.tsx b/new-ui/src/shared/components/DetailsFold/DetailsFold.tsx new file mode 100644 index 000000000..6ac9b07c2 --- /dev/null +++ b/new-ui/src/shared/components/DetailsFold/DetailsFold.tsx @@ -0,0 +1,43 @@ +import './style.scss'; + +import clsx from 'clsx'; +import { Fragment, type ReactNode } from 'react'; +import { Divider } from '../Divider/Divider'; + +export type DetailsFoldRow = { + label: string; + value: ReactNode; +}; + +export type DetailsFoldSection = { + title: string; + rows: DetailsFoldRow[]; + compact?: boolean; +}; + +type Props = { + sections: DetailsFoldSection[]; +}; + +export const DetailsFold = ({ sections }: Props) => { + return ( +
+ {sections.map((section) => ( +
+

{section.title}

+
+ {section.rows.map((row, index) => ( + + {index > 0 && } +
+

{row.label}

+

{row.value}

+
+
+ ))} +
+
+ ))} +
+ ); +}; diff --git a/new-ui/src/shared/components/DetailsFold/style.scss b/new-ui/src/shared/components/DetailsFold/style.scss new file mode 100644 index 000000000..4580799d9 --- /dev/null +++ b/new-ui/src/shared/components/DetailsFold/style.scss @@ -0,0 +1,56 @@ +.details-fold { + display: flex; + flex-flow: column; + row-gap: var(--spacing-3xl); + + > .group { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + + > p { + font: var(--t-body-sm-500); + color: var(--fg-white-70); + user-select: none; + } + + > .card { + box-sizing: border-box; + display: flex; + flex-flow: column; + row-gap: var(--spacing-lg); + border-radius: 12px; + border: 1px solid var(--border-disabled); + padding: var(--spacing-md); + + &.compact { + row-gap: var(--spacing-md); + } + + .row { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + justify-content: space-between; + column-gap: var(--spacing-2xl); + + :nth-child(1) { + user-select: none; + font: var(--t-body-xs-400); + color: var(--fg-white-80); + text-align: left; + flex-shrink: 0; + } + + :nth-child(2) { + font: var(--t-body-xs-400); + color: var(--fg-white-100); + text-align: right; + max-width: 365px; + min-width: 0; + overflow-wrap: anywhere; + } + } + } + } +} diff --git a/new-ui/src/shared/components/Divider/Divider.tsx b/new-ui/src/shared/components/Divider/Divider.tsx new file mode 100644 index 000000000..2cf8076c3 --- /dev/null +++ b/new-ui/src/shared/components/Divider/Divider.tsx @@ -0,0 +1,59 @@ +import './style.scss'; +import clsx from 'clsx'; +import { type CSSProperties, useMemo } from 'react'; +import type { OrientationValue, ThemeSpacingValue } from '../../types'; +import { isPresent } from '../../utils/isPresent'; + +type Props = { + text?: string; + orientation?: OrientationValue; + spacing?: ThemeSpacingValue; +}; + +export const Divider = ({ text, spacing, orientation = 'horizontal' }: Props) => { + const textPresent = isPresent(text) && text.length > 0; + + const style = useMemo((): CSSProperties => { + const res: CSSProperties = {}; + if (spacing) { + switch (orientation) { + case 'horizontal': + res.paddingTop = spacing; + res.paddingBottom = spacing; + break; + case 'vertical': + res.paddingLeft = spacing; + res.paddingRight = spacing; + break; + } + } + return res; + }, [orientation, spacing]); + + return ( +
+ {orientation === 'horizontal' && ( + <> + {textPresent && ( + <> + + {text} + + + )} + {!textPresent && } + + )} + {orientation === 'vertical' && } +
+ ); +}; + +const Line = () => { + return
; +}; diff --git a/new-ui/src/shared/components/Divider/style.scss b/new-ui/src/shared/components/Divider/style.scss new file mode 100644 index 000000000..e47a4feea --- /dev/null +++ b/new-ui/src/shared/components/Divider/style.scss @@ -0,0 +1,53 @@ +.divider { + --divider-line-size: 1px; + --divider-color: var(--bg-white-10); + + user-select: none; + + .line { + content: ' '; + display: block; + background-color: var(--divider-color); + border-radius: 0; + margin: 0; + padding: 0; + } + + &.vertical { + display: inline-block; + height: 10px; + + .line { + height: inherit; + width: var(--divider-line-size); + } + } + + &.horizontal { + width: 100%; + + .line { + width: 100%; + height: var(--divider-line-size); + } + } + + &.horizontal.text { + --divider-color: var(--bg-action-faded); + + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-rows: 1fr; + column-gap: var(--spacing-lg); + align-items: center; + + .line { + width: 100%; + } + } + + span { + font: var(--t-body-xs-500); + color: var(--fg-white-70); + } +} diff --git a/new-ui/src/shared/components/EmptyState/EmptyState.tsx b/new-ui/src/shared/components/EmptyState/EmptyState.tsx new file mode 100644 index 000000000..3e0bdb08f --- /dev/null +++ b/new-ui/src/shared/components/EmptyState/EmptyState.tsx @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import './style.scss'; +import clsx from 'clsx'; +import { ThemeSpacing } from '../../types'; +import { isPresent } from '../../utils/isPresent'; +import { Button } from '../Button/Button'; +import { SizedBox } from '../SizedBox/SizedBox'; +import type { EmptyStateProps } from './types'; + +const Empty = () => { + return null; +}; + +export const EmptyState = ({ + ref, + icon, + primaryAction, + secondaryAction, + secondaryActionText, + subtitle, + title, + className, + id, + testId, +}: EmptyStateProps) => { + const RenderIcon = useMemo(() => { + if (!icon) return Empty; + return Empty; + }, [icon]); + + return ( +
+ {isPresent(icon) && ( + <> + + + + )} + {isPresent(title) && ( + <> +

{title}

+ + + )} + {isPresent(subtitle) &&

{subtitle}

} + + {isPresent(primaryAction) && ( + <> + + )} +
+ ); +}; diff --git a/new-ui/src/shared/components/EmptyState/style.scss b/new-ui/src/shared/components/EmptyState/style.scss new file mode 100644 index 000000000..90a3b5964 --- /dev/null +++ b/new-ui/src/shared/components/EmptyState/style.scss @@ -0,0 +1,46 @@ +.empty-state { + display: flex; + flex-flow: column; + flex: none; + align-items: center; + justify-content: flex-start; + height: auto; + user-select: none; + + & > p, + & > span { + text-align: center; + } + + .title { + color: var(--fg-muted); + font: var(--t-body-primary-500); + } + + .subtitle { + color: var(--fg-muted); + font: var(--t-body-sm-400); + } + + .secondary-action { + background-color: transparent; + text-align: center; + color: var(--fg-action); + border: none; + padding: 0; + margin: 0; + } + + .empty-icon { + width: 40px; + height: 40px; + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; + flex: none; + padding: 4px; + border: 1px dashed var(--border-faded); + border-radius: 100px; + } +} diff --git a/new-ui/src/shared/components/EmptyState/types.ts b/new-ui/src/shared/components/EmptyState/types.ts new file mode 100644 index 000000000..7288f512b --- /dev/null +++ b/new-ui/src/shared/components/EmptyState/types.ts @@ -0,0 +1,15 @@ +import type { Ref } from 'react'; +import type { ButtonProps } from '../Button/types'; + +export type EmptyStateProps = { + ref?: Ref; + title?: string; + subtitle?: string; + icon?: string; + className?: string; + testId?: string; + id?: string; + primaryAction?: ButtonProps; + secondaryAction?: () => void; + secondaryActionText?: string; +}; diff --git a/new-ui/src/shared/components/EmptyStateFlexible/EmptyStateFlexible.tsx b/new-ui/src/shared/components/EmptyStateFlexible/EmptyStateFlexible.tsx new file mode 100644 index 000000000..4e47d97dd --- /dev/null +++ b/new-ui/src/shared/components/EmptyStateFlexible/EmptyStateFlexible.tsx @@ -0,0 +1,35 @@ +import './style.scss'; +import { useWindowSize } from '@uidotdev/usehooks'; +import { useMemo, useRef } from 'react'; +import { EmptyState } from '../EmptyState/EmptyState'; +import type { EmptyStateProps } from '../EmptyState/types'; + +type Props = EmptyStateProps; + +export const EmptyStateFlexible = (props: Props) => { + const containerRef = useRef(null); + const windowHeight = useWindowSize().height; + + const initHeight = useMemo(() => { + if (!containerRef.current) return 0; + return window.innerHeight - containerRef.current.getBoundingClientRect().top; + }, []); + + const minHeight = useMemo(() => { + const container = containerRef.current; + if (!container || !windowHeight) return null; + return windowHeight - container.getBoundingClientRect().top; + }, [windowHeight]); + + return ( +
+ +
+ ); +}; diff --git a/new-ui/src/shared/components/EmptyStateFlexible/style.scss b/new-ui/src/shared/components/EmptyStateFlexible/style.scss new file mode 100644 index 000000000..8023b98d4 --- /dev/null +++ b/new-ui/src/shared/components/EmptyStateFlexible/style.scss @@ -0,0 +1,8 @@ +.flexible-empty-state { + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: var(--spacing-2xl) 0; +} diff --git a/new-ui/src/shared/components/FieldBox/FieldBox.tsx b/new-ui/src/shared/components/FieldBox/FieldBox.tsx new file mode 100644 index 000000000..9a8801579 --- /dev/null +++ b/new-ui/src/shared/components/FieldBox/FieldBox.tsx @@ -0,0 +1,58 @@ +import './style.scss'; +import clsx from 'clsx'; +import { isPresent } from '../../utils/isPresent'; +import { InteractionBox } from '../InteractionBox/InteractionBox'; +import type { FieldBoxProps } from './types'; + +// generalized field box for components like Input, shouldn't be in layout on it's own +export const FieldBox = ({ + children, + disabled, + error, + className, + boxRef, + interactionRef, + iconLeft, + iconRight, + size, + forceFocusState, + onInteractionClick, + reserveInteraction = false, + ...rest +}: FieldBoxProps) => { + const hasIconLeft = isPresent(iconLeft); + const hasIconRight = isPresent(iconRight) || reserveInteraction; + return ( +
+ {hasIconLeft && iconLeft} + {children} + {hasIconRight && ( + <> + {isPresent(iconRight) && ( + + {iconRight} + + )} + {!isPresent(iconRight) &&
} + + )} +
+ ); +}; diff --git a/new-ui/src/shared/components/FieldBox/style.scss b/new-ui/src/shared/components/FieldBox/style.scss new file mode 100644 index 000000000..8f69c3695 --- /dev/null +++ b/new-ui/src/shared/components/FieldBox/style.scss @@ -0,0 +1,88 @@ +.field-box { + --border-color: var(--border-default); + --background-color: transparent; + + position: relative; + box-sizing: border-box; + display: grid; + grid-template-rows: 1fr; + align-items: center; + column-gap: var(--spacing-sm); + overflow: hidden; + cursor: pointer; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: var(--spacing-sm) var(--spacing-md); + outline: none; + background-color: var(--background-color); + + @include animate(border-color, background-color); + + p, + span { + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + &.size-default { + min-height: 36px; + + p, + span { + font: var(--t-input-text-primary); + color: var(--fg-white-100); + } + } + + &.grid-default { + grid-template-columns: 1fr; + } + + &.grid-left { + grid-template-columns: 20px 1fr; + } + + &.grid-right { + grid-template-columns: 1fr 20px; + } + + &.grid-both { + grid-template-columns: 20px 1fr 20px; + } + + .placeholder { + color: var(--fg-white-50); + font: var(--t-input-text-primary); + } + + .interaction-box { + & > button { + height: 28px; + width: 28px; + } + } + + &:not(.disabled, .error) { + &:hover { + --border-color: var(--border-emphasis); + } + + &:focus-within, + &.focus { + --border-color: var(--border-action); + } + } + + &.error { + --border-color: var(--border-critical); + } + + &.disabled { + --border-color: var(--border-disabled); + --background-color: var(--bg-white-10); + + cursor: not-allowed; + } +} diff --git a/new-ui/src/shared/components/FieldBox/types.ts b/new-ui/src/shared/components/FieldBox/types.ts new file mode 100644 index 000000000..5c8f117f2 --- /dev/null +++ b/new-ui/src/shared/components/FieldBox/types.ts @@ -0,0 +1,22 @@ +import type { + HTMLAttributes, + MouseEventHandler, + PropsWithChildren, + ReactNode, + Ref, +} from 'react'; + +export type FieldSize = 'lg' | 'default'; + +export interface FieldBoxProps extends HTMLAttributes, PropsWithChildren { + boxRef?: Ref; + interactionRef?: Ref; + error?: boolean; + disabled?: boolean; + iconLeft?: ReactNode; + iconRight?: ReactNode; + size?: FieldSize; + forceFocusState?: boolean; + onInteractionClick?: MouseEventHandler; + reserveInteraction?: boolean; +} diff --git a/new-ui/src/shared/components/FieldError/FieldError.tsx b/new-ui/src/shared/components/FieldError/FieldError.tsx new file mode 100644 index 000000000..0bb3e48cc --- /dev/null +++ b/new-ui/src/shared/components/FieldError/FieldError.tsx @@ -0,0 +1,35 @@ +import { motion } from 'motion/react'; +import './style.scss'; +import { motionTransitionStandard } from '../../consts'; +import { isPresent } from '../../utils/isPresent'; + +type Props = { + error?: string | null; +}; + +export const FieldError = ({ error }: Props) => { + return ( + <> + {isPresent(error) && error.length > 0 && ( + + {error} + + )} + + ); +}; diff --git a/new-ui/src/shared/components/FieldError/style.scss b/new-ui/src/shared/components/FieldError/style.scss new file mode 100644 index 000000000..d606952ea --- /dev/null +++ b/new-ui/src/shared/components/FieldError/style.scss @@ -0,0 +1,6 @@ +.field-error { + padding-top: 8px; + font: var(--t-input-error-message); + color: var(--bg-critical-muted); + user-select: none; +} diff --git a/new-ui/src/shared/components/FieldLabel/FieldLabel.tsx b/new-ui/src/shared/components/FieldLabel/FieldLabel.tsx new file mode 100644 index 000000000..3aadd31b1 --- /dev/null +++ b/new-ui/src/shared/components/FieldLabel/FieldLabel.tsx @@ -0,0 +1,42 @@ +import './style.scss'; + +import clsx from 'clsx'; +import type { MouseEventHandler, Ref } from 'react'; + +type Props = { + text: string; + id?: string; + ref?: Ref; + required?: boolean; + onClick?: MouseEventHandler; +}; + +export const FieldLabel = ({ text, ref, required, id, onClick }: Props) => { + return ( +
+ {required && ( + + + + )} + {text} +
+ ); +}; diff --git a/new-ui/src/shared/components/FieldLabel/style.scss b/new-ui/src/shared/components/FieldLabel/style.scss new file mode 100644 index 000000000..d24608997 --- /dev/null +++ b/new-ui/src/shared/components/FieldLabel/style.scss @@ -0,0 +1,33 @@ +.field-label { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-sm); + position: relative; + padding-bottom: var(--spacing-xs); + + span { + font: var(--t-input-title); + color: var(--fg-white-80); + } + + .required-icon { + user-select: none; + position: absolute; + left: 0; + top: 2px; + + path { + fill: var(--fg-white-60); + } + } + + &.required { + padding-left: 8px; + } + + svg path { + fill: var(--fg-white-60); + } +} diff --git a/new-ui/src/shared/components/FloatingMenu/FloatingMenu.tsx b/new-ui/src/shared/components/FloatingMenu/FloatingMenu.tsx new file mode 100644 index 000000000..980b9456c --- /dev/null +++ b/new-ui/src/shared/components/FloatingMenu/FloatingMenu.tsx @@ -0,0 +1,14 @@ +import './style.scss'; +import clsx from 'clsx'; +import type { HTMLProps, PropsWithChildren } from 'react'; + +interface Props extends PropsWithChildren { + containerProps: HTMLProps; +} +export const FloatingMenu = ({ containerProps, children }: Props) => { + return ( +
+ {children} +
+ ); +}; diff --git a/new-ui/src/shared/components/FloatingMenu/style.scss b/new-ui/src/shared/components/FloatingMenu/style.scss new file mode 100644 index 000000000..e3e69ee13 --- /dev/null +++ b/new-ui/src/shared/components/FloatingMenu/style.scss @@ -0,0 +1,8 @@ +.floating-menu { + border-radius: 12px; + box-sizing: border-box; + padding: 8px; + background-color: var(--c-saturated-dark-blue-60); + box-shadow: 0 4px 12px 0 rgb(0 0 0 / 7%); + backdrop-filter: blur(10px); +} diff --git a/new-ui/src/shared/components/Fold/Fold.tsx b/new-ui/src/shared/components/Fold/Fold.tsx new file mode 100644 index 000000000..362786373 --- /dev/null +++ b/new-ui/src/shared/components/Fold/Fold.tsx @@ -0,0 +1,29 @@ +import './style.scss'; +import clsx from 'clsx'; +import type { HTMLAttributes, PropsWithChildren, Ref } from 'react'; + +export const Fold = ({ + ref, + className, + children, + open, + contentClassName, + ...rest +}: { + open: boolean; + ref?: Ref; + contentClassName?: string; +} & PropsWithChildren & + HTMLAttributes) => { + return ( +
+
{children}
+
+ ); +}; diff --git a/new-ui/src/shared/components/Fold/style.scss b/new-ui/src/shared/components/Fold/style.scss new file mode 100644 index 000000000..f11e4fc81 --- /dev/null +++ b/new-ui/src/shared/components/Fold/style.scss @@ -0,0 +1,19 @@ +.fold { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + + @include animate(grid-template-rows); + + &.folded { + grid-template-rows: 0fr; + } + + .fold-content { + overflow: hidden; + + // fix for content with single button in place + padding-bottom: 1px; + padding-left: 1px; + } +} diff --git a/new-ui/src/shared/components/FullPageTitle/FullPageTitle.tsx b/new-ui/src/shared/components/FullPageTitle/FullPageTitle.tsx new file mode 100644 index 000000000..fd059892c --- /dev/null +++ b/new-ui/src/shared/components/FullPageTitle/FullPageTitle.tsx @@ -0,0 +1,19 @@ +import { ThemeSpacing, type ThemeSpacingValue } from '../../types'; +import './style.scss'; + +interface Props { + title: string; + spacing?: ThemeSpacingValue; +} +export const FullPageTitle = ({ title, spacing = ThemeSpacing.Md }: Props) => { + return ( +
+

{title}

+
+ ); +}; diff --git a/new-ui/src/shared/components/FullPageTitle/style.scss b/new-ui/src/shared/components/FullPageTitle/style.scss new file mode 100644 index 000000000..8f3450658 --- /dev/null +++ b/new-ui/src/shared/components/FullPageTitle/style.scss @@ -0,0 +1,8 @@ +.full-page-title { + user-select: none; + + p { + font: var(--t-h5); + color: var(--fg-white-100); + } +} diff --git a/new-ui/src/shared/components/Icon/Icon.tsx b/new-ui/src/shared/components/Icon/Icon.tsx new file mode 100644 index 000000000..a533801d6 --- /dev/null +++ b/new-ui/src/shared/components/Icon/Icon.tsx @@ -0,0 +1,505 @@ +import { type CSSProperties, type Ref, useMemo } from 'react'; +import type { IconKindValue } from './icon-types'; +import './style.scss'; +import clsx from 'clsx'; +import type { DirectionValue, ThemeVariableValue } from '../../types'; +import { isPresent } from '../../utils/isPresent'; +import { IconAccessSettings } from './icons/IconAccessSettings'; +import { IconActivity } from './icons/IconActivity'; +import { IconActivityNotes } from './icons/IconActivityNotes'; +import { IconAddAlias } from './icons/IconAddAlias'; +import { IconAddDevice } from './icons/IconAddDevice'; +import { IconAddGroup } from './icons/IconAddGroup'; +import { IconAddLocation } from './icons/IconAddLocation'; +import { IconAddRule } from './icons/IconAddRule'; +import { IconAddToken } from './icons/IconAddToken'; +import { IconAddUser } from './icons/IconAddUser'; +import { IconAliases } from './icons/IconAliases'; +import { IconAnalytics } from './icons/IconAnalytics'; +import { IconAndroid } from './icons/IconAndroid'; +import { IconApple } from './icons/IconApple'; +import { IconAppStore } from './icons/IconAppstore'; +import { IconArchLinux } from './icons/IconArchLinux'; +import { IconArrowBig } from './icons/IconArrowBig'; +import { IconArrowSmall } from './icons/IconArrowSmall'; +import { IconAttentionFilled } from './icons/IconAttentionFilled'; +import { IconAttentionOutlined } from './icons/IconAttentionOutlined'; +import { IconAuthorisedApp } from './icons/IconAuthorisedApp'; +import { IconBiometric } from './icons/IconBiometric'; +import { IconBug } from './icons/IconBug'; +import { IconCalendar } from './icons/IconCalendar'; +import { IconChat } from './icons/IconChat'; +import { IconCheck } from './icons/IconCheck'; +import { IconCheckCircle } from './icons/IconCheckCircle'; +import { IconCheckFilled } from './icons/IconCheckFilled'; +import { IconClear } from './icons/IconClear'; +import { IconClose } from './icons/IconClose'; +import { IconCode } from './icons/IconCode'; +import { IconConfig } from './icons/IconConfig'; +import { IconConnectedDevices } from './icons/IconConnectedDevices'; +import { IconCopy } from './icons/IconCopy'; +import { IconCreditCard } from './icons/IconCreditCard'; +import { IconCustomize } from './icons/IconCustomize'; +import { IconDarkTheme } from './icons/IconDarkTheme'; +import { IconDebian } from './icons/IconDebian'; +import { IconDelete } from './icons/IconDelete'; +import { IconDeploy } from './icons/IconDeploy'; +import { IconDesktop } from './icons/IconDesktop'; +import { IconDevices } from './icons/IconDevices'; +import { IconDevicesActive } from './icons/IconDevicesActive'; +import { IconDisabled } from './icons/IconDisabled'; +import { IconDisableMfa } from './icons/IconDisableMfa'; +import { IconDisconnectAll } from './icons/IconDisconnectAll'; +import { IconDownload } from './icons/IconDownload'; +import { IconEdit } from './icons/IconEdit'; +import { IconEmptyPoint } from './icons/IconEmptyPoint'; +import { IconEnrollment } from './icons/IconEnrollment'; +import { IconEnter } from './icons/IconEnter'; +import { IconExternalMfa } from './icons/IconExternalMFA'; +import { IconFile } from './icons/IconFile'; +import { IconFileAdd } from './icons/IconFileAdd'; +import { IconFiltration } from './icons/IconFiltration'; +import { IconGateway } from './icons/IconGateway'; +import { IconGithub } from './icons/IconGithub'; +import { IconGlobe } from './icons/IconGlobe'; +import { IconGlobeBlocked } from './icons/IconGlobeBlocked'; +import { IconGroups } from './icons/IconGroups'; +import { IconHamburger } from './icons/IconHamburger'; +import { IconHelp } from './icons/IconHelp'; +import { IconHide } from './icons/IconHide'; +import { IconInfoFilled } from './icons/IconInfoFilled'; +import { IconInfoOutlined } from './icons/IconInfoOutlined'; +import { IconInternalMfa } from './icons/IconInternalMFA'; +import { IconIpSuggest } from './icons/IconIpSuggest'; +import { IconKey } from './icons/IconKey'; +import { IconLightBulb } from './icons/IconLightBulb'; +import { IconLightTheme } from './icons/IconLightTheme'; +import { IconLinux } from './icons/IconLinux'; +import { IconLoader } from './icons/IconLoader'; +import { IconLocation } from './icons/IconLocation'; +import { IconLocationTracking } from './icons/IconLocationTracking'; +import { IconLockOpen } from './icons/IconLock'; +import { IconLockClosed } from './icons/IconLockClosed'; +import { IconLogout } from './icons/IconLogout'; +import { IconMail } from './icons/IconMail'; +import { IconMenu } from './icons/IconMenu'; +import { IconMinusCircle } from './icons/IconMinusCircle'; +import { IconMobile } from './icons/IconMobile'; +import { IconMobileLock } from './icons/IconMobileLock'; +import { IconNetworkSettings } from './icons/IconNetworkSettings'; +import { IconNotification } from './icons/IconNotification'; +import { IconOneTimePassword } from './icons/IconOneTimePassword'; +import { IconOnline } from './icons/IconOnline'; +import { IconOpenId } from './icons/IconOpenId'; +import { IconOpenInNewWindow } from './icons/IconOpenInNewWindow'; +import { IconPending } from './icons/IconPending'; +import { IconPieChart } from './icons/IconPieChart'; +import { IconPlay } from './icons/IconPlay'; +import { IconPlayFilled } from './icons/IconPlayFilled'; +import { IconPlus } from './icons/IconPlus'; +import { IconPlusCircle } from './icons/IconPlusCircle'; +import { IconProfile } from './icons/IconProfile'; +import { IconProtection } from './icons/IconProtection'; +import { IconQuestion } from './icons/IconQuestion'; +import { IconRefresh } from './icons/IconRefresh'; +import { IconReport } from './icons/IconReport'; +import { IconRequest } from './icons/IconRequest'; +import { IconRules } from './icons/IconRules'; +import { IconSearch } from './icons/IconSearch'; +import { IconServers } from './icons/IconServers'; +import { IconServiceUnavailable } from './icons/IconServiceUnavailable'; +import { IconSettings } from './icons/IconSettings'; +import { IconShow } from './icons/IconShow'; +import { IconSortable } from './icons/IconSortable'; +import { IconStatusAttention } from './icons/IconStatusAttention'; +import { IconStatusAvailable } from './icons/IconStatusAvailable'; +import { IconStatusImportant } from './icons/IconStatusImportant'; +import { IconStatusPremium } from './icons/IconStatusPremium'; +import { IconStatusSimple } from './icons/IconStatusSimple'; +import { IconSupport } from './icons/IconSupport'; +import { IconSync } from './icons/IconSync'; +import { IconToken } from './icons/IconToken'; +import { IconTransactions } from './icons/IconTransactions'; +import { IconTutorial } from './icons/IconTutorial'; +import { IconTutorialNotAvailable } from './icons/IconTutorialNotAvailable'; +import { IconUbuntu } from './icons/IconUbuntu'; +import { IconUpload } from './icons/IconUpload'; +import { IconUser } from './icons/IconUser'; +import { IconUserActive } from './icons/IconUserActive'; +import { IconUsers } from './icons/IconUsers'; +import { IconWarningFilled } from './icons/IconWarningFilled'; +import { IconWarningOutlined } from './icons/IconWarningOutlined'; +import { IconWebhooks } from './icons/IconWebhooks'; +import { IconWindows } from './icons/IconWindows'; + +type Props = { + icon: T; + staticColor?: ThemeVariableValue; + size?: number; + rotationDirection?: DirectionValue; + customRotation?: number; + ref?: Ref; + className?: string; +}; + +type RotationMap = Record; + +const mapRotation = (kind: IconKindValue, direction: DirectionValue): number => { + switch (kind) { + case 'arrow-small': + case 'arrow-big': { + const map: RotationMap = { + down: 90, + right: 0, + up: -90, + left: 180, + }; + return map[direction]; + } + } + console.error(`Unimplemented rotation mapping for icon kind of ${kind}`); + // safe return for unimplemented + return 0; +}; + +const EmptyIcon = () => { + return null; +}; + +// Color should be set by css bcs some icons have different structures like 'loader' +export const Icon = ({ + icon: iconKind, + rotationDirection, + customRotation, + ref, + className, + staticColor, + size, +}: Props) => { + const IconToRender = useMemo(() => { + switch (iconKind) { + case 'mobile-lock': + return IconMobileLock; + case 'sync': + return IconSync; + case 'attention-filled': + return IconAttentionFilled; + case 'ip-suggest': + return IconIpSuggest; + case 'filtration': + return IconFiltration; + case 'rules': + return IconRules; + case 'add-rule': + return IconAddRule; + case 'add-alias': + return IconAddAlias; + case 'aliases': + return IconAliases; + case 'upload': + return IconUpload; + case 'lock-closed': + return IconLockClosed; + case 'enrollment': + return IconEnrollment; + case 'customize': + return IconCustomize; + case 'light-theme': + return IconLightTheme; + case 'dark-theme': + return IconDarkTheme; + case 'refresh': + return IconRefresh; + case 'network-settings': + return IconNetworkSettings; + case 'connected-devices': + return IconConnectedDevices; + case 'external-mfa': + return IconExternalMfa; + case 'internal-mfa': + return IconInternalMfa; + case 'token': + return IconToken; + case 'add-location': + return IconAddLocation; + case 'add-group': + return IconAddGroup; + case 'add-token': + return IconAddToken; + case 'online': + return IconOnline; + case 'key': + return IconKey; + case 'add-device': + return IconAddDevice; + case 'warning-filled': + return IconWarningFilled; + case 'warning-outlined': + return IconWarningOutlined; + case 'ubuntu': + return IconUbuntu; + case 'debian': + return IconDebian; + case 'arch-linux': + return IconArchLinux; + case 'disabled': + return IconDisabled; + case 'disable-mfa': + return IconDisableMfa; + case 'show': + return IconShow; + case 'hide': + return IconHide; + case 'copy': + return IconCopy; + case 'config': + return IconConfig; + case 'open-in-new-window': + return IconOpenInNewWindow; + case 'arrow-big': + return IconArrowBig; + case 'arrow-small': + return IconArrowSmall; + case 'loader': + return IconLoader; + case 'plus': + return IconPlus; + case 'status-simple': + return IconStatusSimple; + case 'lock-open': + return IconLockOpen; + case 'check-circle': + return IconCheckCircle; + case 'check-filled': + return IconCheckFilled; + case 'empty-point': + return IconEmptyPoint; + case 'desktop': + return IconDesktop; + case 'mobile': + return IconMobile; + case 'windows': + return IconWindows; + case 'linux': + return IconLinux; + case 'app-store': + return IconAppStore; + case 'apple': + return IconApple; + case 'android': + return IconAndroid; + case 'close': + return IconClose; + case 'file': + return IconFile; + case 'file-add': + return IconFileAdd; + case 'globe': + return IconGlobe; + case 'globe-blocked': + return IconGlobeBlocked; + case 'service-unavailable': + return IconServiceUnavailable; + case 'help': + return IconHelp; + case 'access-settings': + return IconAccessSettings; + case 'activity': + return IconActivity; + case 'activity-notes': + return IconActivityNotes; + case 'add-user': + return IconAddUser; + case 'analytics': + return IconAnalytics; + case 'archive': + return EmptyIcon; + case 'attention-outlined': + return IconAttentionOutlined; + case 'check': + return IconCheck; + case 'clear': + return IconClear; + case 'code': + return IconCode; + case 'collapse': + return EmptyIcon; + case 'credit-card': + return IconCreditCard; + case 'date': + return EmptyIcon; + case 'delete': + return IconDelete; + case 'deploy': + return IconDeploy; + case 'devices': + return IconDevices; + case 'devices-active': + return IconDevicesActive; + case 'download': + return IconDownload; + case 'edit': + return IconEdit; + case 'enter': + return IconEnter; + case 'expand': + return EmptyIcon; + case 'filter': + return EmptyIcon; + case 'gateway': + return IconGateway; + case 'gift': + return EmptyIcon; + case 'github': + return IconGithub; + case 'groups': + return IconGroups; + case 'hamburger': + return IconHamburger; + case 'info-filled': + return IconInfoFilled; + case 'info-outlined': + return IconInfoOutlined; + case 'location': + return IconLocation; + case 'location-preview': + return EmptyIcon; + case 'location-tracking': + return IconLocationTracking; + case 'logout': + return IconLogout; + case 'mail': + return IconMail; + case 'manage-keys': + return EmptyIcon; + case 'menu': + return IconMenu; + case 'minus-circle': + return IconMinusCircle; + case 'navigation-collapse': + return EmptyIcon; + case 'navigation-uncollapse': + return EmptyIcon; + case 'notification': + return IconNotification; + case 'one-time-password': + return IconOneTimePassword; + case 'openid': + return IconOpenId; + case 'pdf': + return EmptyIcon; + case 'pie-chart': + return IconPieChart; + case 'plus-circle': + return IconPlusCircle; + case 'profile': + return IconProfile; + case 'protection': + return IconProtection; + case 'qr': + return EmptyIcon; + case 'search': + return IconSearch; + case 'servers': + return IconServers; + case 'settings': + return IconSettings; + case 'sort': + return EmptyIcon; + case 'sortable': + return IconSortable; + case 'status-premium': + return IconStatusPremium; + case 'status-attention': + return IconStatusAttention; + case 'status-available': + return IconStatusAvailable; + case 'status-important': + return IconStatusImportant; + case 'support': + return IconSupport; + case 'transactions': + return IconTransactions; + case 'user': + return IconUser; + case 'user-active': + return IconUserActive; + case 'users': + return IconUsers; + case 'webhooks': + return IconWebhooks; + case 'yubi-keys': + return EmptyIcon; + case 'biometric': + return IconBiometric; + case 'pending': + return IconPending; + case 'bug': + return IconBug; + case 'chat': + return IconChat; + case 'request': + return IconRequest; + case 'question': + return IconQuestion; + case 'calendar': + return IconCalendar; + case 'light-bulb': + return IconLightBulb; + case 'tutorial': + return IconTutorial; + case 'tutorial-not-available': + return IconTutorialNotAvailable; + case 'authorised-app': + return IconAuthorisedApp; + case 'play': + return IconPlay; + case 'play-filled': + return IconPlayFilled; + case 'disconnect-all': + return IconDisconnectAll; + case 'report': + return IconReport; + } + }, [iconKind]); + + const getStyle = useMemo((): CSSProperties => { + const styles: CSSProperties = {}; + if (isPresent(staticColor)) { + // @ts-expect-error + styles['--icon-color'] = staticColor; + } + const transform: string[] = []; + // kind specific configurations + switch (iconKind) { + case 'arrow-big': + case 'arrow-small': + if (rotationDirection) { + transform.push(`rotate(${mapRotation(iconKind, rotationDirection)}deg)`); + } + break; + } + if (customRotation && !rotationDirection) { + transform.push(`rotate(${customRotation}deg)`); + } + if (size) { + styles.width = size; + styles.height = size; + } + if (transform.length) { + styles.transform = transform.join(' '); + } + return styles; + }, [iconKind, size, rotationDirection, customRotation, staticColor]); + + return ( +
+ +
+ ); +}; diff --git a/new-ui/src/shared/components/Icon/icon-types.ts b/new-ui/src/shared/components/Icon/icon-types.ts new file mode 100644 index 000000000..b84ddb884 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icon-types.ts @@ -0,0 +1,145 @@ +export const IconKind = { + DisconnectAll: 'disconnect-all', + MobileLock: 'mobile-lock', + IpSuggest: 'ip-suggest', + Filtration: 'filtration', + AddAlias: 'add-alias', + Aliases: 'aliases', + Customize: 'customize', + NetworkSettings: 'network-settings', + AddGroup: 'add-group', + AddToken: 'add-token', + Key: 'key', + Biometric: 'biometric', + Hide: 'hide', + ArrowBig: 'arrow-big', + ArrowSmall: 'arrow-small', + PlusCircle: 'plus-circle', + MinusCircle: 'minus-circle', + Edit: 'edit', + Show: 'show', + Analytics: 'analytics', + Search: 'search', + Delete: 'delete', + Transactions: 'transactions', + Enrollment: 'enrollment', + Copy: 'copy', + Settings: 'settings', + Close: 'close', + Plus: 'plus', + Support: 'support', + Menu: 'menu', + Sync: 'sync', + Pending: 'pending', + Check: 'check', + Date: 'date', + CreditCard: 'credit-card', + Archive: 'archive', + PieChart: 'pie-chart', + Notification: 'notification', + Globe: 'globe', + GlobeBlocked: 'globe-blocked', + ServiceUnavailable: 'service-unavailable', + Groups: 'groups', + OpenInNewWindow: 'open-in-new-window', + Users: 'users', + Mail: 'mail', + Filter: 'filter', + User: 'user', + LockOpen: 'lock-open', + LockClosed: 'lock-closed', + Servers: 'servers', + Protection: 'protection', + NavigationCollapse: 'navigation-collapse', + NavigationUncollapse: 'navigation-uncollapse', + Devices: 'devices', + Logout: 'logout', + YubiKeys: 'yubi-keys', + OpenId: 'openid', + Webhooks: 'webhooks', + Help: 'help', + ActivityNotes: 'activity-notes', + Activity: 'activity', + AccessSettings: 'access-settings', + Profile: 'profile', + AttentionOutlined: 'attention-outlined', + AttentionFilled: 'attention-filled', + WarningOutlined: 'warning-outlined', + Download: 'download', + Code: 'code', + Deploy: 'deploy', + Expand: 'expand', + Collapse: 'collapse', + CheckCircle: 'check-circle', + Location: 'location', + InfoOutlined: 'info-outlined', + InfoFilled: 'info-filled', + LocationPreview: 'location-preview', + AddUser: 'add-user', + QR: 'qr', + File: 'file', + FileAdd: 'file-add', + LocationTracking: 'location-tracking', + Config: 'config', + Gift: 'gift', + Hamburger: 'hamburger', + Sort: 'sort', + Sortable: 'sortable', + Gateway: 'gateway', + EmptyPoint: 'empty-point', + DevicesActive: 'devices-active', + UserActive: 'user-active', + Windows: 'windows', + AppStore: 'app-store', + Apple: 'apple', + Desktop: 'desktop', + Mobile: 'mobile', + Android: 'android', + Pdf: 'pdf', + Linux: 'linux', + Clear: 'clear', + CheckFilled: 'check-filled', + Enter: 'enter', + Github: 'github', + OneTimePassword: 'one-time-password', + Loader: 'loader', + ManageKeys: 'manage-keys', + StatusSimple: 'status-simple', + StatusAttention: 'status-attention', + StatusAvailable: 'status-available', + StatusImportant: 'status-important', + StatusPremium: 'status-premium', + Disabled: 'disabled', + ArchLinux: 'arch-linux', + Debian: 'debian', + Ubuntu: 'ubuntu', + AddDevice: 'add-device', + Token: 'token', + AddLocation: 'add-location', + InternalMFA: 'internal-mfa', + ExternalMFa: 'external-mfa', + ConnectedDevices: 'connected-devices', + Refresh: 'refresh', + Online: 'online', + LightTheme: 'light-theme', + DarkTheme: 'dark-theme', + WarningFilled: 'warning-filled', + Upload: 'upload', + AddRule: 'add-rule', + Rules: 'rules', + DisableMfa: 'disable-mfa', + Bug: 'bug', + Chat: 'chat', + Request: 'request', + Calendar: 'calendar', + LightBulb: 'light-bulb', + Tutorial: 'tutorial', + TutorialNotAvailable: 'tutorial-not-available', + AuthorisedApp: 'authorised-app', + Play: 'play', + PlayFilled: 'play-filled', + Question: 'question', + Report: 'report', +} as const; + +export type IconKindValue = (typeof IconKind)[keyof typeof IconKind]; diff --git a/new-ui/src/shared/components/Icon/icons/IconAccessSettings.tsx b/new-ui/src/shared/components/Icon/icons/IconAccessSettings.tsx new file mode 100644 index 000000000..5cb508a04 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAccessSettings.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconAccessSettings = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconActivity.tsx b/new-ui/src/shared/components/Icon/icons/IconActivity.tsx new file mode 100644 index 000000000..28091c682 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconActivity.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconActivity = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconActivityNotes.tsx b/new-ui/src/shared/components/Icon/icons/IconActivityNotes.tsx new file mode 100644 index 000000000..37935b98f --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconActivityNotes.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconActivityNotes = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAddAlias.tsx b/new-ui/src/shared/components/Icon/icons/IconAddAlias.tsx new file mode 100644 index 000000000..3e50787b8 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAddAlias.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconAddAlias = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAddDevice.tsx b/new-ui/src/shared/components/Icon/icons/IconAddDevice.tsx new file mode 100644 index 000000000..5a77a88ce --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAddDevice.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconAddDevice = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAddGroup.tsx b/new-ui/src/shared/components/Icon/icons/IconAddGroup.tsx new file mode 100644 index 000000000..61585b2fc --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAddGroup.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconAddGroup = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAddLocation.tsx b/new-ui/src/shared/components/Icon/icons/IconAddLocation.tsx new file mode 100644 index 000000000..cb009a8d9 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAddLocation.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconAddLocation = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAddRule.tsx b/new-ui/src/shared/components/Icon/icons/IconAddRule.tsx new file mode 100644 index 000000000..4a2273290 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAddRule.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconAddRule = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAddToken.tsx b/new-ui/src/shared/components/Icon/icons/IconAddToken.tsx new file mode 100644 index 000000000..e1017c5ef --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAddToken.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconAddToken = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAddUser.tsx b/new-ui/src/shared/components/Icon/icons/IconAddUser.tsx new file mode 100644 index 000000000..d9072e447 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAddUser.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconAddUser = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAliases.tsx b/new-ui/src/shared/components/Icon/icons/IconAliases.tsx new file mode 100644 index 000000000..1e68e1acb --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAliases.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconAliases = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAnalytics.tsx b/new-ui/src/shared/components/Icon/icons/IconAnalytics.tsx new file mode 100644 index 000000000..90814a03f --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAnalytics.tsx @@ -0,0 +1,16 @@ +export const IconAnalytics = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAndroid.tsx b/new-ui/src/shared/components/Icon/icons/IconAndroid.tsx new file mode 100644 index 000000000..bf37eaa56 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAndroid.tsx @@ -0,0 +1,31 @@ +import type { SVGProps } from 'react'; + +export const IconAndroid = (props: SVGProps) => { + return ( + + + + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconApple.tsx b/new-ui/src/shared/components/Icon/icons/IconApple.tsx new file mode 100644 index 000000000..d0bb87aef --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconApple.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react'; + +export const IconApple = (props: SVGProps) => { + return ( + + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAppstore.tsx b/new-ui/src/shared/components/Icon/icons/IconAppstore.tsx new file mode 100644 index 000000000..1f75861a3 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAppstore.tsx @@ -0,0 +1,47 @@ +import type { SVGProps } from 'react'; + +export const IconAppStore = (props: SVGProps) => { + return ( + + + + + + + + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconArchLinux.tsx b/new-ui/src/shared/components/Icon/icons/IconArchLinux.tsx new file mode 100644 index 000000000..ab1de5bec --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconArchLinux.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconArchLinux = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconArrowBig.tsx b/new-ui/src/shared/components/Icon/icons/IconArrowBig.tsx new file mode 100644 index 000000000..3d80a8518 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconArrowBig.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +export const IconArrowBig = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconArrowSmall.tsx b/new-ui/src/shared/components/Icon/icons/IconArrowSmall.tsx new file mode 100644 index 000000000..4092fa37e --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconArrowSmall.tsx @@ -0,0 +1,20 @@ +import type { SVGProps } from 'react'; + +export const IconArrowSmall = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAttentionFilled.tsx b/new-ui/src/shared/components/Icon/icons/IconAttentionFilled.tsx new file mode 100644 index 000000000..875a07208 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAttentionFilled.tsx @@ -0,0 +1,16 @@ +export const IconAttentionFilled = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAttentionOutlined.tsx b/new-ui/src/shared/components/Icon/icons/IconAttentionOutlined.tsx new file mode 100644 index 000000000..fd3ed62ab --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAttentionOutlined.tsx @@ -0,0 +1,16 @@ +export const IconAttentionOutlined = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconAuthorisedApp.tsx b/new-ui/src/shared/components/Icon/icons/IconAuthorisedApp.tsx new file mode 100644 index 000000000..d1994d0a5 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconAuthorisedApp.tsx @@ -0,0 +1,16 @@ +export const IconAuthorisedApp = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconBiometric.tsx b/new-ui/src/shared/components/Icon/icons/IconBiometric.tsx new file mode 100644 index 000000000..f84219583 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconBiometric.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconBiometric = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconBug.tsx b/new-ui/src/shared/components/Icon/icons/IconBug.tsx new file mode 100644 index 000000000..dab4f86ca --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconBug.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconBug = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconCalendar.tsx b/new-ui/src/shared/components/Icon/icons/IconCalendar.tsx new file mode 100644 index 000000000..d3078da9e --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconCalendar.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconCalendar = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconChat.tsx b/new-ui/src/shared/components/Icon/icons/IconChat.tsx new file mode 100644 index 000000000..2b3a636b0 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconChat.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconChat = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconCheck.tsx b/new-ui/src/shared/components/Icon/icons/IconCheck.tsx new file mode 100644 index 000000000..580b0728d --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconCheck.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconCheck = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconCheckCircle.tsx b/new-ui/src/shared/components/Icon/icons/IconCheckCircle.tsx new file mode 100644 index 000000000..9a94792cf --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconCheckCircle.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconCheckCircle = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconCheckFilled.tsx b/new-ui/src/shared/components/Icon/icons/IconCheckFilled.tsx new file mode 100644 index 000000000..0fc9b0909 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconCheckFilled.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconCheckFilled = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconClear.tsx b/new-ui/src/shared/components/Icon/icons/IconClear.tsx new file mode 100644 index 000000000..4cda92d4f --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconClear.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconClear = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconClose.tsx b/new-ui/src/shared/components/Icon/icons/IconClose.tsx new file mode 100644 index 000000000..cb8e9d1a1 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconClose.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconClose = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconCode.tsx b/new-ui/src/shared/components/Icon/icons/IconCode.tsx new file mode 100644 index 000000000..b0b763705 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconCode.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconCode = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconConfig.tsx b/new-ui/src/shared/components/Icon/icons/IconConfig.tsx new file mode 100644 index 000000000..12886c2f0 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconConfig.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconConfig = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconConnectedDevices.tsx b/new-ui/src/shared/components/Icon/icons/IconConnectedDevices.tsx new file mode 100644 index 000000000..d87efe7f9 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconConnectedDevices.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconConnectedDevices = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconCopy.tsx b/new-ui/src/shared/components/Icon/icons/IconCopy.tsx new file mode 100644 index 000000000..eb5706fa6 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconCopy.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconCopy = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconCreditCard.tsx b/new-ui/src/shared/components/Icon/icons/IconCreditCard.tsx new file mode 100644 index 000000000..e2004ac39 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconCreditCard.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconCreditCard = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconCustomize.tsx b/new-ui/src/shared/components/Icon/icons/IconCustomize.tsx new file mode 100644 index 000000000..6bbbbf2b2 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconCustomize.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconCustomize = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDarkTheme.tsx b/new-ui/src/shared/components/Icon/icons/IconDarkTheme.tsx new file mode 100644 index 000000000..9ec3eb405 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDarkTheme.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDarkTheme = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDebian.tsx b/new-ui/src/shared/components/Icon/icons/IconDebian.tsx new file mode 100644 index 000000000..74295c5b3 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDebian.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDebian = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDelete.tsx b/new-ui/src/shared/components/Icon/icons/IconDelete.tsx new file mode 100644 index 000000000..3d7b881ad --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDelete.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDelete = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDeploy.tsx b/new-ui/src/shared/components/Icon/icons/IconDeploy.tsx new file mode 100644 index 000000000..d5e20fe4f --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDeploy.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDeploy = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDesktop.tsx b/new-ui/src/shared/components/Icon/icons/IconDesktop.tsx new file mode 100644 index 000000000..335b047cb --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDesktop.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDesktop = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDevices.tsx b/new-ui/src/shared/components/Icon/icons/IconDevices.tsx new file mode 100644 index 000000000..ec5bd61c7 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDevices.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDevices = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDevicesActive.tsx b/new-ui/src/shared/components/Icon/icons/IconDevicesActive.tsx new file mode 100644 index 000000000..2bbe756f2 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDevicesActive.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDevicesActive = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDisableMfa.tsx b/new-ui/src/shared/components/Icon/icons/IconDisableMfa.tsx new file mode 100644 index 000000000..7e823b7bf --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDisableMfa.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDisableMfa = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDisabled.tsx b/new-ui/src/shared/components/Icon/icons/IconDisabled.tsx new file mode 100644 index 000000000..33bb4931a --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDisabled.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDisabled = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDisconnectAll.tsx b/new-ui/src/shared/components/Icon/icons/IconDisconnectAll.tsx new file mode 100644 index 000000000..14b80ec63 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDisconnectAll.tsx @@ -0,0 +1,16 @@ +export const IconDisconnectAll = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconDownload.tsx b/new-ui/src/shared/components/Icon/icons/IconDownload.tsx new file mode 100644 index 000000000..e822c6476 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconDownload.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconDownload = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconEdit.tsx b/new-ui/src/shared/components/Icon/icons/IconEdit.tsx new file mode 100644 index 000000000..021da95e6 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconEdit.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconEdit = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconEmptyPoint.tsx b/new-ui/src/shared/components/Icon/icons/IconEmptyPoint.tsx new file mode 100644 index 000000000..a5b5323bc --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconEmptyPoint.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconEmptyPoint = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconEnrollment.tsx b/new-ui/src/shared/components/Icon/icons/IconEnrollment.tsx new file mode 100644 index 000000000..b0454e35d --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconEnrollment.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconEnrollment = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconEnter.tsx b/new-ui/src/shared/components/Icon/icons/IconEnter.tsx new file mode 100644 index 000000000..c8b375a8b --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconEnter.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconEnter = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconExternalMFA.tsx b/new-ui/src/shared/components/Icon/icons/IconExternalMFA.tsx new file mode 100644 index 000000000..e30fdbc02 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconExternalMFA.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconExternalMfa = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconFile.tsx b/new-ui/src/shared/components/Icon/icons/IconFile.tsx new file mode 100644 index 000000000..0f4266cf2 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconFile.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconFile = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconFileAdd.tsx b/new-ui/src/shared/components/Icon/icons/IconFileAdd.tsx new file mode 100644 index 000000000..7c92896f9 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconFileAdd.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconFileAdd = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconFiltration.tsx b/new-ui/src/shared/components/Icon/icons/IconFiltration.tsx new file mode 100644 index 000000000..e3b64c3ce --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconFiltration.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconFiltration = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconGateway.tsx b/new-ui/src/shared/components/Icon/icons/IconGateway.tsx new file mode 100644 index 000000000..46348328a --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconGateway.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; + +export const IconGateway = (_props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconGithub.tsx b/new-ui/src/shared/components/Icon/icons/IconGithub.tsx new file mode 100644 index 000000000..fc901b720 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconGithub.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from 'react'; + +export const IconGithub = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconGlobe.tsx b/new-ui/src/shared/components/Icon/icons/IconGlobe.tsx new file mode 100644 index 000000000..c7d51e12c --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconGlobe.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconGlobe = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconGlobeBlocked.tsx b/new-ui/src/shared/components/Icon/icons/IconGlobeBlocked.tsx new file mode 100644 index 000000000..c99baf29c --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconGlobeBlocked.tsx @@ -0,0 +1,54 @@ +import type { SVGProps } from 'react'; + +export const IconGlobeBlocked = (props: SVGProps) => { + return ( + + + + + + + + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconGroups.tsx b/new-ui/src/shared/components/Icon/icons/IconGroups.tsx new file mode 100644 index 000000000..376b25f47 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconGroups.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconGroups = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconHamburger.tsx b/new-ui/src/shared/components/Icon/icons/IconHamburger.tsx new file mode 100644 index 000000000..5c54f7421 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconHamburger.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconHamburger = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconHelp.tsx b/new-ui/src/shared/components/Icon/icons/IconHelp.tsx new file mode 100644 index 000000000..602470385 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconHelp.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconHelp = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconHide.tsx b/new-ui/src/shared/components/Icon/icons/IconHide.tsx new file mode 100644 index 000000000..3dbe44a08 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconHide.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconHide = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconInfoFilled.tsx b/new-ui/src/shared/components/Icon/icons/IconInfoFilled.tsx new file mode 100644 index 000000000..38343d480 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconInfoFilled.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconInfoFilled = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconInfoOutlined.tsx b/new-ui/src/shared/components/Icon/icons/IconInfoOutlined.tsx new file mode 100644 index 000000000..ea68ad989 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconInfoOutlined.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconInfoOutlined = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconInternalMFA.tsx b/new-ui/src/shared/components/Icon/icons/IconInternalMFA.tsx new file mode 100644 index 000000000..4bd770667 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconInternalMFA.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconInternalMfa = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconIpSuggest.tsx b/new-ui/src/shared/components/Icon/icons/IconIpSuggest.tsx new file mode 100644 index 000000000..0ca67dce6 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconIpSuggest.tsx @@ -0,0 +1,16 @@ +export const IconIpSuggest = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconKey.tsx b/new-ui/src/shared/components/Icon/icons/IconKey.tsx new file mode 100644 index 000000000..774c8d06f --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconKey.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconKey = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconLightBulb.tsx b/new-ui/src/shared/components/Icon/icons/IconLightBulb.tsx new file mode 100644 index 000000000..097c646e9 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconLightBulb.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconLightBulb = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconLightTheme.tsx b/new-ui/src/shared/components/Icon/icons/IconLightTheme.tsx new file mode 100644 index 000000000..b1fb5827e --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconLightTheme.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconLightTheme = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconLinux.tsx b/new-ui/src/shared/components/Icon/icons/IconLinux.tsx new file mode 100644 index 000000000..f0a86fb38 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconLinux.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from 'react'; + +export const IconLinux = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconLoader.tsx b/new-ui/src/shared/components/Icon/icons/IconLoader.tsx new file mode 100644 index 000000000..117773e4c --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconLoader.tsx @@ -0,0 +1,17 @@ +export const IconLoader = () => { + return ( + + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconLocation.tsx b/new-ui/src/shared/components/Icon/icons/IconLocation.tsx new file mode 100644 index 000000000..fa792b7d1 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconLocation.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconLocation = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconLocationTracking.tsx b/new-ui/src/shared/components/Icon/icons/IconLocationTracking.tsx new file mode 100644 index 000000000..f35049cc0 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconLocationTracking.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconLocationTracking = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconLock.tsx b/new-ui/src/shared/components/Icon/icons/IconLock.tsx new file mode 100644 index 000000000..9bd4ef7fe --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconLock.tsx @@ -0,0 +1,16 @@ +export const IconLockOpen = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconLockClosed.tsx b/new-ui/src/shared/components/Icon/icons/IconLockClosed.tsx new file mode 100644 index 000000000..3ad83281e --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconLockClosed.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconLockClosed = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconLogout.tsx b/new-ui/src/shared/components/Icon/icons/IconLogout.tsx new file mode 100644 index 000000000..675ddd674 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconLogout.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconLogout = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconMail.tsx b/new-ui/src/shared/components/Icon/icons/IconMail.tsx new file mode 100644 index 000000000..1c2b8cfbd --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconMail.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconMail = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconMenu.tsx b/new-ui/src/shared/components/Icon/icons/IconMenu.tsx new file mode 100644 index 000000000..2ca7cd474 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconMenu.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconMenu = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconMinusCircle.tsx b/new-ui/src/shared/components/Icon/icons/IconMinusCircle.tsx new file mode 100644 index 000000000..14285d6c5 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconMinusCircle.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconMinusCircle = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconMobile.tsx b/new-ui/src/shared/components/Icon/icons/IconMobile.tsx new file mode 100644 index 000000000..a9a3c5910 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconMobile.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconMobile = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconMobileLock.tsx b/new-ui/src/shared/components/Icon/icons/IconMobileLock.tsx new file mode 100644 index 000000000..cb4c45892 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconMobileLock.tsx @@ -0,0 +1,16 @@ +export const IconMobileLock = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconNetworkSettings.tsx b/new-ui/src/shared/components/Icon/icons/IconNetworkSettings.tsx new file mode 100644 index 000000000..f7a4b65b4 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconNetworkSettings.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconNetworkSettings = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconNotification.tsx b/new-ui/src/shared/components/Icon/icons/IconNotification.tsx new file mode 100644 index 000000000..bd0c947e7 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconNotification.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconNotification = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconOneTimePassword.tsx b/new-ui/src/shared/components/Icon/icons/IconOneTimePassword.tsx new file mode 100644 index 000000000..a138f9120 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconOneTimePassword.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconOneTimePassword = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconOnline.tsx b/new-ui/src/shared/components/Icon/icons/IconOnline.tsx new file mode 100644 index 000000000..0e63d6a8e --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconOnline.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconOnline = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconOpenId.tsx b/new-ui/src/shared/components/Icon/icons/IconOpenId.tsx new file mode 100644 index 000000000..c23dccb5e --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconOpenId.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconOpenId = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconOpenInNewWindow.tsx b/new-ui/src/shared/components/Icon/icons/IconOpenInNewWindow.tsx new file mode 100644 index 000000000..69514713c --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconOpenInNewWindow.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconOpenInNewWindow = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconPending.tsx b/new-ui/src/shared/components/Icon/icons/IconPending.tsx new file mode 100644 index 000000000..eb0e6e28f --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconPending.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconPending = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconPieChart.tsx b/new-ui/src/shared/components/Icon/icons/IconPieChart.tsx new file mode 100644 index 000000000..5b57078e3 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconPieChart.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconPieChart = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconPlay.tsx b/new-ui/src/shared/components/Icon/icons/IconPlay.tsx new file mode 100644 index 000000000..5f5109463 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconPlay.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; + +export const IconPlay = (_props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconPlayFilled.tsx b/new-ui/src/shared/components/Icon/icons/IconPlayFilled.tsx new file mode 100644 index 000000000..eab194510 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconPlayFilled.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; + +export const IconPlayFilled = (_props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconPlus.tsx b/new-ui/src/shared/components/Icon/icons/IconPlus.tsx new file mode 100644 index 000000000..b375db67c --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconPlus.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +export const IconPlus = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconPlusCircle.tsx b/new-ui/src/shared/components/Icon/icons/IconPlusCircle.tsx new file mode 100644 index 000000000..0bd3e2af1 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconPlusCircle.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconPlusCircle = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconProfile.tsx b/new-ui/src/shared/components/Icon/icons/IconProfile.tsx new file mode 100644 index 000000000..1aea62122 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconProfile.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconProfile = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconProtection.tsx b/new-ui/src/shared/components/Icon/icons/IconProtection.tsx new file mode 100644 index 000000000..11d0c0ced --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconProtection.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconProtection = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconQuestion.tsx b/new-ui/src/shared/components/Icon/icons/IconQuestion.tsx new file mode 100644 index 000000000..20284cc64 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconQuestion.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconQuestion = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconRefresh.tsx b/new-ui/src/shared/components/Icon/icons/IconRefresh.tsx new file mode 100644 index 000000000..782f7958f --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconRefresh.tsx @@ -0,0 +1,28 @@ +import { type SVGProps, useId } from 'react'; + +export const IconRefresh = (props: SVGProps) => { + const id = useId(); + + return ( + + + + + + + + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconReport.tsx b/new-ui/src/shared/components/Icon/icons/IconReport.tsx new file mode 100644 index 000000000..8bbb5bb82 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconReport.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconReport = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconRequest.tsx b/new-ui/src/shared/components/Icon/icons/IconRequest.tsx new file mode 100644 index 000000000..ea84919ea --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconRequest.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconRequest = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconRules.tsx b/new-ui/src/shared/components/Icon/icons/IconRules.tsx new file mode 100644 index 000000000..8137f5e72 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconRules.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconRules = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconSearch.tsx b/new-ui/src/shared/components/Icon/icons/IconSearch.tsx new file mode 100644 index 000000000..8d51c753b --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconSearch.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconSearch = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconServers.tsx b/new-ui/src/shared/components/Icon/icons/IconServers.tsx new file mode 100644 index 000000000..47f90f5fb --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconServers.tsx @@ -0,0 +1,16 @@ +export const IconServers = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconServiceUnavailable.tsx b/new-ui/src/shared/components/Icon/icons/IconServiceUnavailable.tsx new file mode 100644 index 000000000..f409fabcd --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconServiceUnavailable.tsx @@ -0,0 +1,30 @@ +import type { SVGProps } from 'react'; + +export const IconServiceUnavailable = (props: SVGProps) => { + return ( + + + + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconSettings.tsx b/new-ui/src/shared/components/Icon/icons/IconSettings.tsx new file mode 100644 index 000000000..e0408ebd5 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconSettings.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconSettings = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconShow.tsx b/new-ui/src/shared/components/Icon/icons/IconShow.tsx new file mode 100644 index 000000000..54ee682f1 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconShow.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconShow = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconSortable.tsx b/new-ui/src/shared/components/Icon/icons/IconSortable.tsx new file mode 100644 index 000000000..47efe0233 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconSortable.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconSortable = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconStatusAttention.tsx b/new-ui/src/shared/components/Icon/icons/IconStatusAttention.tsx new file mode 100644 index 000000000..48ad14fb9 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconStatusAttention.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconStatusAttention = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconStatusAvailable.tsx b/new-ui/src/shared/components/Icon/icons/IconStatusAvailable.tsx new file mode 100644 index 000000000..c2c6ebb36 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconStatusAvailable.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconStatusAvailable = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconStatusImportant.tsx b/new-ui/src/shared/components/Icon/icons/IconStatusImportant.tsx new file mode 100644 index 000000000..8c1cd791c --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconStatusImportant.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconStatusImportant = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconStatusPremium.tsx b/new-ui/src/shared/components/Icon/icons/IconStatusPremium.tsx new file mode 100644 index 000000000..cceac1440 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconStatusPremium.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconStatusPremium = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconStatusSimple.tsx b/new-ui/src/shared/components/Icon/icons/IconStatusSimple.tsx new file mode 100644 index 000000000..8ca913e8c --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconStatusSimple.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +export const IconStatusSimple = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconSupport.tsx b/new-ui/src/shared/components/Icon/icons/IconSupport.tsx new file mode 100644 index 000000000..edfea7fd5 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconSupport.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconSupport = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconSync.tsx b/new-ui/src/shared/components/Icon/icons/IconSync.tsx new file mode 100644 index 000000000..1383a5fbb --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconSync.tsx @@ -0,0 +1,16 @@ +export const IconSync = () => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconToken.tsx b/new-ui/src/shared/components/Icon/icons/IconToken.tsx new file mode 100644 index 000000000..33e2572e0 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconToken.tsx @@ -0,0 +1,27 @@ +import { type SVGProps, useId } from 'react'; + +export const IconToken = (props: SVGProps) => { + const id = useId(); + return ( + + + + + + + + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconTransactions.tsx b/new-ui/src/shared/components/Icon/icons/IconTransactions.tsx new file mode 100644 index 000000000..0b3039c46 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconTransactions.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconTransactions = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconTutorial.tsx b/new-ui/src/shared/components/Icon/icons/IconTutorial.tsx new file mode 100644 index 000000000..387953fdc --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconTutorial.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; + +export const IconTutorial = (_props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconTutorialNotAvailable.tsx b/new-ui/src/shared/components/Icon/icons/IconTutorialNotAvailable.tsx new file mode 100644 index 000000000..dccae3c67 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconTutorialNotAvailable.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; + +export const IconTutorialNotAvailable = (_props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconUbuntu.tsx b/new-ui/src/shared/components/Icon/icons/IconUbuntu.tsx new file mode 100644 index 000000000..4279ff447 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconUbuntu.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconUbuntu = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconUpload.tsx b/new-ui/src/shared/components/Icon/icons/IconUpload.tsx new file mode 100644 index 000000000..b7760ba64 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconUpload.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconUpload = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconUser.tsx b/new-ui/src/shared/components/Icon/icons/IconUser.tsx new file mode 100644 index 000000000..8658cba76 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconUser.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconUser = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconUserActive.tsx b/new-ui/src/shared/components/Icon/icons/IconUserActive.tsx new file mode 100644 index 000000000..b27531714 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconUserActive.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconUserActive = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconUsers.tsx b/new-ui/src/shared/components/Icon/icons/IconUsers.tsx new file mode 100644 index 000000000..a8dbcd0d0 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconUsers.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconUsers = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconWarningFilled.tsx b/new-ui/src/shared/components/Icon/icons/IconWarningFilled.tsx new file mode 100644 index 000000000..ba4a994b8 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconWarningFilled.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconWarningFilled = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconWarningOutlined.tsx b/new-ui/src/shared/components/Icon/icons/IconWarningOutlined.tsx new file mode 100644 index 000000000..12dd4dca7 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconWarningOutlined.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconWarningOutlined = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconWebhooks.tsx b/new-ui/src/shared/components/Icon/icons/IconWebhooks.tsx new file mode 100644 index 000000000..b47e332ed --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconWebhooks.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconWebhooks = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/icons/IconWindows.tsx b/new-ui/src/shared/components/Icon/icons/IconWindows.tsx new file mode 100644 index 000000000..773876cc5 --- /dev/null +++ b/new-ui/src/shared/components/Icon/icons/IconWindows.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconWindows = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/Icon/index.ts b/new-ui/src/shared/components/Icon/index.ts new file mode 100644 index 000000000..a9ebdc3e2 --- /dev/null +++ b/new-ui/src/shared/components/Icon/index.ts @@ -0,0 +1,3 @@ +export { Icon } from './Icon'; +export type { IconKindValue } from './icon-types'; +export { IconKind } from './icon-types'; diff --git a/new-ui/src/shared/components/Icon/style.scss b/new-ui/src/shared/components/Icon/style.scss new file mode 100644 index 000000000..2f60dd6f4 --- /dev/null +++ b/new-ui/src/shared/components/Icon/style.scss @@ -0,0 +1,47 @@ +.icon { + display: inline-block; + overflow: hidden; + user-select: none; + transition-property: transform; + width: var(--icon-size); + height: var(--icon-size); + + @include animate; + + svg { + width: inherit; + height: inherit; + + path { + @include animate(fill); + } + + circle { + @include animate(stroke); + } + } +} + +.icon svg { + path { + fill: var(--icon-color, var(--fg-white-100)); + } + + circle { + stroke: var(--icon-color, var(--fg-white-100)); + } +} + +.icon[style*='--icon-color'] svg path { + fill: var(--icon-color, var(--fg-white-100)); +} + +.icon[style*='--icon-color'] svg circle { + stroke: var(--icon-color, var(--fg-white-100)); +} + +.icon svg.stroke-icon path, +.icon[style*='--icon-color'] svg.stroke-icon path { + fill: none; + stroke: var(--icon-color, var(--c-white-100)); +} diff --git a/new-ui/src/shared/components/IconButton/IconButton.tsx b/new-ui/src/shared/components/IconButton/IconButton.tsx new file mode 100644 index 000000000..db35312b2 --- /dev/null +++ b/new-ui/src/shared/components/IconButton/IconButton.tsx @@ -0,0 +1,30 @@ +import './style.scss'; +import clsx from 'clsx'; +import { Icon } from '../Icon/Icon'; +import { type IconButtonProps, IconButtonVariant } from './types'; + +export const IconButton = ({ + icon, + ref, + iconRotation, + className, + variant = IconButtonVariant.Big, + disabled = false, + onClick, +}: IconButtonProps) => { + return ( +
{ + if (!disabled) { + onClick?.(e); + } + }} + role="button" + aria-disabled={disabled} + > + +
+ ); +}; diff --git a/new-ui/src/shared/components/IconButton/style.scss b/new-ui/src/shared/components/IconButton/style.scss new file mode 100644 index 000000000..93d90de6d --- /dev/null +++ b/new-ui/src/shared/components/IconButton/style.scss @@ -0,0 +1,82 @@ +.icon-button { + --size: 36px; + --bg: transparent; + --icon: var(--c-white-80); + --icon-size: 20px; + + border-radius: 8px; + height: var(--size); + width: var(--size); + display: inline-flex; + flex-flow: column; + align-items: center; + justify-content: center; + background: var(--bg); + cursor: pointer; + + @include animate(background); + + svg { + --icon-color: var(--icon); + } + + &.variant { + &-big, + &-big-selected { + --size: 36px; + --icon-size: 20px; + } + + &-small, + &-small-selected { + --size: 24px; + --icon-size: 16px; + } + + &-big { + --bg: transparent; + --icon: var(--c-white-80); + + &:hover { + --bg: var(--c-white-5); + --icon: var(--c-white-100); + } + } + + &-big-selected { + --bg: var(--c-white-10); + --icon: var(--c-white-100); + + &:hover { + --bg: var(--c-white-20); + --icon: var(--c-white-100); + } + } + + &-small { + --bg: var(--c-white-5); + --icon: var(--c-white-80); + + &:hover { + --bg: var(--c-white-10); + --icon: var(--c-white-100); + } + } + + &-small-selected { + --bg: var(--c-white-10); + --icon: var(--c-white-100); + + &:hover { + --bg: var(--c-white-20); + --icon: var(--c-white-100); + } + } + } + + &.disabled { + cursor: not-allowed; + pointer-events: none; + --icon: var(--c-white-30); + } +} diff --git a/new-ui/src/shared/components/IconButton/types.ts b/new-ui/src/shared/components/IconButton/types.ts new file mode 100644 index 000000000..d66697a47 --- /dev/null +++ b/new-ui/src/shared/components/IconButton/types.ts @@ -0,0 +1,23 @@ +import type { MouseEventHandler, Ref } from 'react'; +import type { DirectionValue } from '../../types'; +import type { IconKindValue } from '../Icon/icon-types'; + +export const IconButtonVariant = { + Big: 'big', + BigSelected: 'big-selected', + Small: 'small', + SmallSelected: 'small-selected', +} as const; + +export type IconButtonVariantValue = + (typeof IconButtonVariant)[keyof typeof IconButtonVariant]; + +export type IconButtonProps = { + variant: IconButtonVariantValue; + icon: IconKindValue; + iconRotation?: DirectionValue; + disabled?: boolean; + ref?: Ref; + className?: string; + onClick?: MouseEventHandler; +}; diff --git a/new-ui/src/shared/components/IconButtonMenu/IconButtonMenu.tsx b/new-ui/src/shared/components/IconButtonMenu/IconButtonMenu.tsx new file mode 100644 index 000000000..bc27974e6 --- /dev/null +++ b/new-ui/src/shared/components/IconButtonMenu/IconButtonMenu.tsx @@ -0,0 +1,85 @@ +import { + autoUpdate, + FloatingPortal, + flip, + offset, + shift, + size, + useClick, + useDismiss, + useFloating, + useInteractions, +} from '@floating-ui/react'; +import clsx from 'clsx'; +import { useState } from 'react'; +import { mergeRefs } from '../../utils/mergeRefs'; +import { IconButton } from '../IconButton/IconButton'; +import type { IconButtonProps } from '../IconButton/types'; +import { Menu } from '../Menu/Menu'; +import type { MenuItemsGroup } from '../Menu/types'; + +export const IconButtonMenu = ({ + menuItems, + ref, + className, + ...buttonProps +}: IconButtonProps & { + menuItems: MenuItemsGroup[]; +}) => { + const [isOpen, setOpen] = useState(false); + const { refs, context, floatingStyles } = useFloating({ + placement: 'bottom-end', + whileElementsMounted: autoUpdate, + onOpenChange: setOpen, + open: isOpen, + middleware: [ + offset(4), + shift(), + flip(), + size({ + apply({ rects, elements, availableHeight }) { + const refWidth = `${rects.reference.width}px`; + elements.floating.style.minWidth = refWidth; + elements.floating.style.maxHeight = `${availableHeight - 10}px`; + }, + }), + ], + }); + const click = useClick(context, { + toggle: true, + }); + + const dismiss = useDismiss(context, { + ancestorScroll: true, + escapeKey: true, + outsidePress: (event) => !(event.target as HTMLElement).closest('.menu'), + }); + + const { getFloatingProps, getReferenceProps } = useInteractions([click, dismiss]); + + return ( + <> + + {isOpen && ( + + { + setOpen(false); + }} + {...getFloatingProps()} + /> + + )} + + ); +}; diff --git a/new-ui/src/shared/components/InfoBanner/InfoBanner.tsx b/new-ui/src/shared/components/InfoBanner/InfoBanner.tsx new file mode 100644 index 000000000..68351cd8d --- /dev/null +++ b/new-ui/src/shared/components/InfoBanner/InfoBanner.tsx @@ -0,0 +1,23 @@ +import './style.scss'; +import { ThemeVariable } from '../../types'; +import { Icon, IconKind, type IconKindValue } from '../Icon'; + +interface Props { + icon?: IconKindValue; + message: string; +} + +export const InfoBanner = ({ message, icon = IconKind.InfoFilled }: Props) => { + return ( +
+
+
+ +
+
+

{message}

+
+
+
+ ); +}; diff --git a/new-ui/src/shared/components/InfoBanner/style.scss b/new-ui/src/shared/components/InfoBanner/style.scss new file mode 100644 index 000000000..74e49a121 --- /dev/null +++ b/new-ui/src/shared/components/InfoBanner/style.scss @@ -0,0 +1,33 @@ +.info-banner { + border-radius: 12px; + box-sizing: border-box; + padding: var(--spacing-md); + background-color: var(--bg-white-10); + + > .grid { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + grid-template-rows: 1fr; + column-gap: var(--spacing-md); + place-items: start start; + + .icon-track { + display: flex; + flex-flow: column; + align-items: flex-start; + justify-content: flex-start; + user-select: none; + } + + .content-track { + display: flex; + flex-flow: column; + row-gap: var(--spacing-md); + + p { + font: var(--t-body-xs-500); + color: var(--fg-white-100); + } + } + } +} diff --git a/new-ui/src/shared/components/Input/Input.tsx b/new-ui/src/shared/components/Input/Input.tsx new file mode 100644 index 000000000..fdb6e6271 --- /dev/null +++ b/new-ui/src/shared/components/Input/Input.tsx @@ -0,0 +1,161 @@ +import { type HTMLInputTypeAttribute, useId, useMemo, useRef, useState } from 'react'; +import './style.scss'; +import clsx from 'clsx'; +import { isNumber } from 'radashi'; +import { isPresent } from '../../utils/isPresent'; +import { mergeRefs } from '../../utils/mergeRefs'; +import { FieldBox } from '../FieldBox/FieldBox'; +import { FieldError } from '../FieldError/FieldError'; +import { FieldLabel } from '../FieldLabel/FieldLabel'; +import { Icon } from '../Icon'; +import type { IconKindValue } from '../Icon/icon-types'; +import type { InputProps } from './types'; + +const externalValueToInput = (value: string | null | number): string | number => { + if (value === null) return ''; + return value; +}; + +const preferredTypeToInternal = (value: InputProps['type']): HTMLInputTypeAttribute => { + if (!isPresent(value)) return 'text'; + + switch (value) { + case 'search': + return 'text'; + case 'number': + return 'number'; + default: + return value; + } +}; + +export const Input = ({ + value, + error, + label, + ref, + name, + placeholder, + boxProps, + testId, + onChange, + onBlur, + onFocus, + notNull, + size = 'default', + type = 'text', + required = false, + disabled = false, + autocomplete = 'off', +}: InputProps) => { + const isPassword = useMemo(() => type === 'password', [type]); + + const preferredTypeIsSearch = useMemo(() => type === 'search', [type]); + + const [inputTypeInner, setInputType] = useState( + preferredTypeToInternal(type), + ); + + const innerRef = useRef(null); + const id = useId(); + + const interactionIconRight = useMemo((): IconKindValue | undefined => { + if (typeof value === 'string') { + // allow clear action for search + if (value?.length && preferredTypeIsSearch) { + return 'clear'; + } + // toggle show / hide for password + if (isPassword) { + if (inputTypeInner === 'password') { + return 'show'; + } else { + return 'hide'; + } + } + } + }, [isPassword, inputTypeInner, value, preferredTypeIsSearch]); + + return ( +
+
+ {isPresent(label) && ( + { + innerRef.current?.focus(); + }} + /> + )} + { + innerRef.current?.focus(); + }} + iconLeft={preferredTypeIsSearch ? : undefined} + iconRight={ + interactionIconRight ? : undefined + } + reserveInteraction={preferredTypeIsSearch} + onInteractionClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + // clear + if (preferredTypeIsSearch) { + onChange?.(''); + } + if (isPassword) { + setInputType((s) => { + if (s === 'password') { + return 'text'; + } + return 'password'; + }); + } + }} + {...boxProps} + > + { + if (isPresent(onChange)) { + let changeValue: string | null | number = e.target.value; + // allows nulls to be typed directly to form state + if (changeValue === '' && !notNull && !required) { + changeValue = null; + } else { + if (inputTypeInner === 'number') { + const parsed = parseInt(changeValue, 10); + if (!isNumber(parsed)) return; + changeValue = parsed; + } + } + onChange(changeValue); + } + }} + /> + + +
+
+ ); +}; diff --git a/new-ui/src/shared/components/Input/style.scss b/new-ui/src/shared/components/Input/style.scss new file mode 100644 index 000000000..f902571ce --- /dev/null +++ b/new-ui/src/shared/components/Input/style.scss @@ -0,0 +1,52 @@ +.input { + & > .inner { + box-sizing: border-box; + + &.error { + padding-bottom: var(--form-field-error-space); + } + + .field-label { + cursor: pointer; + user-select: none; + } + + .input-track { + --input-font: var(--t-input-text-primary); + + &.size-lg { + --input-font: var(--t-input-text-big); + } + + input { + font: var(--input-font); + color: var(--fg-white-100); + border: none; + background: none; + border-radius: 0; + width: 100%; + max-width: 100%; + overflow: hidden; + margin: 0; + padding: 0; + + &::placeholder { + font: var(--input-font); + color: var(--fg-white-50); + } + + &:disabled { + cursor: not-allowed; + } + } + } + + &.disabled { + user-select: none; + + & > .field-label { + cursor: not-allowed; + } + } + } +} diff --git a/new-ui/src/shared/components/Input/types.ts b/new-ui/src/shared/components/Input/types.ts new file mode 100644 index 000000000..8bcdd92fc --- /dev/null +++ b/new-ui/src/shared/components/Input/types.ts @@ -0,0 +1,41 @@ +import type { + HTMLAttributes, + HTMLInputAutoCompleteAttribute, + MouseEventHandler, + Ref, +} from 'react'; +import type { FieldBoxProps, FieldSize } from '../FieldBox/types'; + +export type InputProps = { + value: string | null | number; + size?: FieldSize; + type?: 'password' | 'text' | 'search' | 'number'; + ref?: Ref; + error?: string | null; + name?: string; + label?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; + onChange?: (value: string | number | null) => void; + boxProps?: Partial; + autocomplete?: HTMLInputAutoCompleteAttribute; + testId?: string; + notNull?: boolean; +} & Pick, 'onBlur' | 'onFocus'>; + +export type FormInputProps = Pick< + InputProps, + | 'name' + | 'placeholder' + | 'disabled' + | 'required' + | 'label' + | 'autocomplete' + | 'size' + | 'type' + | 'notNull' +> & { + mapError?: (error: string) => string | undefined; + onDismiss?: MouseEventHandler; +}; diff --git a/new-ui/src/shared/components/InteractionBox/InteractionBox.tsx b/new-ui/src/shared/components/InteractionBox/InteractionBox.tsx new file mode 100644 index 000000000..b29b64b7c --- /dev/null +++ b/new-ui/src/shared/components/InteractionBox/InteractionBox.tsx @@ -0,0 +1,47 @@ +import { type MouseEventHandler, type PropsWithChildren, type Ref, useMemo } from 'react'; +import './style.scss'; +import clsx from 'clsx'; + +type Props = { + interactionSize?: number; + onClick?: MouseEventHandler; + id?: string; + className?: string; + ref?: Ref; + disabled?: boolean; + tabIndex?: number; +}; + +export const InteractionBox = ({ + onClick, + className, + id, + ref, + tabIndex, + interactionSize, + disabled = false, + children, +}: Props & PropsWithChildren) => { + const style = useMemo(() => { + const res: Record = {}; + if (interactionSize) { + res['--interaction-size'] = `${interactionSize}px`; + } + return res; + }, [interactionSize]); + + return ( +
+ {children} + +
+ ); +}; diff --git a/new-ui/src/shared/components/InteractionBox/style.scss b/new-ui/src/shared/components/InteractionBox/style.scss new file mode 100644 index 000000000..18f2694d9 --- /dev/null +++ b/new-ui/src/shared/components/InteractionBox/style.scss @@ -0,0 +1,31 @@ +.interaction-box { + --interaction-size: 36px; + + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: center; + flex: none; + position: relative; + user-select: none; + + & > button { + display: block; + position: absolute; + content: ' '; + width: var(--interaction-size); + height: var(--interaction-size); + background-color: transparent; + border: none; + cursor: pointer; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + padding: 0; + margin: 0; + + &:disabled { + cursor: not-allowed; + } + } +} diff --git a/new-ui/src/shared/components/LoaderSpinner/LoaderSpinner.tsx b/new-ui/src/shared/components/LoaderSpinner/LoaderSpinner.tsx new file mode 100644 index 000000000..20e2bbabf --- /dev/null +++ b/new-ui/src/shared/components/LoaderSpinner/LoaderSpinner.tsx @@ -0,0 +1,24 @@ +import clsx from 'clsx'; +import { Icon } from '../Icon'; +import './style.scss'; +import { useMemo } from 'react'; + +type Props = { + size?: number; + variant?: 'empty' | 'primary'; +}; + +export const LoaderSpinner = ({ size = 20, variant }: Props) => { + const variantClass = useMemo(() => (variant ? `variant-${variant}` : null), [variant]); + return ( +
+ +
+ ); +}; diff --git a/new-ui/src/shared/components/LoaderSpinner/style.scss b/new-ui/src/shared/components/LoaderSpinner/style.scss new file mode 100644 index 000000000..c538dc820 --- /dev/null +++ b/new-ui/src/shared/components/LoaderSpinner/style.scss @@ -0,0 +1,38 @@ +.loader-spinner { + display: inline-block; + user-select: none; + + &.variant-primary { + --spinner-track: var(--c-white-30); + --spinner-indicator: var(--c-white-100); + } + + & > .icon { + animation: spin 1s ease-in-out infinite; + } + + svg { + & > path { + stroke: var(--spinner-indicator); + fill: unset !important; + + @include animate(stroke); + } + + & > circle { + stroke: var(--spinner-track); + + @include animate(stroke); + } + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} diff --git a/new-ui/src/shared/components/LocationCard/LocationCard.tsx b/new-ui/src/shared/components/LocationCard/LocationCard.tsx new file mode 100644 index 000000000..35a08166e --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/LocationCard.tsx @@ -0,0 +1,93 @@ +import './style.scss'; +import clsx from 'clsx'; +import type { ReactNode } from 'react'; +import type { InstanceInfo, LocationInfo } from '../../rust-api/types'; +import { Direction } from '../../types'; +import { Fold } from '../Fold/Fold'; +import { IconKind } from '../Icon'; +import { IconButton } from '../IconButton/IconButton'; +import { IconButtonVariant } from '../IconButton/types'; +import { LocationCardHeaderInfo } from './components/LocationCardHeaderInfo/LocationCardHeaderInfo'; +import { LocationCardProvider, useLocationCardContext } from './context/context'; +import { LocationCardViews, type LocationCardViewsValue } from './context/types'; +import { ConnectedView } from './views/ConnectedView/ConnectedView'; +import { DefaultView } from './views/DefaultView/DefaultView'; +import { LocationCardConnectionErrorView } from './views/LocationCardConnectionErrorView/LocationCardConnectionErrorView'; +import { LocationCardMfaEmailView } from './views/LocationCardMfaEmailView/LocationCardMfaEmailView'; +import { LocationCardMfaMobileView } from './views/LocationCardMfaMobileView/LocationCardMfaMobileView'; +import { LocationCardMfaOidcView } from './views/LocationCardMfaOidcView/LocationCardMfaOidcView'; +import { LocationCardMfaSettings } from './views/LocationCardMfaSettings/LocationCardMfaSettings'; +import { LocationCardMfaTotpView } from './views/LocationCardMfaTotpView/LocationCardMfaTotpView'; +import { LocationCardPostureCheckFailView } from './views/LocationCardPostureCheckFailView/LocationCardPostureCheckFailView'; + +interface Props { + location: LocationInfo; + isOpen: boolean; + onOpen: () => void; + disableOpen?: boolean; + instance?: InstanceInfo; +} + +const views: Record = { + [LocationCardViews.Default]: , + [LocationCardViews.MfaTotp]: , + [LocationCardViews.MfaEmail]: , + [LocationCardViews.MfaOidc]: , + [LocationCardViews.MfaMobile]: , + [LocationCardViews.MfaSettings]: , + [LocationCardViews.Connecting]: null, + [LocationCardViews.Connected]: , + [LocationCardViews.PostureCheckFail]: , + [LocationCardViews.ConnectionError]: , +}; + +interface InnerProps { + isOpen: boolean; + onOpen: () => void; + disableOpen: boolean; +} + +const LocationCardInner = ({ isOpen, onOpen, disableOpen }: InnerProps) => { + const { location, currentView } = useLocationCardContext(); + + return ( +
+
+ +
+ {!disableOpen && ( + + )} +
+
+ {views[currentView]} +
+ ); +}; + +export const LocationCard = ({ + location, + isOpen, + onOpen, + instance, + disableOpen = false, +}: Props) => { + return ( + + + + ); +}; diff --git a/new-ui/src/shared/components/LocationCard/api/connectError.ts b/new-ui/src/shared/components/LocationCard/api/connectError.ts new file mode 100644 index 000000000..e500ca060 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/api/connectError.ts @@ -0,0 +1,22 @@ +import z from 'zod'; + +export const DEFAULT_CONNECTION_ERROR = + 'One or more external services are unavailable or unreachable. This may be caused by a network issue or a temporary service outage. Please try again later.'; + +const connectErrorSchema = z.object({ + kind: z.enum([ + 'postureCheckFailed', + 'serviceUnavailable', + 'allTrafficConflict', + 'other', + ]), + message: z.string(), +}); + +export type ConnectError = z.infer; + +export const parseConnectError = (err: unknown): ConnectError | null => { + const result = connectErrorSchema.safeParse(err); + + return result.success ? result.data : null; +}; diff --git a/new-ui/src/shared/components/LocationCard/assets/location_avatar.png b/new-ui/src/shared/components/LocationCard/assets/location_avatar.png new file mode 100644 index 000000000..ce0ce6332 Binary files /dev/null and b/new-ui/src/shared/components/LocationCard/assets/location_avatar.png differ diff --git a/new-ui/src/shared/components/LocationCard/components/ConnectButton/ConnectButton.tsx b/new-ui/src/shared/components/LocationCard/components/ConnectButton/ConnectButton.tsx new file mode 100644 index 000000000..55c63e884 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/ConnectButton/ConnectButton.tsx @@ -0,0 +1,22 @@ +import './style.scss'; +import clsx from 'clsx'; + +interface Props { + active: boolean; + onClick: () => void; + disabled?: boolean; +} + +export const ConnectButton = ({ active, onClick, disabled = false }: Props) => ( + +); diff --git a/new-ui/src/shared/components/LocationCard/components/ConnectButton/style.scss b/new-ui/src/shared/components/LocationCard/components/ConnectButton/style.scss new file mode 100644 index 000000000..146ff21da --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/ConnectButton/style.scss @@ -0,0 +1,52 @@ +.connect-button { + --bg: var(--bg-white-100); + --shadow: 0 4px 5px 0 rgb(53 84 179 / 7%); + --color: var(--fg-action); + --border: var(--bg); + + background: var(--bg); + border: 1px solid var(--border); + color: var(--color); + box-shadow: var(--shadow); + transition-duration: 200ms; + transition-property: background, border-color, box-shadow, color; + transition-timing-function: ease-in-out; + cursor: pointer; + display: inline-flex; + width: 100%; + align-items: center; + justify-content: center; + border-radius: 100px; + min-height: 38px; + box-sizing: border-box; + padding: 0 var(--spacing-lg); + + &:not(.connected):hover { + --bg: var(--bg-white-80); + } + + &.connected { + --bg: transparent; + --color: var(--fg-white-100); + --border: var(--border-default); + + &:hover { + --border: var(--border-default); + --bg: var(--bg-white-10); + } + } + + &:disabled { + cursor: not-allowed; + pointer-events: none; + + --bg: var(--bg-white-20); + --color: var(--fg-white-40); + --border: var(--bg); + } + + p { + font: var(--t-body-sm-600); + color: inherit; + } +} diff --git a/new-ui/src/shared/components/LocationCard/components/ConnectionChart/ConnectionChart.tsx b/new-ui/src/shared/components/LocationCard/components/ConnectionChart/ConnectionChart.tsx new file mode 100644 index 000000000..980714e19 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/ConnectionChart/ConnectionChart.tsx @@ -0,0 +1,101 @@ +import './style.scss'; +import { BarElement, CategoryScale, Chart as ChartJS, LinearScale } from 'chart.js'; +import { sum } from 'radashi'; +import { useMemo } from 'react'; +import { Bar } from 'react-chartjs-2'; +import type { LocationStats } from '../../../../rust-api/types'; +import { BoxIcon } from '../../../BoxIcon/BoxIcon'; +import { Icon, IconKind } from '../../../Icon'; +import { TransferText } from '../../../TransferText/TransferText'; + +ChartJS.register(BarElement, CategoryScale, LinearScale); + +const UPLOAD_COLOR = 'rgba(255, 255, 255, 0.20)'; +const DOWNLOAD_COLOR = 'rgba(255, 255, 255, 1.0)'; + +interface Props { + stats: LocationStats[]; +} + +export const ConnectionChart = ({ stats }: Props) => { + const statsSum = useMemo( + () => ({ + download: sum(stats ?? [], (s) => s.download), + upload: sum(stats ?? [], (s) => s.upload), + }), + [stats], + ); + + const chartData = { + labels: stats?.map((s) => s.collected_at) ?? [], + datasets: [ + { + label: 'upload', + data: stats?.map((s) => s.upload) ?? [], + backgroundColor: UPLOAD_COLOR, + borderWidth: 0, + borderRadius: 0, + categoryPercentage: 0.95, + barPercentage: 1.0, + maxBarThickness: 2.2, + }, + { + label: 'download', + data: stats?.map((s) => s.download) ?? [], + backgroundColor: DOWNLOAD_COLOR, + borderWidth: 0, + borderRadius: 0, + categoryPercentage: 0.95, + barPercentage: 1.0, + maxBarThickness: 2.2, + }, + ], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + animation: false as const, + layout: { padding: 0 }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + scales: { + x: { + display: false, + grid: { display: false }, + border: { display: false }, + }, + y: { + display: false, + grid: { display: false }, + border: { display: false }, + }, + }, + }; + + if (!stats?.length) return null; + + return ( +
+
+ +
+
+
+ + + + +
+
+ + + + +
+
+
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/components/ConnectionChart/style.scss b/new-ui/src/shared/components/LocationCard/components/ConnectionChart/style.scss new file mode 100644 index 000000000..20aeb93c2 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/ConnectionChart/style.scss @@ -0,0 +1,21 @@ +.connection-chart { + > .chart-container { + padding-bottom: var(--spacing-lg); + } + + > .stats-summary { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-3xl); + + > .summary { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-sm); + } + } +} diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx new file mode 100644 index 000000000..6b919bfd7 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx @@ -0,0 +1,68 @@ +import { useMutation } from '@tanstack/react-query'; +import { api } from '../../../rust-api/api'; +import { shouldStartMfa } from '../../../utils/mfa'; +import { parseConnectError } from '../api/connectError'; +import { useLocationCardContext } from '../context/context'; +import { LocationCardViews } from '../context/types'; +import { ConnectButton } from './ConnectButton/ConnectButton'; + +export const LocationCardConnectButton = () => { + const { location, setPostureError, setView, startMfa } = useLocationCardContext(); + + const { mutate: connect, isPending: isConnecting } = useMutation({ + mutationFn: api.connect, + onSuccess: () => { + setView(LocationCardViews.Connected); + }, + onError: (err) => { + const connectError = parseConnectError(err); + + if ( + location.posture_check_required && + connectError?.kind === 'postureCheckFailed' + ) { + setPostureError(connectError.message); + setView(LocationCardViews.PostureCheckFail); + } else if (connectError?.kind === 'allTrafficConflict') { + setView(LocationCardViews.ConnectionError, connectError.message); + } else if (connectError?.kind === 'serviceUnavailable') { + setView(LocationCardViews.ConnectionError); + } + }, + meta: { + invalidate: ['locations'], + }, + }); + + const { mutate: disconnect, isPending: isDisconnecting } = useMutation({ + mutationFn: api.disconnect, + onSuccess: () => { + setView(LocationCardViews.Default); + }, + meta: { + invalidate: ['locations'], + }, + }); + + const isBusy = isConnecting || isDisconnecting; + + const handleClick = () => { + if (location.active) { + disconnect({ + connectionType: location.connection_type, + locationId: location.id, + }); + } else if (shouldStartMfa(location)) { + startMfa(); + } else { + connect({ + connectionType: location.connection_type, + locationId: location.id, + }); + } + }; + + return ( + + ); +}; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionInfo/LocationCardConnectionInfo.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionInfo/LocationCardConnectionInfo.tsx new file mode 100644 index 000000000..3efc4ef3b --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionInfo/LocationCardConnectionInfo.tsx @@ -0,0 +1,107 @@ +import './style.scss'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import { useId, useMemo } from 'react'; +import { api } from '../../../../rust-api/api'; +import { getLocationStatsQueryOptions } from '../../../../rust-api/query'; +import type { LocationInfo } from '../../../../rust-api/types'; +import { ThemeSpacing } from '../../../../types'; +import { SizedBox } from '../../../SizedBox/SizedBox'; +import { ConnectionChart } from '../ConnectionChart/ConnectionChart'; + +export const LocationCardConnectionInfo = ({ location }: { location: LocationInfo }) => { + const { data: stats } = useQuery( + getLocationStatsQueryOptions({ + locationId: location.id, + connectionType: location.connection_type, + }), + ); + + const { data: lastConnection } = useQuery({ + queryKey: ['locations', location.id, 'last-connect'], + queryFn: () => + api.getLastConnection({ + connectionType: location.connection_type, + locationId: location.id, + }), + }); + + const lastConnectedText = useMemo(() => { + if (!lastConnection) return 'Never'; + return dayjs.utc(lastConnection.end).local().format('DD MMM YYYY'); + }, [lastConnection]); + + if (!stats || stats.length === 0) + return ( +
+ + +

{`Traffic data not available`}

+ +

{`Connect once to see the traffic details.`}

+
+ ); + + return ( +
+
+
+
Last connected
+
{lastConnectedText}
+
+
+
Assigned IP
+
{location.address}
+
+
+ + +
+ ); +}; + +const EmptyIcon = () => { + const id = useId(); + return ( + + + + + + + + + + + + + + ); +}; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionInfo/style.scss b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionInfo/style.scss new file mode 100644 index 000000000..cf0287fc1 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionInfo/style.scss @@ -0,0 +1,42 @@ +.location-card-connection-info { + > .connection-info { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + justify-content: space-between; + + > .info { + > .label { + font: var(--t-body-xxs-400); + color: var(--fg-white-50); + padding-bottom: var(--spacing-xs); + } + + > .label-value { + font: var(--t-body-xs-500); + } + } + } +} + +.no-connection-info { + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + user-select: none; + + p { + text-align: center; + } + + .title { + font: var(--t-body-sm-500); + color: var(--fg-white-100); + } + + .description { + font: var(--t-body-xs-400); + color: var(--fg-white-60); + } +} diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/LocationCardConnectionTiles.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/LocationCardConnectionTiles.tsx new file mode 100644 index 000000000..a0331475b --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/LocationCardConnectionTiles.tsx @@ -0,0 +1,58 @@ +import './style.scss'; +import clsx from 'clsx'; +import { useMemo } from 'react'; +import { useAppData } from '../../../../providers/AppDataContext'; +import { + ClientTrafficPolicy, + type InstanceInfo, + type LocationInfo, + LocationMfaMode, +} from '../../../../rust-api/types'; +import { isPresent } from '../../../../utils/isPresent'; +import { mfaToText } from '../../../../utils/mfa'; +import { BoxIcon } from '../../../BoxIcon/BoxIcon'; +import { Icon, IconKind } from '../../../Icon'; + +interface Props { + variant: 'compact' | 'full'; + location: LocationInfo; + instance?: InstanceInfo; +} + +export const LocationCardConnectionTiles = ({ location, instance, variant }: Props) => { + const { connectionMfaMethod } = useAppData(); + + const routeAllTraffic = + location.route_all_traffic || + instance?.client_traffic_policy === ClientTrafficPolicy.ForceAllTraffic; + + const mfaMethod = useMemo(() => { + const key = `${location.connection_type.toLowerCase()}-${location.id}`; + const method = connectionMfaMethod[key]; + return method; + }, [connectionMfaMethod, location.connection_type.toLowerCase, location.id]); + + return ( +
+
+ + + +

Allowed traffic

+

+ {routeAllTraffic ? 'All traffic' : 'Predefined traffic'} +

+
+ {location.location_mfa_mode !== LocationMfaMode.Disabled && + isPresent(mfaMethod) && ( +
+ + + +

Active MFA

+

{mfaToText(mfaMethod)}

+
+ )} +
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/style.scss b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/style.scss new file mode 100644 index 000000000..fb75abefb --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/style.scss @@ -0,0 +1,57 @@ +.location-card-connection-tiles { + &.variant- { + &compact { + display: grid; + grid-template-columns: repeat(2, 1fr); + column-gap: var(--spacing-md); + + .tile { + border-radius: 8px; + padding: var(--spacing-sm) var(--spacing-md); + + > .box-icon { + margin-bottom: 13px; + } + + > .label { + padding-bottom: var(--spacing-xs); + } + } + } + + &full { + display: flex; + flex-flow: row nowrap; + column-gap: var(--spacing-md); + align-items: center; + justify-content: flex-start; + + .tile { + display: inline-flex; + flex-flow: row nowrap; + column-gap: var(--spacing-sm); + padding: var(--spacing-sm); + border-radius: 12px; + align-items: center; + + > .label { + display: none; + } + } + } + } + + .tile { + background: var(--bg-white-5); + box-sizing: border-box; + + > .label { + font: var(--t-body-xxs-400); + color: var(--fg-white-50); + } + + > .label-value { + font: var(--t-body-xs-500); + } + } +} diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardControls/LocationCardControls.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardControls/LocationCardControls.tsx new file mode 100644 index 000000000..a86ee1571 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardControls/LocationCardControls.tsx @@ -0,0 +1,5 @@ +import { Controls } from '../../../Controls/Controls'; + +export const LocationCardControls = () => { + return ; +}; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardHeaderInfo/LocationCardHeaderInfo.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardHeaderInfo/LocationCardHeaderInfo.tsx new file mode 100644 index 000000000..2b6cc9b50 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardHeaderInfo/LocationCardHeaderInfo.tsx @@ -0,0 +1,43 @@ +import './style.scss'; +import { ConnectionType, type LocationInfo } from '../../../../rust-api/types'; +import { ThemeVariable } from '../../../../types'; +import { Icon, IconKind } from '../../../Icon'; +import { LocationCardIcon } from '../LocationCardIcon'; + +interface Props { + location: LocationInfo; + onInfoClick?: () => void; +} + +export const LocationCardHeaderInfo = ({ location, onInfoClick }: Props) => ( +
+ +
+

+ {location.connection_type === ConnectionType.Location ? 'Location' : 'Tunnel'} +

+
+

{location.name}

+ {onInfoClick && ( + + )} + {location.active && ( +
+

Online

+
+ )} +
+
+
+); diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardHeaderInfo/style.scss b/new-ui/src/shared/components/LocationCard/components/LocationCardHeaderInfo/style.scss new file mode 100644 index 000000000..3bb5f3fb9 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardHeaderInfo/style.scss @@ -0,0 +1,57 @@ +.location-card-header-info { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-md); + + .info { + display: flex; + flex-flow: column; + + .label { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + } + + .location-name { + font: var(--t-body-primary-600); + color: var(--fg-white-100); + } + + > .bottom { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-xs); + } + + .info-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; + border: 0; + background: transparent; + cursor: pointer; + } + + .online-badge { + display: inline-block; + box-sizing: border-box; + padding: 1px 4px; + border-radius: 4px; + background-color: #74ffb8; + + p { + font-family: var(--font-family-body); + font-size: 10px; + letter-spacing: 0.1px; + font-weight: 600; + color: #2f50c2; + } + } + } +} diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardIcon.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardIcon.tsx new file mode 100644 index 000000000..3912e2243 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardIcon.tsx @@ -0,0 +1,20 @@ +import cardImage from '../assets/location_avatar.png'; + +export const LocationCardIcon = () => { + return ( +
+ +
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/LocationCardMfaEdit.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/LocationCardMfaEdit.tsx new file mode 100644 index 000000000..5f3724fd2 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/LocationCardMfaEdit.tsx @@ -0,0 +1,32 @@ +import './style.scss'; +import clsx from 'clsx'; +import type { LocationInfo } from '../../../../rust-api/types'; +import { mfaToText } from '../../../../utils/mfa'; +import { IconButton } from '../../../IconButton/IconButton'; +import { IconButtonVariant } from '../../../IconButton/types'; + +interface Props { + variant: 'compact' | 'full'; + location: LocationInfo; + onEdit: () => void; +} + +export const LocationCardMfaEdit = ({ location, onEdit, variant }: Props) => { + if (location.location_mfa_mode === 'disabled' || !location.mfa_method) return null; + + return ( +
+
+

MFA

+
+

{mfaToText(location.mfa_method)}

+ {location.location_mfa_mode === 'internal' && !location.active && ( + + )} +
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/style.scss b/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/style.scss new file mode 100644 index 000000000..4a8d1319f --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/style.scss @@ -0,0 +1,58 @@ +.location-card-mfa-edit { + user-select: none; + + &.variant- { + &full { + display: inline-flex; + flex-flow: row nowrap; + flex-grow: 0; + align-items: center; + justify-content: flex-start; + + .mfa-badge { + margin-right: var(--spacing-sm); + } + + .name { + margin-right: var(--spacing-md); + } + } + + &compact { + display: grid; + grid-template-columns: auto minmax(0, 1fr) 24px; + grid-template-rows: 1fr; + align-items: center; + column-gap: var(--spacing-md); + user-select: none; + } + } + + > .name { + font: var(--t-body-sm-400); + color: var(--fg-white-100); + } + + .mfa-badge { + border-radius: 4px; + box-sizing: border-box; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + justify-content: center; + padding: 0 4px; + min-height: 20px; + width: 36px; + background-color: transparent; + border: 1px solid var(--bg-white-60); + + p { + font: var(--font-family-body); + font-size: 11px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.11px; + color: var(--fg-white-60); + } + } +} diff --git a/new-ui/src/shared/components/LocationCard/components/LocationViewHeader/LocationViewHeader.tsx b/new-ui/src/shared/components/LocationCard/components/LocationViewHeader/LocationViewHeader.tsx new file mode 100644 index 000000000..a45fc9d5b --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationViewHeader/LocationViewHeader.tsx @@ -0,0 +1,15 @@ +import './style.scss'; +import type { PropsWithChildren } from 'react'; + +interface Props extends PropsWithChildren { + title: string; +} + +export const LocationViewHeader = ({ title, children }: Props) => { + return ( +
+

{title}

+ {children} +
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationViewHeader/style.scss b/new-ui/src/shared/components/LocationCard/components/LocationViewHeader/style.scss new file mode 100644 index 000000000..a6563063e --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/LocationViewHeader/style.scss @@ -0,0 +1,15 @@ +.location-card-view-header { + display: flex; + flex-flow: column; + row-gap: var(--spacing-xs); + + p { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + } + + > .title { + font: var(--t-body-sm-500); + color: var(--fg-white-100); + } +} diff --git a/new-ui/src/shared/components/LocationCard/components/MfaSelector/MfaSelector.tsx b/new-ui/src/shared/components/LocationCard/components/MfaSelector/MfaSelector.tsx new file mode 100644 index 000000000..490e2eb02 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/MfaSelector/MfaSelector.tsx @@ -0,0 +1,59 @@ +import './style.scss'; +import clsx from 'clsx'; +import { type HTMLProps, type MouseEventHandler, useMemo } from 'react'; +import type { MfaMethodValue } from '../../../../rust-api/types'; +import { mfaToText } from '../../../../utils/mfa'; +import { Icon, IconKind, type IconKindValue } from '../../../Icon'; + +interface Props { + factor: MfaMethodValue; + selected?: boolean; + isDefault?: boolean; + onClick?: MouseEventHandler; + containerProps?: Omit, 'onClick'>; +} + +export const MfaSelector = ({ + factor, + onClick, + containerProps, + selected = false, + isDefault = false, +}: Props) => { + const iconKind = useMemo((): IconKindValue => { + switch (factor) { + case 'email': + return 'mail'; + case 'mobileapprove': + return 'mobile'; + case 'oidc': + return 'token'; + case 'totp': + return 'lock-closed'; + case 'biometric': + return 'biometric'; + } + }, [factor]); + + return ( +
+ +
+

{mfaToText(factor)}

+ {isDefault && ( +
+

Default

+
+ )} +
+ {selected && } +
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/components/MfaSelector/style.scss b/new-ui/src/shared/components/LocationCard/components/MfaSelector/style.scss new file mode 100644 index 000000000..164325802 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/components/MfaSelector/style.scss @@ -0,0 +1,80 @@ +.mfa-selector { + --bg: transparent; + --border: var(--border-default); + --icon: var(--fg-white-80); + --color: var(--fg-white-80); + --box-shadow: box-shadow: 0 4px 4px 0 rgb(0 0 0 / 0%); + + display: grid; + grid-template-columns: 20px minmax(0, 1fr) 16px; + column-gap: var(--spacing-sm); + background: var(--bg); + border: 1px solid var(--border); + color: var(--color); + user-select: none; + align-items: center; + box-sizing: border-box; + box-shadow: var(--box-shadow); + padding: 0 var(--spacing-md); + min-height: 40px; + border-radius: 8px; + cursor: pointer; + transition-duration: 250ms; + transition-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1); + transition-property: border-color, background, color, box-shadow; + background-clip: padding-box; + + &:hover { + --bg: var(--bg-white-5); + --color: var(--fg-white-100); + --border: var(--border-action-disabled); + --icon: var(--fg-white-100); + } + + &.selected { + --bg: var(--bg-white-10); + --color: var(--fg-white-100); + --border: var(--border-action-disabled); + --icon: var(--fg-white-100); + --box-shadow: box-shadow: 0 4px 4px 0 rgb(0 0 0 / 5%); + } + + > .middle { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-md); + } + + .default-badge { + display: inline-block; + box-sizing: border-box; + padding: 2px 5px; + border-radius: 5px; + background: var(--bg-white-10); + + p { + font-family: var(--font-family-body); + font-size: 11px; + font-weight: 400; + line-height: normal; + letter-spacing: -0.11px; + } + } + + .factor-icon { + --icon-color: var(--icon); + + path { + transition-duration: 250ms; + transition-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1); + transition-property: fill; + } + } + + .name { + color: inherit; + font: var(--t-body-sm-400); + } +} diff --git a/new-ui/src/shared/components/LocationCard/context/context.tsx b/new-ui/src/shared/components/LocationCard/context/context.tsx new file mode 100644 index 000000000..044d62c38 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/context/context.tsx @@ -0,0 +1,161 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { useAppData } from '../../../providers/AppDataContext'; +import { api } from '../../../rust-api/api'; +import type { InstanceInfo, LocationInfo } from '../../../rust-api/types'; +import { ConnectionType, MfaMethod, type MfaMethodValue } from '../../../rust-api/types'; +import { useAppStore } from '../../../store/useAppStore'; +import { isPresent } from '../../../utils/isPresent'; +import { LocationCardViews, type LocationCardViewsValue } from './types'; + +interface LocationCardContextValue { + location: LocationInfo; + instance?: InstanceInfo; + currentView: LocationCardViewsValue; + previousView: LocationCardViewsValue | null; + postureError: string | null; + connectionError: string | null; + autoConnectOpenid: boolean; + mfaMethod: MfaMethodValue; + setMfaMethod: (value: MfaMethodValue) => void; + setView: (view: LocationCardViewsValue, connectionError?: string) => void; + setPostureError: (error: string | null) => void; + startMfa: () => void; +} + +const LocationCardContext = createContext(null); + +export const useLocationCardContext = (): LocationCardContextValue => { + const ctx = useContext(LocationCardContext); + if (!ctx) { + throw new Error('useLocationCardContext must be used within a LocationCardProvider'); + } + return ctx; +}; + +interface LocationCardProviderProps { + instance?: InstanceInfo; + location: LocationInfo; + children: ReactNode; +} + +export const LocationCardProvider = ({ + location, + instance, + children, +}: LocationCardProviderProps) => { + const conTypeSetOnce = useRef(false); + const mfaStarted = useRef(false); + const { connectionMfaMethod, setConnectionMethod } = useAppData(); + const [autoConnectOpenid, setAutoConnectOpenid] = useState(false); + const [previousView, setPreviousView] = useState(null); + const [postureError, setPostureError] = useState(null); + const [connectionError, setConnectionError] = useState(null); + const [currentView, setCurrentView] = useState( + location.active ? LocationCardViews.Connected : LocationCardViews.Default, + ); + const [mfaMethod, setMfaMethod] = useState( + location.mfa_method ?? MfaMethod.Totp, + ); + + // Other location updates must not undo an optimistic connection transition. + // biome-ignore lint/correctness/useExhaustiveDependencies: synchronize only on active state + useEffect(() => { + if (location.active) { + setCurrentView(LocationCardViews.Connected); + } else { + setMfaMethod(location.mfa_method ?? MfaMethod.Totp); + setCurrentView(LocationCardViews.Default); + } + }, [location.active]); + + const setView = useCallback( + (view: LocationCardViewsValue, connectionError?: string) => { + setPreviousView(currentView); + setCurrentView(view); + setConnectionError(connectionError ?? null); + }, + [currentView], + ); + + const startMfa = useCallback(async () => { + mfaStarted.current = true; + const appConfig = await api.getAppConfig(); + setAutoConnectOpenid(appConfig.auto_start_openid_mfa); + switch (mfaMethod) { + case MfaMethod.Totp: + setView(LocationCardViews.MfaTotp); + break; + case MfaMethod.Email: + setView(LocationCardViews.MfaEmail); + break; + case MfaMethod.Oidc: + setView(LocationCardViews.MfaOidc); + break; + case MfaMethod.MobileApprove: + setView(LocationCardViews.MfaMobile); + break; + } + }, [setView, mfaMethod]); + + const mfaAutoStartRequested = useAppStore( + (s) => s.mfaAutoStartLocationId === location.id, + ); + useEffect(() => { + if ( + mfaAutoStartRequested && + location.connection_type === ConnectionType.Location && + !location.active + ) { + useAppStore.setState({ mfaAutoStartLocationId: null }); + void startMfa(); + } + }, [mfaAutoStartRequested, location.connection_type, location.active, startMfa]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: side-effect on location.active + useEffect(() => { + if ( + location.active && + location.connection_type !== ConnectionType.Tunnel && + !conTypeSetOnce.current + ) { + const key = `${location.connection_type.toLowerCase()}-${location.id}`; + if (mfaStarted.current || !isPresent(connectionMfaMethod[key])) { + conTypeSetOnce.current = true; + setConnectionMethod(location.id, location.connection_type, mfaMethod); + } + } + if (!location.active) { + conTypeSetOnce.current = false; + mfaStarted.current = false; + } + }, [location.active]); + + return ( + + {children} + + ); +}; diff --git a/new-ui/src/shared/components/LocationCard/context/types.ts b/new-ui/src/shared/components/LocationCard/context/types.ts new file mode 100644 index 000000000..007abfe48 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/context/types.ts @@ -0,0 +1,15 @@ +export const LocationCardViews = { + Default: 'default', + MfaTotp: 'mfa-totp', + MfaEmail: 'mfa-email', + MfaOidc: 'mfa-oidc', + MfaMobile: 'mfa-mobile', + MfaSettings: 'mfa-settings', + Connecting: 'connecting', + Connected: 'connected', + PostureCheckFail: 'posture-check-fail', + ConnectionError: 'connection-error', +} as const; + +export type LocationCardViewsValue = + (typeof LocationCardViews)[keyof typeof LocationCardViews]; diff --git a/new-ui/src/shared/components/LocationCard/hooks/useMfaConnect.ts b/new-ui/src/shared/components/LocationCard/hooks/useMfaConnect.ts new file mode 100644 index 000000000..699ff946e --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/hooks/useMfaConnect.ts @@ -0,0 +1,121 @@ +import { useQuery } from '@tanstack/react-query'; +import { error } from '@tauri-apps/plugin-log'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '../../../rust-api/api'; +import { + isConnectFailure, + isInvalidCode, + isMfaPostureError, + isServiceUnavailable, + isSessionExpired, + mfaErrorMessage, +} from '../../../rust-api/mfaError'; +import { getInstancesQueryOptions } from '../../../rust-api/query'; +import type { LocationInfo, MfaMethod } from '../../../rust-api/types'; + +type CodeMfaMethod = typeof MfaMethod.Totp | typeof MfaMethod.Email; + +type UseMfaConnectOptions = { + debounceMs?: number; + onConnected?: () => void; + onSessionExpired?: () => void; + onPostureError?: (message: string) => void; + onServiceUnavailable?: () => void; +}; + +const waitForMinimumDuration = async (startedAt: number, minimumMs: number) => { + const remainingMs = Math.max(minimumMs - (performance.now() - startedAt), 0); + if (remainingMs === 0) return; + + await new Promise((resolve) => window.setTimeout(resolve, remainingMs)); +}; + +export const useMfaConnect = ( + location: LocationInfo, + method: CodeMfaMethod, + { + debounceMs = 0, + onConnected, + onSessionExpired, + onPostureError, + onServiceUnavailable, + }: UseMfaConnectOptions = {}, +) => { + const [token, setToken] = useState(null); + const [isStarting, setIsStarting] = useState(debounceMs > 0); + const [startError, setStartError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const [verifyError, setVerifyError] = useState(null); + + const { data: instances } = useQuery(getInstancesQueryOptions); + + const instance = instances?.find((i) => i.id === location.instance_id); + + // Fire the /start request exactly once when instance data is ready. + const startCalled = useRef(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional one-shot trigger via startCalled ref + useEffect(() => { + if (!instance || startCalled.current) return; + + startCalled.current = true; + const startedAt = performance.now(); + + setIsStarting(true); + + (async () => { + try { + const info = await api.mfaStart(instance.id, location.id, method); + await waitForMinimumDuration(startedAt, debounceMs); + setToken(info.token); + } catch (err) { + void error(`MFA start failed: ${err}`); + await waitForMinimumDuration(startedAt, debounceMs); + if (isMfaPostureError(err, location)) { + onPostureError?.(mfaErrorMessage(err)); + return; + } + if (isServiceUnavailable(err)) { + onServiceUnavailable?.(); + return; + } + setStartError(mfaErrorMessage(err)); + } finally { + setIsStarting(false); + } + })(); + }, [instance]); + + const verifyCode = useCallback( + async (code: string) => { + if (!token || !instance) return; + + setIsVerifying(true); + setVerifyError(null); + + try { + // mfaFinishCode completes MFA and brings up the connection in the + // backend; the preshared key never reaches the frontend. + await api.mfaFinishCode(instance.id, location.id, token, code); + onConnected?.(); + } catch (err) { + void error(`MFA verification failed: ${err}`); + const message = mfaErrorMessage(err); + if (isConnectFailure(message)) { + setVerifyError('Failed to establish VPN connection'); + } else if (isInvalidCode(message)) { + setVerifyError('Invalid code'); + } else if (isSessionExpired(message)) { + onSessionExpired?.(); + } else { + setVerifyError('Verification failed'); + } + } finally { + setIsVerifying(false); + } + }, + [token, instance, location, onConnected, onSessionExpired], + ); + + return { token, isStarting, startError, verifyCode, isVerifying, verifyError }; +}; diff --git a/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts b/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts new file mode 100644 index 000000000..e1a5f72b6 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts @@ -0,0 +1,189 @@ +import { encode } from '@stablelib/base64'; +import { useQuery } from '@tanstack/react-query'; +import type { UnlistenFn } from '@tauri-apps/api/event'; +import { listen } from '@tauri-apps/api/event'; +import { error } from '@tauri-apps/plugin-log'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '../../../rust-api/api'; +import { + isConnectFailure, + isMfaPostureError, + isServiceUnavailable, + mfaErrorMessage, +} from '../../../rust-api/mfaError'; +import { getInstancesQueryOptions } from '../../../rust-api/query'; +import type { LocationInfo, MfaErrorPayload } from '../../../rust-api/types'; +import { MfaMethod, TauriEvent } from '../../../rust-api/types'; + +type TokenData = { + token: string; + challenge: string; +}; + +type Options = { + onConnected?: () => void; + onPostureError?: (message?: string) => void; + onServiceUnavailable?: () => void; +}; + +export const useMfaMobileConnect = (location: LocationInfo, options?: Options) => { + const { onConnected, onPostureError, onServiceUnavailable } = options ?? {}; + + const { data: instances } = useQuery(getInstancesQueryOptions); + const instance = instances?.find((i) => i.id === location.instance_id); + + const [isStarting, setIsStarting] = useState(false); + const [startError, setStartError] = useState(null); + const [tokenData, setTokenData] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [connectionError, setConnectionError] = useState(null); + + const taskIdRef = useRef(null); + const unlistenRef = useRef(null); + + const cleanupListeners = useCallback(() => { + if (unlistenRef.current !== null) { + unlistenRef.current(); + unlistenRef.current = null; + } + }, []); + + // Clean up on unmount + useEffect(() => { + return () => { + cleanupListeners(); + const taskId = taskIdRef.current; + if (taskId) { + void api.cancelMfa(taskId).catch(() => {}); + } + }; + }, [cleanupListeners]); + + // Connect WebSocket via Rust when tokenData is available + useEffect(() => { + if (!tokenData || !instance) return; + + let cancelled = false; + cleanupListeners(); + setIsConnecting(true); + setConnectionError(null); + + (async () => { + try { + const taskId = await api.mfaConnectMobileApprove( + instance.id, + location.id, + tokenData.token, + ); + if (cancelled) { + void api.cancelMfa(taskId).catch(() => {}); + return; + } + taskIdRef.current = taskId; + + // The backend brings up the connection itself; completion means connected. + const completeUnlisten = await listen(TauriEvent.MfaMobileComplete, () => { + cleanupListeners(); + setIsConnecting(false); + onConnected?.(); + }); + + const errorUnlisten = await listen( + TauriEvent.MfaMobileError, + (event) => { + cleanupListeners(); + setIsConnecting(false); + error( + `Mobile MFA failed for location ${location.id}: ${event.payload.error}`, + ); + const message = mfaErrorMessage(event.payload.error); + setConnectionError( + isConnectFailure(message) + ? 'Failed to establish VPN connection' + : 'Connection error. Please try again.', + ); + }, + ); + + unlistenRef.current = () => { + completeUnlisten(); + errorUnlisten(); + }; + } catch (e) { + if (!cancelled) { + setIsConnecting(false); + setConnectionError('Failed to start mobile approval. Please try again.'); + error(`Mobile MFA connect failed for location ${location.id}: ${e}`); + } + } + })(); + + return () => { + cancelled = true; + cleanupListeners(); + setIsConnecting(false); + }; + }, [tokenData, instance, location, onConnected, cleanupListeners]); + + const qrValue = useMemo(() => { + if (!tokenData || !instance) return null; + const json = JSON.stringify({ + token: tokenData.token, + challenge: tokenData.challenge, + instance_id: instance.uuid, + }); + return encode(new TextEncoder().encode(json)); + }, [tokenData, instance]); + + const start = useCallback(async () => { + if (!instance) { + setStartError('Instance not found'); + return; + } + + setIsStarting(true); + setStartError(null); + setConnectionError(null); + // Clear previous task via effect + setTokenData(null); + + try { + const info = await api.mfaStart(instance.id, location.id, MfaMethod.MobileApprove); + if (!info.challenge) { + setStartError('Unsupported response from proxy'); + return; + } + + setTokenData({ token: info.token, challenge: info.challenge }); + } catch (e) { + void error(`Mobile MFA start failed for location ${location.id}: ${e}`); + if (isMfaPostureError(e, location)) { + onPostureError?.(mfaErrorMessage(e)); + return; + } + if (isServiceUnavailable(e)) { + onServiceUnavailable?.(); + return; + } + setStartError(mfaErrorMessage(e)); + } finally { + setIsStarting(false); + } + }, [instance, location, onPostureError, onServiceUnavailable]); + + const reset = useCallback(() => { + cleanupListeners(); + const taskId = taskIdRef.current; + if (taskId) { + void api.cancelMfa(taskId).catch(() => {}); + taskIdRef.current = null; + } + setTokenData(null); + setIsStarting(false); + setStartError(null); + setIsConnecting(false); + setConnectionError(null); + }, [cleanupListeners]); + + return { start, isStarting, startError, qrValue, isConnecting, connectionError, reset }; +}; diff --git a/new-ui/src/shared/components/LocationCard/hooks/useMfaOidcConnect.ts b/new-ui/src/shared/components/LocationCard/hooks/useMfaOidcConnect.ts new file mode 100644 index 000000000..7e28f80f7 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/hooks/useMfaOidcConnect.ts @@ -0,0 +1,122 @@ +import { useQuery } from '@tanstack/react-query'; +import type { UnlistenFn } from '@tauri-apps/api/event'; +import { listen } from '@tauri-apps/api/event'; +import { error } from '@tauri-apps/plugin-log'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '../../../rust-api/api'; +import { + isConnectFailure, + isMfaPostureError, + isServiceUnavailable, + isSessionExpired, + isTimeout, + mfaErrorMessage, +} from '../../../rust-api/mfaError'; +import { getInstancesQueryOptions } from '../../../rust-api/query'; +import type { MfaErrorPayload } from '../../../rust-api/types'; +import { MfaMethod, TauriEvent } from '../../../rust-api/types'; +import { useLocationCardContext } from '../context/context'; +import { LocationCardViews } from '../context/types'; + +export const useMfaOidcConnect = () => { + const { location, setPostureError, setView } = useLocationCardContext(); + + const [isStarting, setIsStarting] = useState(false); + const [startError, setStartError] = useState(null); + const [isPolling, setIsPolling] = useState(false); + const [pollError, setPollError] = useState(null); + + const { data: instances } = useQuery(getInstancesQueryOptions); + const instance = instances?.find((i) => i.id === location.instance_id); + + const taskIdRef = useRef(null); + const unlistenRef = useRef(null); + + const cleanup = useCallback(() => { + if (unlistenRef.current !== null) { + unlistenRef.current(); + unlistenRef.current = null; + } + }, []); + + // Clean up on unmount + useEffect(() => { + return () => { + cleanup(); + const taskId = taskIdRef.current; + if (taskId) { + void api.cancelMfa(taskId).catch(() => {}); + } + }; + }, [cleanup]); + + const start = useCallback(async () => { + if (!instance) { + setStartError('Instance not found'); + return; + } + + setIsStarting(true); + setStartError(null); + setPollError(null); + cleanup(); + + try { + const info = await api.mfaStart(instance.id, location.id, MfaMethod.Oidc); + await api.openLink(`${instance.proxy_url}openid/mfa?token=${info.token}`); + + setIsStarting(false); + setIsPolling(true); + + const taskId = await api.mfaPollOpenId(instance.id, location.id, info.token); + taskIdRef.current = taskId; + + // The backend brings up the connection itself; completion means connected. + const completeUnlisten = await listen(TauriEvent.MfaOpenIdComplete, () => { + cleanup(); + setIsPolling(false); + setView(LocationCardViews.Connected); + }); + + const errorUnlisten = await listen( + TauriEvent.MfaOpenIdError, + (event) => { + cleanup(); + setIsPolling(false); + error(`OIDC MFA failed for location ${location.id}: ${event.payload.error}`); + const message = mfaErrorMessage(event.payload.error); + if (isTimeout(event.payload.error)) { + setPollError('Authentication timed out. Please try again.'); + } else if (isConnectFailure(message)) { + setPollError('Failed to establish VPN connection'); + } else if (isSessionExpired(message)) { + setPollError('Session expired. Please try again.'); + } else { + setPollError('Authentication failed. Please try again.'); + } + }, + ); + + unlistenRef.current = () => { + completeUnlisten(); + errorUnlisten(); + }; + } catch (e) { + void error(`OIDC MFA start failed for location ${location.id}: ${e}`); + if (isMfaPostureError(e, location)) { + setPostureError(mfaErrorMessage(e)); + setView(LocationCardViews.PostureCheckFail); + return; + } + if (isServiceUnavailable(e)) { + setView(LocationCardViews.ConnectionError); + return; + } + setStartError(mfaErrorMessage(e)); + } finally { + setIsStarting(false); + } + }, [instance, location, setPostureError, setView, cleanup]); + + return { start, isStarting, startError, isPolling, pollError }; +}; diff --git a/new-ui/src/shared/components/LocationCard/images/NoConnectionIcon.tsx b/new-ui/src/shared/components/LocationCard/images/NoConnectionIcon.tsx new file mode 100644 index 000000000..042cc62c0 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/images/NoConnectionIcon.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react'; + +export const NoConnectionIcon = (props: SVGProps) => ( + + + + +); diff --git a/new-ui/src/shared/components/LocationCard/style.scss b/new-ui/src/shared/components/LocationCard/style.scss new file mode 100644 index 000000000..0708b9347 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/style.scss @@ -0,0 +1,33 @@ +.location-card { + border-radius: 16px; + box-sizing: border-box; + padding: var(--spacing-lg); + background-color: var(--bg-dark-blue-40); + + > .top-track { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + user-select: none; + + &.interactive { + cursor: pointer; + + &:hover { + > .right > .icon-button { + --bg: var(--c-white-20); + --icon: var(--c-white-100); + } + } + } + + > .right { + margin-left: auto; + } + } + + .controls { + padding-top: var(--spacing-3xl); + } +} diff --git a/new-ui/src/shared/components/LocationCard/views/ConnectedView/ConnectedView.tsx b/new-ui/src/shared/components/LocationCard/views/ConnectedView/ConnectedView.tsx new file mode 100644 index 000000000..7b1b0c30e --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/ConnectedView/ConnectedView.tsx @@ -0,0 +1,26 @@ +import { ThemeSpacing } from '../../../../types'; +import { Divider } from '../../../Divider/Divider'; +import { SizedBox } from '../../../SizedBox/SizedBox'; +import { LocationCardConnectButton } from '../../components/LocationCardConnectButton'; +import { LocationCardConnectionInfo } from '../../components/LocationCardConnectionInfo/LocationCardConnectionInfo'; +import { LocationCardConnectionTiles } from '../../components/LocationCardConnectionTiles/LocationCardConnectionTiles'; +import { useLocationCardContext } from '../../context/context'; + +export const ConnectedView = () => { + const { location, instance } = useLocationCardContext(); + + return ( +
+ + + + + + +
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx b/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx new file mode 100644 index 000000000..f9784c119 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx @@ -0,0 +1,69 @@ +import { useMutation } from '@tanstack/react-query'; +import { Fragment } from 'react/jsx-runtime'; +import { api } from '../../../../rust-api/api'; +import { + ClientTrafficPolicy, + LocationMfaMode, + MfaMethod, +} from '../../../../rust-api/types'; +import { ThemeSpacing } from '../../../../types'; +import { Divider } from '../../../Divider/Divider'; +import { SizedBox } from '../../../SizedBox/SizedBox'; +import { Toggle } from '../../../Toggle/Toggle'; +import { LocationCardConnectButton } from '../../components/LocationCardConnectButton'; +import { LocationCardMfaEdit } from '../../components/LocationCardMfaEdit/LocationCardMfaEdit'; +import { useLocationCardContext } from '../../context/context'; +import { LocationCardViews } from '../../context/types'; + +export const DefaultView = () => { + const { location, instance, setView } = useLocationCardContext(); + + const mfaMethod = location.mfa_method ?? MfaMethod.Totp; + + const { mutate: updateRouting } = useMutation({ + mutationFn: api.updateLocationRouting, + meta: { + invalidate: ['locations'], + }, + }); + + return ( +
+ {(instance?.client_traffic_policy === ClientTrafficPolicy.None || !instance) && ( + + + { + updateRouting({ + connectionType: location.connection_type, + locationId: location.id, + routeAllTraffic: !location.route_all_traffic, + }); + }} + /> + + )} + {location.location_mfa_mode !== LocationMfaMode.Disabled && mfaMethod && ( + + + { + setView(LocationCardViews.MfaSettings); + }} + /> + + )} + + +
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/LocationCardConnectionErrorView.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/LocationCardConnectionErrorView.tsx new file mode 100644 index 000000000..428dc667c --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/LocationCardConnectionErrorView.tsx @@ -0,0 +1,31 @@ +import './style.scss'; +import { ThemeSpacing } from '../../../../types'; +import { Button } from '../../../Button/Button'; +import { ButtonVariant } from '../../../Button/types'; +import { Divider } from '../../../Divider/Divider'; +import { Icon, IconKind } from '../../../Icon'; +import { SizedBox } from '../../../SizedBox/SizedBox'; +import { DEFAULT_CONNECTION_ERROR } from '../../api/connectError'; +import { useLocationCardContext } from '../../context/context'; +import { LocationCardViews } from '../../context/types'; + +export const LocationCardConnectionErrorView = () => { + const { setView, connectionError } = useLocationCardContext(); + + return ( +
+ + + + +

{connectionError ?? DEFAULT_CONNECTION_ERROR}

+ +
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/style.scss b/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/style.scss new file mode 100644 index 000000000..076a82814 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/style.scss @@ -0,0 +1,11 @@ +.location-card-connection-error-view { + display: flex; + flex-direction: column; + align-items: center; + + .description { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + text-align: center; + } +} diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaEmailView/LocationCardMfaEmailView.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaEmailView/LocationCardMfaEmailView.tsx new file mode 100644 index 000000000..4d56aa240 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaEmailView/LocationCardMfaEmailView.tsx @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useState } from 'react'; +import { MfaMethod } from '../../../../rust-api/types'; +import { ThemeSpacing } from '../../../../types'; +import { isPresent } from '../../../../utils/isPresent'; +import { Button } from '../../../Button/Button'; +import { ButtonVariant } from '../../../Button/types'; +import { CodeInput } from '../../../CodeInput/CodeInput'; +import { Controls } from '../../../Controls/Controls'; +import { Divider } from '../../../Divider/Divider'; +import { IconKind } from '../../../Icon'; +import { IconButton } from '../../../IconButton/IconButton'; +import { IconButtonVariant } from '../../../IconButton/types'; +import { SizedBox } from '../../../SizedBox/SizedBox'; +import { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader'; +import { useLocationCardContext } from '../../context/context'; +import { LocationCardViews } from '../../context/types'; +import { useMfaConnect } from '../../hooks/useMfaConnect'; +import { LocationCardMfaStartLoader } from '../LocationCardMfaStartLoader/LocationCardMfaStartLoader'; + +const MIN_POSTURE_LOADER_MS = 500; + +export const LocationCardMfaEmailView = () => { + const { setView, location, setPostureError } = useLocationCardContext(); + const { verifyCode, isVerifying, verifyError, isStarting, startError } = useMfaConnect( + location, + MfaMethod.Email, + { + debounceMs: location.posture_check_required ? MIN_POSTURE_LOADER_MS : 0, + onConnected: () => setView(LocationCardViews.Connected), + onSessionExpired: () => setView(LocationCardViews.Default), + onPostureError: (msg) => { + setPostureError(msg); + setView(LocationCardViews.PostureCheckFail); + }, + onServiceUnavailable: () => setView(LocationCardViews.ConnectionError), + }, + ); + + const [emailCode, setEmailCode] = useState(null); + const [error, setError] = useState(null); + + const handleVerify = useCallback( + (argCode?: string | null) => { + const toCheck = argCode ?? emailCode; + + if (!isPresent(toCheck)) { + setError('Enter code'); + return; + } + if (toCheck.length !== 6) { + setError('6 digits are required'); + return; + } + verifyCode(toCheck); + }, + [emailCode, verifyCode], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: side effect of code input + useEffect(() => { + setError(null); + }, [emailCode, setError]); + + // Reflect server-side verify errors into the local error state + useEffect(() => { + if (verifyError) setError(verifyError); + }, [verifyError]); + + // Show loader when posture is being evaluated + const showLoader = location.posture_check_required && isStarting && !startError; + if (showLoader) { + return ; + } + return ( +
{ + if (e.key === 'Enter') handleVerify(); + }} + > + + +

Enter the 6-digit code sent to your email address.

+
+ + { + handleVerify(value); + }} + /> + + { + setView(LocationCardViews.Default); + }} + /> +
+
+
+
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaMobileView/LocationCardMfaMobileView.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaMobileView/LocationCardMfaMobileView.tsx new file mode 100644 index 000000000..a3bb41f12 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaMobileView/LocationCardMfaMobileView.tsx @@ -0,0 +1,92 @@ +import './style.scss'; +import { useEffect, useRef, useState } from 'react'; +import { ThemeSpacing } from '../../../../types'; +import { Button } from '../../../Button/Button'; +import { ButtonVariant } from '../../../Button/types'; +import { Controls } from '../../../Controls/Controls'; +import { Divider } from '../../../Divider/Divider'; +import { IconKind } from '../../../Icon'; +import { IconButton } from '../../../IconButton/IconButton'; +import { IconButtonVariant } from '../../../IconButton/types'; +import { QrCard } from '../../../QrCard/QrCard'; +import { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader'; +import { useLocationCardContext } from '../../context/context'; +import { LocationCardViews } from '../../context/types'; +import { useMfaMobileConnect } from '../../hooks/useMfaMobileConnect'; + +type Screen = 'loading' | 'qr' | 'error'; + +export const LocationCardMfaMobileView = () => { + const { setView, setPostureError, location } = useLocationCardContext(); + const { start, startError, qrValue, connectionError } = useMfaMobileConnect(location, { + onConnected: () => setView(LocationCardViews.Connected), + onPostureError: (message) => setPostureError(message ?? null), + onServiceUnavailable: () => setView(LocationCardViews.ConnectionError), + }); + const [screen, setScreen] = useState('loading'); + const startedRef = useRef(false); + + // Auto-start on mount + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + void start(); + }, [start]); + + useEffect(() => { + if (startError ?? connectionError) { + setScreen('error'); + } else if (qrValue) { + setScreen('qr'); + } + }, [startError, connectionError, qrValue]); + + const backToLocation = () => { + setPostureError(null); + setView(LocationCardViews.Default); + }; + + const errorMessage = startError ?? connectionError; + + return ( +
+ + + {screen === 'loading' &&

Preparing authentication...

} + {screen === 'qr' && ( +

Open your Defguard mobile app and scan the QR code you see bellow.

+ )} + {screen === 'error' &&

{errorMessage}

} +
+ {screen === 'qr' && qrValue && ( +
+ +
+ )} + + setView(LocationCardViews.Default)} + /> +
+
+
+
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaMobileView/style.scss b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaMobileView/style.scss new file mode 100644 index 000000000..02de3b954 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaMobileView/style.scss @@ -0,0 +1,22 @@ +.location-card-mfa-mobile { + display: flex; + flex-direction: column; + + .qr-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-md); + padding-top: var(--spacing-lg); + + p { + text-align: center; + } + } + + .location-card-view-header { + p.error { + color: var(--fg-critical); + } + } +} diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaOidcView/LocationCardMfaOidcView.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaOidcView/LocationCardMfaOidcView.tsx new file mode 100644 index 000000000..c206d24a4 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaOidcView/LocationCardMfaOidcView.tsx @@ -0,0 +1,117 @@ +import './style.scss'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; +import { api } from '../../../../rust-api/api'; +import { getAppConfigQueryOptions } from '../../../../rust-api/query'; +import { ThemeSpacing } from '../../../../types'; +import { Button } from '../../../Button/Button'; +import { ButtonVariant } from '../../../Button/types'; +import { Checkbox } from '../../../Checkbox/Checkbox'; +import { Controls } from '../../../Controls/Controls'; +import { Divider } from '../../../Divider/Divider'; +import { IconKind } from '../../../Icon'; +import { IconButton } from '../../../IconButton/IconButton'; +import { IconButtonVariant } from '../../../IconButton/types'; +import { SizedBox } from '../../../SizedBox/SizedBox'; +import { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader'; +import { useLocationCardContext } from '../../context/context'; +import { LocationCardViews } from '../../context/types'; +import { useMfaOidcConnect } from '../../hooks/useMfaOidcConnect'; + +type Screen = 'idle' | 'polling' | 'error'; + +export const LocationCardMfaOidcView = () => { + const { data: appConfig } = useQuery(getAppConfigQueryOptions); + const { setView, setPostureError, autoConnectOpenid } = useLocationCardContext(); + const { start, isStarting, startError, isPolling, pollError } = useMfaOidcConnect(); + const [screen, setScreen] = useState('idle'); + + useEffect(() => { + if (startError ?? pollError) { + setScreen((prev) => (prev !== 'idle' ? 'error' : prev)); + } else if (isPolling) { + setScreen('polling'); + } + }, [startError, pollError, isPolling]); + + const handleStart = useCallback(async () => { + await start(); + setScreen('polling'); + }, [start]); + + const errorMessage = startError ?? pollError; + + const backToLocation = () => { + setPostureError(null); + setView(LocationCardViews.Default); + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: on mount effect + useEffect(() => { + if (autoConnectOpenid) { + handleStart(); + } + }, []); + + return ( +
+ + + {screen === 'idle' && ( +

+ To connect to the VPN, authenticate via your OpenID provider. A browser window + will open for you to sign in. +

+ )} + {screen === 'polling' && ( +

+ {`Complete the sign-in in your browser. This page will update automatically.`} +

+ )} + {screen === 'error' &&

{errorMessage}

} +
+ {screen === 'idle' && !autoConnectOpenid && ( +
+ + { + void api.setAppConfig( + { + auto_start_openid_mfa: !appConfig?.auto_start_openid_mfa, + }, + true, + ); + }} + /> +
+ )} + + setView(LocationCardViews.Default)} + /> +
+ {screen !== 'error' && ( +
+
+
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaOidcView/style.scss b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaOidcView/style.scss new file mode 100644 index 000000000..3f2d3f620 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaOidcView/style.scss @@ -0,0 +1,5 @@ +.location-card-view-header { + p.error { + color: var(--fg-critical); + } +} diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/LocationCardMfaSettings.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/LocationCardMfaSettings.tsx new file mode 100644 index 000000000..85fccdc2b --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/LocationCardMfaSettings.tsx @@ -0,0 +1,133 @@ +import './style.scss'; +import { useMutation } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { api } from '../../../../rust-api/api'; +import { + LocationMfaMode, + MfaMethod, + type MfaMethodValue, +} from '../../../../rust-api/types'; +import { ThemeSpacing } from '../../../../types'; +import { Button } from '../../../Button/Button'; +import { ButtonVariant } from '../../../Button/types'; +import { Checkbox } from '../../../Checkbox/Checkbox'; +import { Controls } from '../../../Controls/Controls'; +import { Divider } from '../../../Divider/Divider'; +import { IconKind } from '../../../Icon'; +import { IconButton } from '../../../IconButton/IconButton'; +import { IconButtonVariant } from '../../../IconButton/types'; +import { SizedBox } from '../../../SizedBox/SizedBox'; +import { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader'; +import { MfaSelector } from '../../components/MfaSelector/MfaSelector'; +import { useLocationCardContext } from '../../context/context'; +import { LocationCardViews } from '../../context/types'; + +export const LocationCardMfaSettings = () => { + const { mutate: setMfaMethod } = useMutation({ + mutationFn: api.setLocationMfaMethod, + meta: { + invalidate: [['locations']], + }, + }); + + const { + previousView, + setView, + location, + mfaMethod: currentMethod, + setMfaMethod: setContextMethod, + } = useLocationCardContext(); + + const locationDefaultMfaMethod = location.mfa_method ?? MfaMethod.Totp; + + const [selectedMethod, setSelectedPref] = useState(currentMethod); + + const isFromDefault = previousView === LocationCardViews.Default; + const [setAsDefault, setSetAsDefault] = useState(true); + + const MfaFactorsList = useMemo((): MfaMethodValue[] => { + if (location.location_mfa_mode === LocationMfaMode.Internal) { + return [MfaMethod.Totp, MfaMethod.Email, MfaMethod.MobileApprove]; + } + return [MfaMethod.Oidc]; + }, [location.location_mfa_mode]); + + const handleSubmit = () => { + setContextMethod(selectedMethod); + if ((isFromDefault || setAsDefault) && selectedMethod !== locationDefaultMfaMethod) { + setMfaMethod({ + locationId: location.id, + mfaMethod: selectedMethod, + }); + } + if (isFromDefault) { + setView(LocationCardViews.Default); + return; + } + switch (selectedMethod) { + case 'totp': + setView(LocationCardViews.MfaTotp); + break; + case 'email': + setView(LocationCardViews.MfaEmail); + break; + case 'mobileapprove': + setView(LocationCardViews.MfaMobile); + break; + case 'oidc': + setView(LocationCardViews.MfaOidc); + break; + default: + setView(LocationCardViews.Default); + } + }; + + return ( +
+ + +

+ If you're having issues with your current verification method, you can choose + another one or set a new default. +

+
+ +
+ {MfaFactorsList.map((factor) => ( + setSelectedPref(factor)} + /> + ))} +
+ {!isFromDefault && ( + setSetAsDefault((prev) => !prev)} + text="Set as default MFA method" + /> + )} + + { + setView(LocationCardViews.Default); + }} + /> +
+
+
+
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/style.scss b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/style.scss new file mode 100644 index 000000000..f1133fe78 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/style.scss @@ -0,0 +1,21 @@ +.location-card-mfa-settings { + > .header { + padding-bottom: var(--spacing-xl); + + :nth-child(1) { + font: var(--t-body-sm-500); + } + + :nth-child(2) { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + } + } + + > .methods { + display: flex; + flex-flow: column; + row-gap: var(--spacing-md); + padding-bottom: var(--spacing-md); + } +} diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaStartLoader/LocationCardMfaStartLoader.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaStartLoader/LocationCardMfaStartLoader.tsx new file mode 100644 index 000000000..de204d3da --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaStartLoader/LocationCardMfaStartLoader.tsx @@ -0,0 +1,14 @@ +import './style.scss'; +import { ThemeSpacing } from '../../../../types'; +import { Divider } from '../../../Divider/Divider'; +import { LoaderSpinner } from '../../../LoaderSpinner/LoaderSpinner'; + +export const LocationCardMfaStartLoader = () => ( +
+ +
+ +

Checking device requirements...

+
+
+); diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaStartLoader/style.scss b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaStartLoader/style.scss new file mode 100644 index 000000000..6dd24ee28 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaStartLoader/style.scss @@ -0,0 +1,15 @@ +.mfa-start-loader { + > .loader-content { + display: flex; + min-height: 140px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + + p { + font: var(--t-body-xs-500); + color: var(--fg-white-70); + } + } +} diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaTotpView/LocationCardMfaTotpView.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaTotpView/LocationCardMfaTotpView.tsx new file mode 100644 index 000000000..5cf19f233 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaTotpView/LocationCardMfaTotpView.tsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useState } from 'react'; +import { MfaMethod } from '../../../../rust-api/types'; +import { ThemeSpacing } from '../../../../types'; +import { isPresent } from '../../../../utils/isPresent'; +import { Button } from '../../../Button/Button'; +import { ButtonVariant } from '../../../Button/types'; +import { CodeInput } from '../../../CodeInput/CodeInput'; +import { Controls } from '../../../Controls/Controls'; +import { Divider } from '../../../Divider/Divider'; +import { IconKind } from '../../../Icon'; +import { IconButton } from '../../../IconButton/IconButton'; +import { IconButtonVariant } from '../../../IconButton/types'; +import { SizedBox } from '../../../SizedBox/SizedBox'; +import { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader'; +import { useLocationCardContext } from '../../context/context'; +import { LocationCardViews } from '../../context/types'; +import { useMfaConnect } from '../../hooks/useMfaConnect'; +import { LocationCardMfaStartLoader } from '../LocationCardMfaStartLoader/LocationCardMfaStartLoader'; + +const MIN_POSTURE_LOADER_MS = 500; + +export const LocationCardMfaTotpView = () => { + const { setView, location, setPostureError } = useLocationCardContext(); + const { verifyCode, isVerifying, verifyError, isStarting, startError } = useMfaConnect( + location, + MfaMethod.Totp, + { + debounceMs: location.posture_check_required ? MIN_POSTURE_LOADER_MS : 0, + onConnected: () => setView(LocationCardViews.Connected), + onSessionExpired: () => setView(LocationCardViews.Default), + onPostureError: (msg) => { + setPostureError(msg); + setView(LocationCardViews.PostureCheckFail); + }, + onServiceUnavailable: () => setView(LocationCardViews.ConnectionError), + }, + ); + + const [totpCode, setTotpCode] = useState(null); + const [error, setError] = useState(null); + + const handleVerify = useCallback( + (argCode?: string | null) => { + const toCheck = argCode ?? totpCode; + + if (!isPresent(toCheck)) { + setError('Enter code'); + return; + } + if (toCheck.replaceAll(' ', '').length !== 6) { + setError('6 digits are required'); + return; + } + verifyCode(toCheck); + }, + [totpCode, verifyCode], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: side effect of code input + useEffect(() => { + setError(null); + }, [totpCode, setError]); + + // Reflect server-side verify errors into the local error state + useEffect(() => { + if (verifyError) setError(verifyError); + }, [verifyError]); + + // Show loader when posture is being evaluated + const showLoader = location.posture_check_required && isStarting && !startError; + if (showLoader) { + return ; + } + + return ( +
{ + if (e.key === 'Enter') handleVerify(); + }} + > + + +

Paste the code from your Authenticator Application.

+
+ + { + handleVerify(value); + }} + /> + + { + setView(LocationCardViews.Default); + }} + /> +
+
+
+
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardPostureCheckFailView/LocationCardPostureCheckFailView.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardPostureCheckFailView/LocationCardPostureCheckFailView.tsx new file mode 100644 index 000000000..988d807b8 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardPostureCheckFailView/LocationCardPostureCheckFailView.tsx @@ -0,0 +1,46 @@ +import './style.scss'; +import { ThemeSpacing } from '../../../../types'; +import { Button } from '../../../Button/Button'; +import { ButtonVariant } from '../../../Button/types'; +import { Divider } from '../../../Divider/Divider'; +import { SizedBox } from '../../../SizedBox/SizedBox'; +import { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader'; +import { useLocationCardContext } from '../../context/context'; +import { LocationCardViews } from '../../context/types'; +import { NoConnectionIcon } from '../../images/NoConnectionIcon'; + +export const LocationCardPostureCheckFailView = () => { + const { postureError, setPostureError, setView } = useLocationCardContext(); + + const backToLocation = () => { + setPostureError(null); + setView(LocationCardViews.Default); + }; + + const postureErrors = postureError + ? postureError + .split(',') + .map((error) => error.trim()) + .filter(Boolean) + : ['Your device did not pass posture check.']; + + return ( +
+ + + + + +
+ {postureErrors.map((error) => ( +

+ {error} +

+ ))} +
+
+ +
+ ); +}; diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardPostureCheckFailView/style.scss b/new-ui/src/shared/components/LocationCard/views/LocationCardPostureCheckFailView/style.scss new file mode 100644 index 000000000..72b5f0ec0 --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardPostureCheckFailView/style.scss @@ -0,0 +1,40 @@ +.location-card-posture-check-fail-view { + display: flex; + flex-direction: column; + align-items: center; + + .posture-error-icon { + display: block; + width: 48px; + height: 48px; + } + + .location-card-view-header { + align-items: center; + row-gap: var(--spacing-sm); + text-align: center; + + > .title { + font: var(--t-body-sm-600); + color: var(--fg-white-100); + } + + .posture-errors { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); + } + + p.error { + margin: 0; + font: var(--t-body-xs-400); + color: var(--fg-white-70); + } + } + + .btn-wrap, + .btn { + width: 100%; + } +} diff --git a/new-ui/src/shared/components/MainBackground/MainBackground.tsx b/new-ui/src/shared/components/MainBackground/MainBackground.tsx new file mode 100644 index 000000000..29580c043 --- /dev/null +++ b/new-ui/src/shared/components/MainBackground/MainBackground.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; + +export const MainBackground = () => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const handleResize = () => { + // Get parent dimensions + // biome-ignore lint/style/noNonNullAssertion: Always have parent + const { clientWidth: w, clientHeight: h } = canvas.parentElement!; + + // Update internal resolution + canvas.width = w; + canvas.height = h; + + // Draw Gradient (134deg) + const angle = (134 * Math.PI) / 180; + const length = Math.sqrt(w ** 2 + h ** 2); + + const x1 = w / 2 - (Math.sin(angle) * length) / 2; + const y1 = h / 2 + (Math.cos(angle) * length) / 2; + const x2 = w / 2 + (Math.sin(angle) * length) / 2; + const y2 = h / 2 - (Math.cos(angle) * length) / 2; + + const gradient = ctx.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, '#5B83FF'); + gradient.addColorStop(1, '#0036DB'); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, w, h); + }; + + // Initial draw + handleResize(); + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return ( + + ); +}; diff --git a/new-ui/src/shared/components/Menu/Menu.tsx b/new-ui/src/shared/components/Menu/Menu.tsx new file mode 100644 index 000000000..6be5111fa --- /dev/null +++ b/new-ui/src/shared/components/Menu/Menu.tsx @@ -0,0 +1,33 @@ +import { Fragment } from 'react'; +import { MenuItem } from './components/MenuItem'; +import './style.scss'; +import clsx from 'clsx'; +import { isPresent } from '../../utils/isPresent'; +import { MenuHeader } from './components/MenuHeader'; +import { MenuSpacer } from './components/MenuSpacer'; +import type { MenuProps } from './types'; + +export const Menu = ({ + itemGroups, + ref, + className, + onClose, + testId, + ...props +}: MenuProps) => { + return ( +
+ {itemGroups.map((group, groupIndex) => ( + + {isPresent(group.header) && } + {group.items.map((item) => ( + + ))} + {groupIndex !== itemGroups.length - 1 && itemGroups.length !== 1 && ( + + )} + + ))} +
+ ); +}; diff --git a/new-ui/src/shared/components/Menu/components/MenuHeader.tsx b/new-ui/src/shared/components/Menu/components/MenuHeader.tsx new file mode 100644 index 000000000..54a33e803 --- /dev/null +++ b/new-ui/src/shared/components/Menu/components/MenuHeader.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx'; +import { isPresent } from '../../../utils/isPresent'; +import { Icon } from '../../Icon'; +import { InteractionBox } from '../../InteractionBox/InteractionBox'; +import type { MenuHeaderProps } from '../types'; + +export const MenuHeader = ({ text, testId, onHelp, onClose }: MenuHeaderProps) => { + return ( +
+

{text}

+ {isPresent(onHelp) && ( + { + onClose?.(); + onHelp(); + }} + > + + + )} +
+ ); +}; diff --git a/new-ui/src/shared/components/Menu/components/MenuItem.tsx b/new-ui/src/shared/components/Menu/components/MenuItem.tsx new file mode 100644 index 000000000..da4ac93a7 --- /dev/null +++ b/new-ui/src/shared/components/Menu/components/MenuItem.tsx @@ -0,0 +1,96 @@ +import { + autoUpdate, + FloatingPortal, + offset, + safePolygon, + shift, + useDismiss, + useFloating, + useHover, + useInteractions, +} from '@floating-ui/react'; +import clsx from 'clsx'; +import { useState } from 'react'; +import { isPresent } from '../../../utils/isPresent'; +import { Icon } from '../../Icon'; +import { Menu } from '../Menu'; +import type { MenuItemProps } from '../types'; + +export const MenuItem = ({ + disabled, + text, + icon, + items, + testId, + variant, + onClick, + onClose, +}: MenuItemProps) => { + const hasItems = isPresent(items) && items.length > 0; + const hasIcon = isPresent(icon); + + const [submenuOpen, setSubmenuOpen] = useState(false); + + const { refs, context, floatingStyles } = useFloating({ + placement: 'right-start', + open: submenuOpen, + onOpenChange: setSubmenuOpen, + whileElementsMounted: autoUpdate, + middleware: [offset(12), shift({ padding: 4 })], + }); + + const hover = useHover(context, { + handleClose: safePolygon(), + enabled: hasItems && !disabled, + }); + + const dismiss = useDismiss(context, { + ancestorScroll: true, + outsidePress: true, + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([hover, dismiss]); + + return ( + <> +
{ + if (!disabled && !hasItems) { + onClick?.(); + onClose?.(); + } + }} + {...getReferenceProps()} + > + {isPresent(icon) && } +

{text}

+ {hasItems && ( +
+ +
+ )} +
+ {hasItems && submenuOpen && items && ( + + + + )} + + ); +}; diff --git a/new-ui/src/shared/components/Menu/components/MenuSpacer.tsx b/new-ui/src/shared/components/Menu/components/MenuSpacer.tsx new file mode 100644 index 000000000..4510f86e7 --- /dev/null +++ b/new-ui/src/shared/components/Menu/components/MenuSpacer.tsx @@ -0,0 +1,7 @@ +export const MenuSpacer = () => { + return ( +
+
+
+ ); +}; diff --git a/new-ui/src/shared/components/Menu/style.scss b/new-ui/src/shared/components/Menu/style.scss new file mode 100644 index 000000000..e948189a7 --- /dev/null +++ b/new-ui/src/shared/components/Menu/style.scss @@ -0,0 +1,126 @@ +/* stylelint-disable no-descending-specificity */ +.menu { + display: flex; + flex-flow: column; + box-sizing: border-box; + padding: var(--spacing-sm); + border-radius: var(--radius-lg); + border: 0; + background-color: var(--c-saturated-dark-blue-60); + box-shadow: 0 4px 12px 0 rgb(0 0 0 / 7%); + backdrop-filter: blur(10px); + overflow: hidden auto; + z-index: 5; + + .menu-spacer { + user-select: none; + padding: var(--spacing-sm) 0; + + & > .line { + display: block; + content: ' '; + background-color: var(--bg-white-20); + height: 1px; + width: 100%; + } + } + + .menu-header { + display: flex; + flex-flow: row nowrap; + column-gap: var(--spacing-md); + justify-content: space-between; + flex: none; + + p { + font: var(--t-menu-title); + color: var(--fg-white-60); + padding-left: var(--spacing-sm); + } + + .interaction-box { + button { + height: 26px; + width: 26px; + } + + .icon { + --icon-color: var(--fg-white-60); + } + + &:hover { + .icon { + --icon-color: var(--fg-white-100); + } + } + } + } + + .menu-item { + --bg-color: var(--bg-default); + --color: var(--fg-default); + --icon-fill: var(--fg-white-100); + + display: flex; + flex-flow: row nowrap; + flex: none; + align-items: center; + border-radius: var(--radius-md); + padding: 0 var(--spacing-sm); + column-gap: var(--spacing-md); + background-color: var(--bg-color); + cursor: pointer; + height: 36px; + color: var(--color); + position: relative; + min-width: 115px; + + @include animate(background-color); + + &.disabled { + --bg-color: var(--fg-white-0); + --icon-fill: var(--fg-white-60); + --color: var(--fg-white-60); + + cursor: not-allowed; + } + + &.nested { + // account for positioned icon on the right side + padding: 0 calc(var(--spacing-sm) + var(--spacing-md) + 20px) 0 var(--spacing-sm); + } + + &.variant-danger { + --color: var(--fg-critical); + --icon-fill: var(--fg-critical); + } + + &:not(.disabled) { + &:hover { + --bg-color: var(--bg-white-5); + } + } + + p { + font: var(--t-menu-text); + color: inherit; + } + + & > .icon { + --icon-color: var(--icon-fill); + } + + & > .suffix { + position: absolute; + height: 20px; + width: 20px; + top: 50%; + right: var(--spacing-sm); + transform: translateY(-50%); + + .icon { + --icon-color: var(--fg-white-100); + } + } + } +} diff --git a/new-ui/src/shared/components/Menu/types.ts b/new-ui/src/shared/components/Menu/types.ts new file mode 100644 index 000000000..8537a9326 --- /dev/null +++ b/new-ui/src/shared/components/Menu/types.ts @@ -0,0 +1,33 @@ +import type { HTMLAttributes, Ref } from 'react'; +import type { IconKindValue } from '../Icon/icon-types'; + +export interface MenuProps extends HTMLAttributes { + itemGroups: MenuItemsGroup[]; + ref?: Ref; + testId?: string; + onClose?: () => void; +} + +export interface MenuItemsGroup { + header?: MenuHeaderProps; + items: MenuItemProps[]; +} + +export interface MenuItemProps { + text: string; + variant?: 'default' | 'danger'; + disabled?: boolean; + icon?: IconKindValue; + items?: MenuItemProps[]; + testId?: string; + onClick?: () => void; + onClose?: () => void; +} + +export interface MenuHeaderProps { + text: string; + tooltip?: string; + testId?: string; + onClose?: () => void; + onHelp?: () => void; +} diff --git a/new-ui/src/shared/components/Modal/Modal.tsx b/new-ui/src/shared/components/Modal/Modal.tsx new file mode 100644 index 000000000..4819d36e7 --- /dev/null +++ b/new-ui/src/shared/components/Modal/Modal.tsx @@ -0,0 +1,37 @@ +import './style.scss'; +import clsx from 'clsx'; +import { isPresent } from '../../utils/isPresent'; +import { IconButton } from '../IconButton/IconButton'; +import { IconButtonVariant } from '../IconButton/types'; +import { ModalFoundation } from '../ModalFoundation/ModalFoundation'; +import { ModalGradient } from '../ModalGradient/ModalGradient'; +import type { ModalProps } from './types'; + +export const Modal = ({ + title, + size, + onClose, + children, + contentClassName, + ...foundationProps +}: ModalProps) => { + return ( + +
+

{title}

+ {isPresent(onClose) && ( + + )} +
+
{children}
+ +
+ ); +}; diff --git a/new-ui/src/shared/components/Modal/style.scss b/new-ui/src/shared/components/Modal/style.scss new file mode 100644 index 000000000..df3ed6360 --- /dev/null +++ b/new-ui/src/shared/components/Modal/style.scss @@ -0,0 +1,44 @@ +#modals-root .modal { + --max-width: var(--modal-size-md); + + border-radius: var(--radius-lg); + width: 100%; + max-width: var(--max-width); + flex-shrink: 0; + position: relative; + overflow: hidden; + + &.size-small { + --max-width: var(--modal-size-sm); + } + + &.size-primary { + --max-width: var(--modal-size-md); + } + + & > .modal-header { + box-sizing: border-box; + padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md); + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + display: grid; + grid-template-columns: 1fr 24px; + grid-template-rows: 1fr; + user-select: none; + align-items: center; + + .title { + font: var(--t-body-primary-500); + color: var(--fg-white-100); + } + } + + & > .modal-content { + box-sizing: border-box; + padding: 0 var(--spacing-lg) var(--spacing-lg); + + .controls { + padding-top: var(--spacing-3xl); + } + } +} diff --git a/new-ui/src/shared/components/Modal/types.ts b/new-ui/src/shared/components/Modal/types.ts new file mode 100644 index 000000000..e07e3c8e3 --- /dev/null +++ b/new-ui/src/shared/components/Modal/types.ts @@ -0,0 +1,6 @@ +import type { ModalBase } from '../ModalFoundation/types'; + +export interface ModalProps extends ModalBase { + title: string; + size?: 'small' | 'primary'; +} diff --git a/new-ui/src/shared/components/ModalFoundation/ModalFoundation.tsx b/new-ui/src/shared/components/ModalFoundation/ModalFoundation.tsx new file mode 100644 index 000000000..5c19652a8 --- /dev/null +++ b/new-ui/src/shared/components/ModalFoundation/ModalFoundation.tsx @@ -0,0 +1,82 @@ +import './style.scss'; + +import clsx from 'clsx'; +import { AnimatePresence, motion } from 'motion/react'; +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { motionTransitionStandard } from '../../consts'; +import type { ModalBase } from './types'; + +const portalTarget = document.getElementById('modals-root') as HTMLElement; +const rootElement = document.getElementById('root') as HTMLElement; + +export const ModalFoundation = ({ + children, + isOpen, + afterClose, + contentClassName, + hideBackdrop, + id, + positionerClassName, +}: Omit) => { + const openRef = useRef(isOpen); + + useEffect(() => { + if (isOpen) { + rootElement.style.overflowY = 'hidden'; + } else { + rootElement.style.overflowY = 'auto'; + } + }, [isOpen]); + + return createPortal( + + {isOpen && ( + + {!hideBackdrop && ( + + )} + + { + if (!openRef.current && target.opacity === 0) { + afterClose?.(); + } + }} + transition={motionTransitionStandard} + > + {children} + + + + )} + , + portalTarget, + ); +}; diff --git a/new-ui/src/shared/components/ModalFoundation/style.scss b/new-ui/src/shared/components/ModalFoundation/style.scss new file mode 100644 index 000000000..4bdf7e729 --- /dev/null +++ b/new-ui/src/shared/components/ModalFoundation/style.scss @@ -0,0 +1,34 @@ +#modals-root { + position: relative; + + .modal-root { + display: block; + + .backdrop { + position: fixed; + left: 0; + top: var(--window-decorations-height); + display: block; + content: ' '; + width: 100%; + height: calc(100dvh - var(--window-decorations-height)); + z-index: 4; + } + + .modal-positioner { + overflow: auto; + position: fixed; + left: 0; + top: var(--window-decorations-height); + width: 100%; + height: calc(100dvh - var(--window-decorations-height)); + z-index: 4; + display: flex; + flex-flow: column; + align-items: safe center; + justify-content: safe center; + box-sizing: border-box; + padding: var(--spacing-xl); + } + } +} diff --git a/new-ui/src/shared/components/ModalFoundation/types.ts b/new-ui/src/shared/components/ModalFoundation/types.ts new file mode 100644 index 000000000..cf3d7e0f0 --- /dev/null +++ b/new-ui/src/shared/components/ModalFoundation/types.ts @@ -0,0 +1,11 @@ +import type { PropsWithChildren } from 'react'; + +export interface ModalBase extends PropsWithChildren { + isOpen: boolean; + id?: string; + hideBackdrop?: boolean; + positionerClassName?: string; + contentClassName?: string; + onClose?: (() => void) | null; + afterClose?: () => void; +} diff --git a/new-ui/src/shared/components/ModalGradient/ModalGradient.tsx b/new-ui/src/shared/components/ModalGradient/ModalGradient.tsx new file mode 100644 index 000000000..49497d1a4 --- /dev/null +++ b/new-ui/src/shared/components/ModalGradient/ModalGradient.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; + +export const ModalGradient = () => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const draw = (w: number, h: number) => { + canvas.width = w; + canvas.height = h; + + const angle = (134 * Math.PI) / 180; + const length = Math.sqrt(w ** 2 + h ** 2); + + const x1 = w / 2 - (Math.sin(angle) * length) / 2; + const y1 = h / 2 + (Math.cos(angle) * length) / 2; + const x2 = w / 2 + (Math.sin(angle) * length) / 2; + const y2 = h / 2 - (Math.cos(angle) * length) / 2; + + const gradient = ctx.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, '#5B83FF'); + gradient.addColorStop(1, '#0036DB'); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, w, h); + }; + + // biome-ignore lint/style/noNonNullAssertion: Always have parent + const parent = canvas.parentElement!; + draw(parent.clientWidth, parent.clientHeight); + + const observer = new ResizeObserver(([entry]) => { + const { inlineSize: w, blockSize: h } = entry.contentBoxSize[0]; + draw(w, h); + }); + + observer.observe(parent); + return () => observer.disconnect(); + }, []); + + return ( + + ); +}; diff --git a/new-ui/src/shared/components/NotFoundRoute/NotFoundRoute.tsx b/new-ui/src/shared/components/NotFoundRoute/NotFoundRoute.tsx new file mode 100644 index 000000000..9705caeb6 --- /dev/null +++ b/new-ui/src/shared/components/NotFoundRoute/NotFoundRoute.tsx @@ -0,0 +1,19 @@ +import { useRouter } from '@tanstack/react-router'; + +export const NotFoundRoute = () => { + const router = useRouter(); + const availableRoutes = Object.keys(router.routesById); + + return ( +
+

Route not found

+

Detected: {window.location.href}

+

Available routes:

+
    + {availableRoutes.map((route) => ( +
  • {route}
  • + ))} +
+
+ ); +}; diff --git a/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx b/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx new file mode 100644 index 000000000..6752980af --- /dev/null +++ b/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx @@ -0,0 +1,186 @@ +import './style.scss'; + +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import clsx from 'clsx'; +import { Fragment, useMemo } from 'react'; +import { + ConnectModalView, + mfaMethodToConnectModalView, +} from '../../../pages/full/OverviewPage/components/ConnectModal/hooks/types'; +import { useConnectModal } from '../../../pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModal'; +import { api } from '../../rust-api/api'; +import { getAppConfigQueryOptions } from '../../rust-api/query'; +import type { InstanceInfo, LocationInfo } from '../../rust-api/types'; +import { MfaMethod } from '../../rust-api/types'; +import { ThemeSpacing } from '../../types'; +import { isPresent } from '../../utils/isPresent'; +import { shouldStartMfa } from '../../utils/mfa'; +import { Divider } from '../Divider/Divider'; +import { parseConnectError } from '../LocationCard/api/connectError'; +import { ConnectButton } from '../LocationCard/components/ConnectButton/ConnectButton'; +import { LocationCardConnectionInfo } from '../LocationCard/components/LocationCardConnectionInfo/LocationCardConnectionInfo'; +import { LocationCardConnectionTiles } from '../LocationCard/components/LocationCardConnectionTiles/LocationCardConnectionTiles'; +import { LocationCardHeaderInfo } from '../LocationCard/components/LocationCardHeaderInfo/LocationCardHeaderInfo'; +import { LocationCardMfaEdit } from '../LocationCard/components/LocationCardMfaEdit/LocationCardMfaEdit'; +import { Toggle } from '../Toggle/Toggle'; + +interface Props { + location: LocationInfo; + instance?: InstanceInfo; +} + +export const OverviewLocationCard = ({ location, instance }: Props) => { + const navigate = useNavigate(); + const { data: appConfig } = useQuery(getAppConfigQueryOptions); + const { mutate: updateRouting } = useMutation({ + mutationFn: api.updateLocationRouting, + meta: { + invalidate: ['locations'], + }, + }); + + const { mutate: connect, isPending: isConnecting } = useMutation({ + mutationFn: api.connect, + onError: (err) => { + const connectError = parseConnectError(err); + if ( + location.posture_check_required && + connectError?.kind === 'postureCheckFailed' + ) { + useConnectModal.getState().open({ + location, + view: ConnectModalView.PostureCheckFail, + postureError: connectError.message, + }); + } else if (connectError?.kind === 'allTrafficConflict') { + useConnectModal.getState().open({ + location, + view: ConnectModalView.ConnectionError, + connectionError: connectError.message, + }); + } else if (connectError?.kind === 'serviceUnavailable') { + useConnectModal.getState().open({ + location, + view: ConnectModalView.ConnectionError, + }); + } + }, + meta: { + invalidate: ['locations'], + }, + }); + + const { mutate: disconnect, isPending: isDisconnecting } = useMutation({ + mutationFn: api.disconnect, + meta: { + invalidate: [ + ['locations'], + ['active-connection'], + ['connection-history'], + ['alive-connections'], + ], + }, + }); + + const isBusy = isConnecting || isDisconnecting; + + const handleConnectClick = () => { + if (!appConfig) return; + if (location.active) { + disconnect({ connectionType: location.connection_type, locationId: location.id }); + return; + } + + if (shouldStartMfa(location)) { + useConnectModal.getState().open({ + view: mfaMethodToConnectModalView(location.mfa_method ?? MfaMethod.Totp), + location, + autoStartOpenId: appConfig.auto_start_openid_mfa, + mfaMethod: location.mfa_method, + }); + return; + } + + connect({ connectionType: location.connection_type, locationId: location.id }); + }; + + const traficLabel = useMemo(() => { + if (location.route_all_traffic) { + return 'All traffic is allowed'; + } else { + return 'Predefined traffic only'; + } + }, [location.route_all_traffic]); + + return ( +
+
+ + navigate({ + to: '/full/location-details', + search: { + locationId: location.id, + locationName: location.name, + connectionType: location.connection_type, + }, + }) + } + /> +
+ +
+
+ +
+ {location.active && ( + + )} + {!location.active && ( + + {(instance?.client_traffic_policy === 'none' || !instance) && ( + { + updateRouting({ + connectionType: location.connection_type, + locationId: location.id, + routeAllTraffic: !location.route_all_traffic, + }); + }} + /> + )} + { + if (isPresent(location)) { + useConnectModal.getState().open({ + view: ConnectModalView.MfaSettings, + location: location, + perviousView: null, + mfaMethod: location.mfa_method, + }); + } + }} + /> + + )} +
+ + +
+ ); +}; diff --git a/new-ui/src/shared/components/OverviewLocationCard/style.scss b/new-ui/src/shared/components/OverviewLocationCard/style.scss new file mode 100644 index 000000000..dd9148d47 --- /dev/null +++ b/new-ui/src/shared/components/OverviewLocationCard/style.scss @@ -0,0 +1,50 @@ +.overview-location-card { + width: 100%; + box-sizing: border-box; + padding: var(--spacing-lg) var(--spacing-md); + background: var(--c-saturated-dark-blue-20); + border-radius: 12px; + + > * { + width: 100%; + } + + > .header { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-md); + + > .right { + margin-left: auto; + display: flex; + align-items: center; + column-gap: var(--spacing-xs); + + .bottom { + button { + height: 24px; + width: 24px; + } + } + } + + > .left { + button { + min-height: 36px; + } + } + } + + .no-connection-info { + min-height: 124px; + } + + > .controls { + display: flex; + flex-flow: row nowrap; + column-gap: var(--spacing-5xl); + min-height: 40px; + } +} diff --git a/new-ui/src/shared/components/QrCard/QrCard.tsx b/new-ui/src/shared/components/QrCard/QrCard.tsx new file mode 100644 index 000000000..b63540e1f --- /dev/null +++ b/new-ui/src/shared/components/QrCard/QrCard.tsx @@ -0,0 +1,15 @@ +import './style.scss'; +import { QRCodeCanvas } from 'qrcode.react'; + +interface Props { + value: string; + size?: number; +} + +export const QrCard = ({ value, size = 200 }: Props) => { + return ( +
+ +
+ ); +}; diff --git a/new-ui/src/shared/components/QrCard/style.scss b/new-ui/src/shared/components/QrCard/style.scss new file mode 100644 index 000000000..723ddd7f5 --- /dev/null +++ b/new-ui/src/shared/components/QrCard/style.scss @@ -0,0 +1,9 @@ +.qr-code-display { + background-color: var(--bg-white-100); + border-radius: var(--radius-lg); + padding: var(--spacing-md); + flex-grow: 0; + display: flex; + flex-flow: row; + align-items: center; +} diff --git a/new-ui/src/shared/components/RadioIndicator/RadioIndicator.tsx b/new-ui/src/shared/components/RadioIndicator/RadioIndicator.tsx new file mode 100644 index 000000000..884942210 --- /dev/null +++ b/new-ui/src/shared/components/RadioIndicator/RadioIndicator.tsx @@ -0,0 +1,105 @@ +import './style.scss'; +import { useMemo } from 'react'; + +type Props = { + hover?: boolean; + active?: boolean; + disabled?: boolean; +}; + +export const RadioIndicator = ({ active, disabled, hover }: Props) => { + const RenderIcon = useMemo(() => { + if (active) { + if (disabled) { + return StateSelectedDisabled; + } + return StateSelected; + } + if (disabled) { + return StateDefaultDisabled; + } + if (hover) { + return StateDefaultHover; + } + return StateDefault; + }, [active, disabled, hover]); + + return ( +
+ +
+ ); +}; + +const StateDefault = () => { + return ( + + + + ); +}; + +const StateDefaultHover = () => { + return ( + + + + ); +}; + +const StateDefaultDisabled = () => { + return ( + + + + + ); +}; + +const StateSelected = () => { + return ( + + + + + ); +}; + +const StateSelectedDisabled = () => { + return ( + + + + + ); +}; diff --git a/new-ui/src/shared/components/RadioIndicator/style.scss b/new-ui/src/shared/components/RadioIndicator/style.scss new file mode 100644 index 000000000..08123f860 --- /dev/null +++ b/new-ui/src/shared/components/RadioIndicator/style.scss @@ -0,0 +1,55 @@ +.radio-indicator { + display: inline-flex; + flex-flow: row; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.radio-indicator svg { + &.icon-default { + circle { + stroke: var(--fg-white-100); + fill: transparent; + } + } + + &.icon-hover { + circle { + stroke: var(--fg-white-100); + fill: transparent; + } + } + + &.icon-disabled { + circle { + stroke: var(--border-disabled); + fill: var(--bg-white-5); + } + } + + &.icon-selected { + circle { + &:nth-child(1) { + fill: var(--fg-white-100); + } + + &:nth-child(2) { + fill: var(--fg-action); + } + } + } + + &.icon-selected-disabled { + circle { + &:nth-child(1) { + fill: var(--bg-white-10); + } + + &:nth-child(2) { + fill: var(--bg-white-60); + } + } + } +} diff --git a/new-ui/src/shared/components/RenderMarkdown/RenderMarkdown.tsx b/new-ui/src/shared/components/RenderMarkdown/RenderMarkdown.tsx new file mode 100644 index 000000000..c16c17bee --- /dev/null +++ b/new-ui/src/shared/components/RenderMarkdown/RenderMarkdown.tsx @@ -0,0 +1,39 @@ +import type { HTMLProps } from 'react'; +import './style.scss'; +import clsx from 'clsx'; +import ReactMarkdown from 'react-markdown'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; +import remarkGfm from 'remark-gfm'; + +const sanitizeSchema = { + ...defaultSchema, + tagNames: (defaultSchema.tagNames ?? []).filter((tag: string) => tag !== 'iframe'), +}; + +export const RenderMarkdown = ({ + content, + containerProps, +}: { + content?: string | null | undefined; + containerProps?: HTMLProps; +}) => { + const containerCustomClassName = containerProps?.className; + + return ( + + ); +}; diff --git a/new-ui/src/shared/components/RenderMarkdown/style.scss b/new-ui/src/shared/components/RenderMarkdown/style.scss new file mode 100644 index 000000000..c6d5a8fc5 --- /dev/null +++ b/new-ui/src/shared/components/RenderMarkdown/style.scss @@ -0,0 +1,6 @@ +.markdown-render { + a { + text-decoration: none; + color: var(--fg-action); + } +} diff --git a/new-ui/src/shared/components/ScrollContainer/ScrollContainer.tsx b/new-ui/src/shared/components/ScrollContainer/ScrollContainer.tsx new file mode 100644 index 000000000..602936605 --- /dev/null +++ b/new-ui/src/shared/components/ScrollContainer/ScrollContainer.tsx @@ -0,0 +1,18 @@ +import './style.scss'; +import { platform } from '@tauri-apps/plugin-os'; +import clsx from 'clsx'; +import type { PropsWithChildren } from 'react'; + +const isWindows = platform() === 'windows'; + +export const ScrollContainer = ({ children }: PropsWithChildren) => { + return ( +
+ {children} +
+ ); +}; diff --git a/new-ui/src/shared/components/ScrollContainer/style.scss b/new-ui/src/shared/components/ScrollContainer/style.scss new file mode 100644 index 000000000..6a8248041 --- /dev/null +++ b/new-ui/src/shared/components/ScrollContainer/style.scss @@ -0,0 +1,13 @@ +.scroll-container { + display: flex; + flex-flow: column; + flex: 1 1 auto; + overflow-y: auto; + min-height: 0; + + &.windows { + scrollbar-gutter: stable; + overflow-y: scroll; + padding-right: 6px; + } +} diff --git a/new-ui/src/shared/components/Select/Select.tsx b/new-ui/src/shared/components/Select/Select.tsx new file mode 100644 index 000000000..65101a4bd --- /dev/null +++ b/new-ui/src/shared/components/Select/Select.tsx @@ -0,0 +1,222 @@ +import './style.scss'; +import { + autoUpdate, + FloatingPortal, + flip, + size as floatingSize, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, +} from '@floating-ui/react'; +import clsx from 'clsx'; +import { Fragment, type JSX, useCallback, useId, useMemo, useState } from 'react'; +import { Direction, ThemeSpacing, ThemeVariable } from '../../types'; +import { isPresent } from '../../utils/isPresent'; +import { Divider } from '../Divider/Divider'; +import { FieldBox } from '../FieldBox/FieldBox'; +import { FieldError } from '../FieldError/FieldError'; +import { FieldLabel } from '../FieldLabel/FieldLabel'; +import { FloatingMenu } from '../FloatingMenu/FloatingMenu'; +import { Icon, IconKind } from '../Icon'; +import type { SelectOption, SelectOptionGroup, SelectProps } from './types'; + +export function Select(props: SelectProps): JSX.Element { + const labelId = useId(); + + const { + label, + options, + groups, + className, + placeholder, + testId, + error, + size = 'default', + disabled = false, + required = false, + } = props; + + const [floatingOpen, setFloatingOpen] = useState(false); + + const { refs, context, floatingStyles } = useFloating({ + placement: 'bottom-start', + open: floatingOpen, + onOpenChange: setFloatingOpen, + middleware: [ + offset(4), + flip(), + shift(), + floatingSize({ + apply({ rects, elements, availableHeight }) { + const refWidth = `${rects.reference.width}px`; + elements.floating.style.minWidth = refWidth; + elements.floating.style.maxHeight = `${availableHeight - 10}px`; + }, + }), + ], + whileElementsMounted: autoUpdate, + }); + + const selectedLabel = useMemo(() => props.value?.label ?? null, [props.value]); + + const renderedGroups: readonly SelectOptionGroup[] = groups ?? []; + const renderedOptions: readonly SelectOption[] = options ?? []; + + // biome-ignore lint/correctness/useExhaustiveDependencies: onChange + const handleChange = useCallback( + (option: SelectOption, isSelected: boolean) => { + if (isSelected) return; + props.onChange(option); + setFloatingOpen(false); + }, + [props.onChange, setFloatingOpen], + ); + + const click = useClick(context, { + toggle: true, + enabled: !disabled, + }); + + const dismiss = useDismiss(context, { + ancestorScroll: true, + escapeKey: true, + outsidePress: true, + }); + + const { getFloatingProps, getReferenceProps } = useInteractions([click, dismiss]); + + return ( + <> +
+
+ {isPresent(label) && ( + + )} + + } + forceFocusState={floatingOpen} + aria-labelledby={labelId} + {...getReferenceProps()} + > +
+ {isPresent(placeholder) && !isPresent(selectedLabel) && ( + {placeholder} + )} + {isPresent(selectedLabel) && {selectedLabel}} +
+
+ +
+
+ {floatingOpen && ( + + + {renderedOptions.map((option, optionIndex) => { + const isSelected = props.value?.key === option.key; + const isLast = renderedOptions.length - 1 === optionIndex; + return ( + + ); + })} + {renderedGroups + .filter((group) => group.options.length > 0) + .map((group, groupIndex, activeGroups) => { + const isLast = activeGroups.length - 1 === groupIndex; + const groupKey = group.key ?? `${group.label}-${groupIndex}`; + + return ( + +
+
+

{group.label}

+
+
+ {group.options.map((option, optionIndex) => { + const isSelected = props.value?.key === option.key; + const isLast = group.options.length - 1 === optionIndex; + return ( + + ); + })} + {!isLast && } +
+ ); + })} +
+
+ )} + + ); +} + +type SelectOptionItemProps = { + isLast: boolean; + isSelected: boolean; + onSelect: (option: SelectOption, isSelected: boolean) => void; + option: SelectOption; +}; + +function SelectOptionItem({ + isLast, + isSelected, + onSelect, + option, +}: SelectOptionItemProps): JSX.Element { + return ( +
{ + onSelect(option, isSelected); + }} + role="listitem" + > + {option.label} + {isSelected && ( + + )} +
+ ); +} diff --git a/new-ui/src/shared/components/Select/style.scss b/new-ui/src/shared/components/Select/style.scss new file mode 100644 index 000000000..b7291ba6c --- /dev/null +++ b/new-ui/src/shared/components/Select/style.scss @@ -0,0 +1,109 @@ +.select { + & > .inner { + box-sizing: border-box; + + .field-label { + cursor: pointer; + user-select: none; + } + + .field-box { + .box-track { + min-width: 0; + + .placeholder, + .value { + display: block; + user-select: none; + } + } + + &.size-default { + .box-track { + .value, + .placeholder { + font: var(--t-input-text-primary); + } + } + } + + &.size-lg { + .box-track { + .value, + .placeholder { + font: var(--t-input-text-big); + } + } + } + } + + &.disabled { + user-select: none; + + & > .field-label { + cursor: not-allowed; + } + } + } +} + +.select-floating { + z-index: 5; + position: absolute; + + .section-title { + box-sizing: border-box; + padding-left: var(--spacing-sm); + min-height: 24px; + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + margin-bottom: 4px; + + p { + font: var(--t-menu-title); + color: var(--fg-white-60); + } + } + + .select-option { + --bg-color: transparent; + + display: grid; + grid-template-columns: minmax(0, 1fr) 20px; + grid-template-rows: 1fr; + box-sizing: border-box; + padding: 0 var(--spacing-sm); + user-select: none; + align-items: center; + justify-content: start; + background-color: var(--bg-color); + width: 100%; + min-height: 36px; + border-radius: 8px; + cursor: pointer; + column-gap: var(--spacing-md); + + @include animate(background-color); + + &:not(.last) { + margin-bottom: var(--spacing-xs); + } + + &:hover { + --bg-color: var(--bg-white-10); + } + + &.selected { + --bg-color: var(--bg-white-5); + } + + span { + font: var(--t-menu-text); + color: var(--fg-white-100); + + @include animate(color); + } + } +} diff --git a/new-ui/src/shared/components/Select/types.ts b/new-ui/src/shared/components/Select/types.ts new file mode 100644 index 000000000..31fa1dba3 --- /dev/null +++ b/new-ui/src/shared/components/Select/types.ts @@ -0,0 +1,48 @@ +import type { FieldBoxProps } from '../FieldBox/types'; + +export type SelectOption = { + key: string | number; + label: string; + value: T; + meta?: unknown; +}; + +export type SelectOptionGroup = { + key?: string | number; + label: string; + options: readonly SelectOption[]; +}; + +export type SelectSingleValue = SelectOption; + +type SelectOptionsSourceProps = + | { + options: readonly SelectOption[]; + groups?: never; + } + | { + options?: never; + groups: readonly SelectOptionGroup[]; + } + | { + options: readonly SelectOption[]; + groups: readonly SelectOptionGroup[]; + }; + +type BaseProps = { + testId?: string; + placeholder?: string; + disabled?: boolean; + className?: string; + label?: string; + required?: boolean; + error?: string; +} & Pick & + SelectOptionsSourceProps; + +export type SelectSingleProps = BaseProps & { + value: SelectSingleValue; + onChange: (v: SelectSingleValue) => void; +}; + +export type SelectProps = SelectSingleProps; diff --git a/new-ui/src/shared/components/SizedBox/SizedBox.tsx b/new-ui/src/shared/components/SizedBox/SizedBox.tsx new file mode 100644 index 000000000..544312fd4 --- /dev/null +++ b/new-ui/src/shared/components/SizedBox/SizedBox.tsx @@ -0,0 +1,19 @@ +import './style.scss'; + +type Props = { + height: string | number; + width?: string | number; +}; + +/**Spawns a block with a strict size, meant to fill spaces that are not regular like layouts that can't utilize "gap" css property due to irregular gaps in across same axis*/ +export const SizedBox = ({ width, height }: Props) => { + return ( +
+ ); +}; diff --git a/new-ui/src/shared/components/SizedBox/style.scss b/new-ui/src/shared/components/SizedBox/style.scss new file mode 100644 index 000000000..7897d8cce --- /dev/null +++ b/new-ui/src/shared/components/SizedBox/style.scss @@ -0,0 +1,7 @@ +.sized-box { + display: block; + user-select: none; + pointer-events: none; + content: ''; + flex: 0 0 auto; +} diff --git a/new-ui/src/shared/components/Split/Split.tsx b/new-ui/src/shared/components/Split/Split.tsx new file mode 100644 index 000000000..daea8804a --- /dev/null +++ b/new-ui/src/shared/components/Split/Split.tsx @@ -0,0 +1,22 @@ +import type { CSSProperties, PropsWithChildren } from 'react'; +import { ThemeSpacing, type ThemeSpacingValue } from '../../types'; + +type Props = PropsWithChildren<{ + split?: number; + spacing?: ThemeSpacingValue; +}>; + +export const Split = ({ children, split = 2, spacing = ThemeSpacing.Sm }: Props) => { + const style: CSSProperties = { + display: 'grid', + gridTemplateColumns: `repeat(${split}, 1fr)`, + columnGap: spacing, + width: '100%', + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/new-ui/src/shared/components/Timer/Timer.tsx b/new-ui/src/shared/components/Timer/Timer.tsx new file mode 100644 index 000000000..d8cf2fbe1 --- /dev/null +++ b/new-ui/src/shared/components/Timer/Timer.tsx @@ -0,0 +1,42 @@ +import './style.scss'; +import dayjs from 'dayjs'; +import { useEffect, useState } from 'react'; +import { interval, type Subscription } from 'rxjs'; +import { ThemeVariable } from '../../types'; +import { formatDuration } from '../../utils/formatDuration'; +import { Icon, IconKind } from '../Icon'; +import type { TimerProps } from './types'; + +function formatTimeLeft(deadline: string): string | null { + const diff = dayjs(deadline).diff(dayjs()); + if (diff <= 0) return null; + return formatDuration(dayjs.duration(diff)); +} + +export const Timer = ({ deadline }: TimerProps) => { + const [timeLeft, setTimeLeft] = useState(() => formatTimeLeft(deadline)); + + useEffect(() => { + setTimeLeft(formatTimeLeft(deadline)); + + const diff = dayjs(deadline).diff(dayjs.utc()); + if (diff <= 0) return; + + const sub: Subscription = interval(1_000).subscribe(() => { + const label = formatTimeLeft(deadline); + setTimeLeft(label); + if (!label) sub.unsubscribe(); + }); + + return () => sub.unsubscribe(); + }, [deadline]); + + if (!timeLeft) return null; + + return ( +
+ +

{`Time left: ${timeLeft}`}

+
+ ); +}; diff --git a/new-ui/src/shared/components/Timer/style.scss b/new-ui/src/shared/components/Timer/style.scss new file mode 100644 index 000000000..b1b8e5266 --- /dev/null +++ b/new-ui/src/shared/components/Timer/style.scss @@ -0,0 +1,14 @@ +.timer { + display: inline-flex; + flex-flow: row nowrap; + column-gap: var(--spacing-sm); + align-items: center; + justify-content: center; + user-select: none; + pointer-events: none; + + p { + font: var(--t-body-xxs-400); + color: var(--fg-white-60); + } +} diff --git a/new-ui/src/shared/components/Timer/types.ts b/new-ui/src/shared/components/Timer/types.ts new file mode 100644 index 000000000..980036961 --- /dev/null +++ b/new-ui/src/shared/components/Timer/types.ts @@ -0,0 +1,3 @@ +export interface TimerProps { + deadline: string; +} diff --git a/new-ui/src/shared/components/Toggle/Toggle.tsx b/new-ui/src/shared/components/Toggle/Toggle.tsx new file mode 100644 index 000000000..a0713353a --- /dev/null +++ b/new-ui/src/shared/components/Toggle/Toggle.tsx @@ -0,0 +1,35 @@ +import './style.scss'; +import clsx from 'clsx'; +import { isPresent } from '../../utils/isPresent'; +import type { ToggleProps } from './types'; + +export const Toggle = ({ + active, + testId, + label, + disabled = false, + onClick, +}: ToggleProps) => { + return ( +
{ + if (!disabled) { + onClick?.(e); + } + }} + > +
+
+
+ {isPresent(label) &&

{label}

} +
+ ); +}; diff --git a/new-ui/src/shared/components/Toggle/style.scss b/new-ui/src/shared/components/Toggle/style.scss new file mode 100644 index 000000000..6fe1cb840 --- /dev/null +++ b/new-ui/src/shared/components/Toggle/style.scss @@ -0,0 +1,73 @@ +.toggle { + --circle-x: 3px; + --circle-shadow: 0 1px 1px 0 rgb(0 0 0 0); + --border: var(--bg); + --bg: var(--bg-white-30); + --circle: var(--fg-white-100); + + cursor: pointer; + display: inline-flex; + flex-flow: row nowrap; + align-items: flex-start; + justify-content: flex-start; + column-gap: var(--spacing-md); + + .inner { + user-select: none; + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + width: 36px; + height: 20px; + box-sizing: border-box; + border-radius: var(--radius-full); + background-color: var(--bg); + border: var(--border-1) solid var(--border); + min-width: 36px; + flex-shrink: 0; + background-clip: padding-box; + + @include animate(border-color, background-color); + + .circle { + display: inline-block; + width: 14px; + height: 14px; + background-color: var(--circle); + margin-left: var(--circle-x); + border-radius: var(--radius-full); + box-shadow: var(--circle-shadow); + + @include animate(background-color, margin-left, box-shadow); + } + } + + & > p { + user-select: none; + font: var(--t-body-sm-400); + color: var(--fg-white-100); + + @include animate(color); + } + + &.disabled { + --circle: var(--border-disabled); + --circle-x: 3px; + --border: var(--circle); + --bg: var(--bg-white-5); + + cursor: not-allowed; + + p { + color: var(--fg-white-60); + } + } + + &:not(.disabled).active { + --border: var(--bg); + --bg: var(--bg-white-90); + --circle: var(--c-saturated-additional-blue-neutral); + --circle-x: 17px; + } +} diff --git a/new-ui/src/shared/components/Toggle/types.ts b/new-ui/src/shared/components/Toggle/types.ts new file mode 100644 index 000000000..5cf0c9fe1 --- /dev/null +++ b/new-ui/src/shared/components/Toggle/types.ts @@ -0,0 +1,9 @@ +import type { MouseEventHandler } from 'react'; + +export interface ToggleProps { + active: boolean; + disabled?: boolean; + label?: string; + onClick?: MouseEventHandler; + testId?: string; +} diff --git a/new-ui/src/shared/components/Tooltip/Tooltip.tsx b/new-ui/src/shared/components/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..f80a508ae --- /dev/null +++ b/new-ui/src/shared/components/Tooltip/Tooltip.tsx @@ -0,0 +1,29 @@ +import './style.scss'; +import clsx from 'clsx'; +import { type MotionProps, motion } from 'motion/react'; +import type { HTMLProps, PropsWithChildren, Ref } from 'react'; +import { motionTransitionStandard } from '../../consts'; + +export const Tooltip = ({ + ref, + children, + className, + ...rest +}: PropsWithChildren & { + ref?: Ref; +} & HTMLProps & + MotionProps) => { + return ( + + {children} + + ); +}; diff --git a/new-ui/src/shared/components/Tooltip/style.scss b/new-ui/src/shared/components/Tooltip/style.scss new file mode 100644 index 000000000..1e8a084d4 --- /dev/null +++ b/new-ui/src/shared/components/Tooltip/style.scss @@ -0,0 +1,20 @@ +.tooltip { + --background: var(--bg-white-100); + --border: 1px solid var(--background); + + box-sizing: border-box; + padding: var(--spacing-sm) var(--spacing-md); + z-index: 5; + background-color: var(--background); + max-width: 250px; + box-shadow: var(--menu-shadow); + border: var(--border); + border-radius: 8px; + + span, + p, + a { + font: var(--t-body-xs-400); + color: var(--fg-black); + } +} diff --git a/new-ui/src/shared/components/TooltipButton/TooltipButton.tsx b/new-ui/src/shared/components/TooltipButton/TooltipButton.tsx new file mode 100644 index 000000000..c6af97f15 --- /dev/null +++ b/new-ui/src/shared/components/TooltipButton/TooltipButton.tsx @@ -0,0 +1,73 @@ +import { + autoUpdate, + FloatingPortal, + offset, + shift, + useFloating, +} from '@floating-ui/react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; +import type { Subject } from 'rxjs'; +import { Button } from '../Button/Button'; +import type { ButtonProps } from '../Button/types'; +import { Tooltip } from '../Tooltip/Tooltip'; + +interface Props { + tooltipText: string; + buttonProps: ButtonProps; + tooltipTimeout?: number; + tooltipTrigger?: Subject; +} + +export const TooltipButton = ({ + buttonProps, + tooltipText, + tooltipTrigger, + tooltipTimeout = 1_500, +}: Props) => { + const [tooltipVisible, setTooltipVisible] = useState(false); + + const { refs, floatingStyles } = useFloating({ + placement: 'top', + whileElementsMounted: autoUpdate, + middleware: [offset(15), shift({ padding: 4 })], + open: tooltipVisible, + onOpenChange: setTooltipVisible, + }); + + useEffect(() => { + if (!tooltipTrigger) return; + const sub = tooltipTrigger.subscribe(() => setTooltipVisible(true)); + return () => sub.unsubscribe(); + }, [tooltipTrigger]); + + useEffect(() => { + if (!tooltipVisible) return; + const timeout = setTimeout(() => setTooltipVisible(false), tooltipTimeout); + return () => clearTimeout(timeout); + }, [tooltipVisible, tooltipTimeout]); + + const referenceProps = useMemo((): ButtonProps => { + const base: ButtonProps = { ...buttonProps, ref: refs.setReference }; + if (tooltipTrigger) return base; + return { + ...base, + onClick: (e) => { + buttonProps.onClick?.(e); + setTooltipVisible(true); + }, + }; + }, [buttonProps, refs.setReference, tooltipTrigger]); + + return ( + + + + + + + ); +}; diff --git a/new-ui/src/shared/components/WindowDecorations/style.scss b/new-ui/src/shared/components/WindowDecorations/style.scss new file mode 100644 index 000000000..e5920f089 --- /dev/null +++ b/new-ui/src/shared/components/WindowDecorations/style.scss @@ -0,0 +1,75 @@ +#window-decorations { + --controls-display: none; + + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-end; + width: 100%; + height: var(--window-decorations-height); + border-bottom: 1px solid var(--border-disabled); + box-sizing: border-box; + + &.macos { + > .window-drag { + margin-left: 100px; + } + } + + &.windows { + --controls-display: flex; + } + + > .window-drag { + content: ' '; + display: flex; + flex: 1 1 auto; + flex-flow: row; + min-width: 0; + height: 100%; + } + + > .window-controls { + display: var(--controls-display); + flex-flow: row nowrap; + } + + button { + --bg: transparent; + --icon: var(--fg-white-80); + + border: 0; + background-color: var(--bg); + width: 46px; + height: 32px; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + justify-content: center; + cursor: pointer; + + @include animate(background-color); + + &:not(.close):hover { + --bg: var(--bg-white-10); + --icon: var(--fg-white-100); + } + + &.close:hover { + --bg: var(--bg-critical); + --icon: var(--fg-white-100); + } + + &.minimize svg path { + stroke: var(--icon); + } + + &.maximize svg rect { + stroke: var(--icon); + } + + &.close svg path { + fill: var(--icon); + } + } +} diff --git a/new-ui/src/shared/components/WindowHeader/WindowHeader.tsx b/new-ui/src/shared/components/WindowHeader/WindowHeader.tsx new file mode 100644 index 000000000..25ff47939 --- /dev/null +++ b/new-ui/src/shared/components/WindowHeader/WindowHeader.tsx @@ -0,0 +1,74 @@ +import clsx from 'clsx'; +import './style.scss'; +import { useQuery } from '@tanstack/react-query'; +import { getVersion } from '@tauri-apps/api/app'; +import { useId } from 'react'; +import { isPresent } from '../../utils/isPresent'; +import { ConnectionWatcher } from './components/ConnectionWatcher/ConnectionsWatcher'; + +interface Props { + variant: 'compact' | 'desktop'; +} + +export const WindowHeader = ({ variant }: Props) => { + const { data: appVersion } = useQuery({ + queryFn: getVersion, + queryKey: ['app-version'], + }); + + const version = () => { + if (appVersion) { + return `Version ${appVersion}`; + } + }; + + return ( +
+ +
+

Defguard VPN Client

+ {variant === 'compact' && } + {variant === 'desktop' && isPresent(appVersion) && ( +

{version()}

+ )} +
+ {variant === 'desktop' && ( +
+ +
+ )} +
+ ); +}; + +const LogoIcon = ({ size = 48 }: { size?: number }) => { + const id = useId(); + return ( + + + + + + + + + + + ); +}; diff --git a/new-ui/src/shared/components/WindowHeader/components/ConnectionWatcher/ConnectionsWatcher.tsx b/new-ui/src/shared/components/WindowHeader/components/ConnectionWatcher/ConnectionsWatcher.tsx new file mode 100644 index 000000000..7a1ab3b99 --- /dev/null +++ b/new-ui/src/shared/components/WindowHeader/components/ConnectionWatcher/ConnectionsWatcher.tsx @@ -0,0 +1,173 @@ +import './style.scss'; +import type { Placement } from '@floating-ui/react'; +import { + autoUpdate, + FloatingPortal, + size as floatingSize, + offset, + safePolygon, + shift, + useDismiss, + useFloating, + useHover, + useInteractions, +} from '@floating-ui/react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import clsx from 'clsx'; +import { useEffect, useMemo, useState } from 'react'; +import { Snackbar } from '../../../../providers/snackbar/snackbar'; +import { api } from '../../../../rust-api/api'; +import { Direction, ThemeSpacing, ThemeVariable } from '../../../../types'; +import { isPresent } from '../../../../utils/isPresent'; +import { Divider } from '../../../Divider/Divider'; +import { FloatingMenu } from '../../../FloatingMenu/FloatingMenu'; +import { Icon } from '../../../Icon'; + +type Props = { + placement?: Placement; +}; + +export const ConnectionWatcher = ({ placement = 'bottom-start' }: Props) => { + const { mutate: disconnect } = useMutation({ + mutationFn: api.disconnect, + onError: () => { + Snackbar.error('Failed to disconnect.'); + }, + }); + + const { data: connections } = useQuery({ + queryKey: ['alive-connection'], + queryFn: api.getAllActiveConnections, + refetchInterval: 5_000, + }); + + const connected = (connections?.length ?? 0) > 0; + + const [floatingOpen, setFloatingOpen] = useState(false); + + const disconnectAllPromise = useMemo(() => { + return () => + Promise.all( + (connections ?? []).map((connection) => + api.disconnect({ + locationId: connection.id, + connectionType: connection.connection_type, + }), + ), + ); + }, [connections]); + + const { mutate: disconnectAll } = useMutation({ + mutationFn: disconnectAllPromise, + onError: () => { + Snackbar.error('Failed to disconnect all locations.'); + }, + }); + + useEffect(() => { + if (!connected) { + setFloatingOpen(false); + } + }, [connected]); + + const { refs, context, floatingStyles } = useFloating({ + placement, + open: floatingOpen, + onOpenChange: setFloatingOpen, + middleware: [ + offset(4), + shift({ padding: 4 }), + floatingSize({ + apply({ rects, elements }) { + elements.floating.style.minWidth = `${rects.reference.width}px`; + }, + }), + ], + whileElementsMounted: autoUpdate, + }); + + const hover = useHover(context, { + handleClose: safePolygon(), + enabled: connected, + }); + + const dismiss = useDismiss(context, { + ancestorScroll: true, + outsidePress: true, + }); + + const { getFloatingProps, getReferenceProps } = useInteractions([hover, dismiss]); + + return ( + <> +
+ {!connected &&

Not connected

} + {connected && isPresent(connections) && ( +
+ +

{`Connected (${connections.length})`}

+ +
+ )} +
+ {floatingOpen && ( + + +

Connected locations

+ {connections?.map((con) => ( +
{ + disconnect({ + locationId: con.id, + connectionType: con.connection_type, + }); + }} + > + + + +

{con.name}

+
+ ))} + + +
+
+ )} + + ); +}; diff --git a/new-ui/src/shared/components/WindowHeader/components/ConnectionWatcher/style.scss b/new-ui/src/shared/components/WindowHeader/components/ConnectionWatcher/style.scss new file mode 100644 index 000000000..857709fc4 --- /dev/null +++ b/new-ui/src/shared/components/WindowHeader/components/ConnectionWatcher/style.scss @@ -0,0 +1,119 @@ +.connection-watcher { + display: block; + box-sizing: border-box; + border-radius: 8px; + user-select: none; + min-height: 20px; + + &:not(.connected) { + padding: 0 var(--spacing-sm); + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + justify-content: center; + border: 1px solid var(--border-default); + background: transparent; + flex-grow: 0; + min-width: 0; + + .no-connection-label { + font: var(--t-body-xxs-500); + color: var(--fg-white-60); + } + } + + &.connected { + display: flex; + flex-flow: row nowrap; + align-items: center; + padding: 0 var(--spacing-xs); + background-color: var(--bg-success); + + > .connected-row { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-xs); + + p { + font: var(--t-body-xxs-500); + color: var(--fg-action); + } + } + } +} + +.connection-watcher-floating { + display: flex; + flex-flow: column; + + .label { + box-sizing: border-box; + padding-left: var(--spacing-sm); + font: var(--t-menu-title); + color: var(--fg-white-60); + min-height: 24px; + line-height: 24px; + font-weight: 400; + } + + .connection { + --bg: transparent; + + border-radius: 8px; + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + box-sizing: border-box; + padding: 0 var(--spacing-sm); + column-gap: var(--spacing-md); + min-height: 36px; + user-select: none; + cursor: pointer; + background: var(--bg); + + @include animate(background); + + &:hover { + --bg: var(--bg-white-5); + } + + svg circle { + fill: var(--bg-success); + } + + p { + font: var(--t-menu-text); + } + } + + .disconnect { + --bg: transparent; + + border-radius: 8px; + box-sizing: border-box; + background: var(--bg); + border: 0; + padding: 0 var(--spacing-sm); + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-sm); + min-height: 36px; + cursor: pointer; + + @include animate(background); + + &:hover { + --bg: var(--bg-white-5); + } + + p { + font: var(--t-menu-text); + color: var(--fg-white-100); + } + } +} diff --git a/new-ui/src/shared/components/WindowHeader/style.scss b/new-ui/src/shared/components/WindowHeader/style.scss new file mode 100644 index 000000000..f0ec3cf57 --- /dev/null +++ b/new-ui/src/shared/components/WindowHeader/style.scss @@ -0,0 +1,50 @@ +#window-header { + &.variant { + &-compact { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-lg); + padding-bottom: var(--spacing-lg); + + > .info { + row-gap: var(--spacing-xs); + } + } + + &-desktop { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-lg); + padding: var(--spacing-sm) var(--spacing-md); + border-bottom: 1px solid var(--border-disabled); + background: var(--bg-white-10); + + .right { + margin-left: auto; + } + } + } + + > .info { + display: flex; + flex-flow: column; + align-items: flex-start; + row-gap: 0; + user-select: none; + + > .label { + font: var(--t-body-sm-500); + color: var(--fg-white-100); + text-align: left; + } + + > .version { + font: var(--t-body-xxs-400); + color: var(--fg-white-60); + } + } +} diff --git a/new-ui/src/shared/components/form/FormInput/FormInput.tsx b/new-ui/src/shared/components/form/FormInput/FormInput.tsx new file mode 100644 index 000000000..70a5d8fd4 --- /dev/null +++ b/new-ui/src/shared/components/form/FormInput/FormInput.tsx @@ -0,0 +1,74 @@ +import { useStore } from '@tanstack/react-form'; +import { useMemo } from 'react'; +import type { z } from 'zod'; +import { useFieldContext, useFormContext } from '../../../form-context'; +import { isPresent } from '../../../utils/isPresent'; +import { Input } from '../../Input/Input'; +import type { FormInputProps, InputProps } from '../../Input/types'; + +export const FormInput = ({ mapError, onDismiss, ...props }: FormInputProps) => { + const field = useFieldContext(); + const form = useFormContext(); + + const boxProps = useMemo(() => { + if (isPresent(onDismiss) && (props.type === 'text' || !props.type)) { + const boxProps: InputProps['boxProps'] = { + iconRight: 'delete', + onInteractionClick: (e) => { + onDismiss(e); + }, + }; + return boxProps; + } + return undefined; + }, [onDismiss, props.type]); + + // allows field to show error even if isPristine is true, this is needed in cases as input required or checkbox checked but user just clicked submit. + // Keyed on submissionAttempts rather than isSubmitSuccessful: a submit whose + // handler injects server-side field errors via setErrorMap (without throwing) + // is still recorded as "successful" by the form, which would otherwise hide + // the error on a pristine field. + const wasSubmitted = useStore(form.store, (store) => store.submissionAttempts > 0); + + const isPristine = useStore(field.store, (state) => state.meta.isPristine); + + const errorState = useStore( + field.store, + // normally this should be ZodIssue but sometime's we want to add some post submit validation and there we probably want to set only error messages + (state) => state.meta.errors as Array, + ); + + const errorMessage = useMemo(() => { + // ignore errors unless some touches the field or submit's the form + if (isPristine && !wasSubmitted) return undefined; + + const fieldError = errorState[0]; + + if (fieldError) { + if (typeof fieldError === 'string') { + if (isPresent(mapError)) { + return mapError(fieldError); + } + return fieldError; + } else { + if (isPresent(mapError)) { + return mapError(fieldError.message); + } + return fieldError.message; + } + } + return undefined; + }, [mapError, errorState[0], isPristine, wasSubmitted]); + + return ( + + ); +}; diff --git a/new-ui/src/shared/components/wizard/WizardPage/WizardPage.tsx b/new-ui/src/shared/components/wizard/WizardPage/WizardPage.tsx new file mode 100644 index 000000000..f8497dcb2 --- /dev/null +++ b/new-ui/src/shared/components/wizard/WizardPage/WizardPage.tsx @@ -0,0 +1,77 @@ +import { + Fragment, + type HTMLProps, + type PropsWithChildren, + Suspense, + useMemo, +} from 'react'; +import './style.scss'; +import clsx from 'clsx'; +import { sort } from 'radashi'; +import Skeleton from 'react-loading-skeleton'; +import { ThemeSpacing } from '../../../types'; +import { SizedBox } from '../../SizedBox/SizedBox'; +import type { WizardPageConfig } from '../types'; +import { WizardStepsCard } from '../WizardStepsCard/WizardStepsCard'; + +type Props = PropsWithChildren & + WizardPageConfig & { + className?: string; + containerProps?: Omit, 'className'>; + }; + +export const WizardPage = ({ + className, + activeStep, + steps, + title, + children, + containerProps, +}: Props) => { + const activeStepData = steps[activeStep]; + + const visibleSteps = useMemo( + () => + sort( + Object.values(steps).filter((step) => !step.hidden), + (s) => s.order, + ), + [steps], + ); + + const activeStepIndex = useMemo( + () => visibleSteps.findIndex((s) => s.id === activeStep), + [visibleSteps, activeStep], + ); + + return ( +
+
+
+
+

{title}

+ + +
+
+
+ {activeStepIndex !== visibleSteps.length - 1 && ( + +
+

{`Step ${activeStepIndex + 1} of ${visibleSteps.length}`}

+
+ +
+ )} + }>{children} +
+
+
+ ); +}; + +const WizardStepSkeleton = () => { + return ( + + ); +}; diff --git a/new-ui/src/shared/components/wizard/WizardPage/style.scss b/new-ui/src/shared/components/wizard/WizardPage/style.scss new file mode 100644 index 000000000..24fc253fc --- /dev/null +++ b/new-ui/src/shared/components/wizard/WizardPage/style.scss @@ -0,0 +1,108 @@ +.wizard-page { + --page-content-limit: 536px; + + height: calc(100dvh - var(--window-decorations-height)); + overflow-y: auto; + + > .page-grid { + display: grid; + grid-template-columns: 232px minmax(0, 1fr); + grid-template-rows: 1fr; + height: 100%; + + & > .side { + position: relative; + box-sizing: border-box; + padding: var(--spacing-md) var(--spacing-lg); + border-right: 1px solid var(--border-disabled); + user-select: none; + + > .side-content { + position: sticky; + top: var(--spacing-md); + align-self: start; + } + + .title { + font: var(--t-body-xs-500); + color: var(--fg-white-100); + } + } + + & > .main { + display: flex; + flex-flow: column; + align-items: flex-start; + justify-content: flex-start; + box-sizing: border-box; + padding: var(--spacing-md) var(--spacing-lg) var(--spacing-lg); + + > .step-content { + display: contents; + + > header { + h1 { + font: var(--t-primary-500); + color: var(--c-white-100); + } + + p { + font: var(--t-body-sm-400); + color: var(--fg-white-70); + } + } + + .input.spacer { + width: 100%; + } + + form { + display: contents; + } + } + + > .wizard-step-badge { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + flex-grow: 0; + flex-shrink: 1; + user-select: none; + box-sizing: border-box; + border: 1px solid var(--border-default); + background: transparent; + border-radius: 100px; + min-height: 24px; + padding: 0 var(--spacing-md); + + p, + span { + font: var(--t-body-xxs-500); + color: var(--fg-white-100); + } + } + } + + .layout-grid { + padding-top: var(--spacing-4xl); + } + + .layout-grid > .side { + .title { + font: var(--t-title-h3); + color: var(--fg-default); + } + + .description { + font: var(--t-body-sm-400); + color: var(--fg-muted); + } + } + } +} + +.wizard-page .main .controls { + margin-top: auto; + width: 100%; +} diff --git a/new-ui/src/shared/components/wizard/WizardStepsCard/WizardStepsCard.tsx b/new-ui/src/shared/components/wizard/WizardStepsCard/WizardStepsCard.tsx new file mode 100644 index 000000000..b6e71ef8c --- /dev/null +++ b/new-ui/src/shared/components/wizard/WizardStepsCard/WizardStepsCard.tsx @@ -0,0 +1,38 @@ +import { Fragment } from 'react/jsx-runtime'; +import type { WizardPageStep } from '../types'; +import './style.scss'; +import clsx from 'clsx'; +import { Icon } from '../../Icon'; + +interface Props { + activeStep: WizardPageStep; + steps: WizardPageStep[]; +} + +export const WizardStepsCard = ({ steps, activeStep }: Props) => { + return ( +
+
    + {steps.map((step, index) => ( + +
  • activeStep.order, + active: step.id === activeStep.id, + success: step.order < activeStep.order, + })} + > +
    +
    + {step.order < activeStep.order && } + {step.order >= activeStep.order && {index + 1}} +
    + {step.label} +
  • +
    + ))} +
+
+ ); +}; diff --git a/new-ui/src/shared/components/wizard/WizardStepsCard/style.scss b/new-ui/src/shared/components/wizard/WizardStepsCard/style.scss new file mode 100644 index 000000000..e81b5fa1d --- /dev/null +++ b/new-ui/src/shared/components/wizard/WizardStepsCard/style.scss @@ -0,0 +1,95 @@ +.wizard-steps-card { + box-sizing: border-box; + border-radius: var(--radius-xl); + background-color: var(--bg-disabled); + + ul { + list-style: none; + display: flex; + flex-flow: column; + row-gap: var(--spacing-xl); + + li { + --indicator-color: var(--fg-white); + --color: var(--fg-muted); + --circle-bg: transparent; + --circle-border: var(--border-default); + + display: flex; + flex-flow: row nowrap; + column-gap: var(--spacing-md); + + &.muted { + --color: var(--fg-white-70); + --circle-bg: transparent; + --circle-border: var(--border-default); + --indicator-color: var(--fg-white-60); + } + + &.active { + --color: var(--fg-white-70); + --circle-bg: var(--bg-white-100); + --circle-border: var(--circle-bg); + --indicator-color: var(--fg-action); + } + + &.success { + --color: var(--fg-white-70); + --circle-bg: transparent; + --circle-border: var(--border-default); + --indicator-color: var(--fg-white-100); + } + + & > span { + font: var(--t-body-sm-400); + color: var(--color); + + @include animate(color); + } + + .step-indicator { + display: grid; + grid-template-columns: 20px; + grid-template-rows: 20px; + place-items: center center; + + .icon { + display: flex; + } + + .icon path { + fill: var(--indicator-color); + } + + .icon, + span, + div { + grid-row: 1; + grid-column: 1 / 2; + } + + span { + font: var(--t-body-xs-500); + color: var(--indicator-color); + width: 100%; + max-width: 100%; + text-align: center; + + @include animate(color); + } + + .circle { + display: block; + content: ' '; + width: 100%; + height: 100%; + border-radius: var(--radius-full); + background-color: var(--circle-bg); + border: 1px solid var(--circle-border); + + @include animate(background-color); + } + } + } + } +} diff --git a/new-ui/src/shared/components/wizard/types.ts b/new-ui/src/shared/components/wizard/types.ts new file mode 100644 index 000000000..d30aac5f3 --- /dev/null +++ b/new-ui/src/shared/components/wizard/types.ts @@ -0,0 +1,19 @@ +export interface WizardPageConfig { + title: string; + subtitle: string; + activeStep: string; + steps: Record; +} + +export interface WizardDocsLink { + link: string; + label: string; +} + +export interface WizardPageStep { + id: string; + order: number; + label: string; + description?: string; + hidden?: boolean; +} diff --git a/new-ui/src/shared/consts.ts b/new-ui/src/shared/consts.ts new file mode 100644 index 000000000..636fca7a6 --- /dev/null +++ b/new-ui/src/shared/consts.ts @@ -0,0 +1,10 @@ +export const motionTransitionStandard = { + type: 'tween', + ease: 'easeOut', + duration: 0.16, +} as const; + +export const WindowId = { + FullView: 'full-view', + CompactView: 'compact-view', +} as const; diff --git a/new-ui/src/shared/form-context.tsx b/new-ui/src/shared/form-context.tsx new file mode 100644 index 000000000..a6705eabe --- /dev/null +++ b/new-ui/src/shared/form-context.tsx @@ -0,0 +1,4 @@ +import { createFormHookContexts } from '@tanstack/react-form'; + +export const { fieldContext, formContext, useFieldContext, useFormContext } = + createFormHookContexts(); diff --git a/new-ui/src/shared/form.tsx b/new-ui/src/shared/form.tsx new file mode 100644 index 000000000..80eab43ef --- /dev/null +++ b/new-ui/src/shared/form.tsx @@ -0,0 +1,14 @@ +import { createFormHook } from '@tanstack/react-form'; +import { FormInput } from './components/form/FormInput/FormInput'; +import { fieldContext, formContext } from './form-context'; + +export { useFieldContext, useFormContext } from './form-context'; + +export const { useAppForm, withFieldGroup, withForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { + FormInput, + }, + formComponents: {}, +}); diff --git a/new-ui/src/shared/formLogic.ts b/new-ui/src/shared/formLogic.ts new file mode 100644 index 000000000..11aa1d6d3 --- /dev/null +++ b/new-ui/src/shared/formLogic.ts @@ -0,0 +1,6 @@ +import { revalidateLogic } from '@tanstack/react-form'; + +export const formChangeLogic = revalidateLogic({ + mode: 'change', + modeAfterSubmission: 'change', +}); diff --git a/new-ui/src/shared/hooks/confirmModal/useConfirmModal.tsx b/new-ui/src/shared/hooks/confirmModal/useConfirmModal.tsx new file mode 100644 index 000000000..7d3942671 --- /dev/null +++ b/new-ui/src/shared/hooks/confirmModal/useConfirmModal.tsx @@ -0,0 +1,37 @@ +import { create } from 'zustand'; +import type { ButtonProps } from '../../components/Button/types'; + +type StoreValues = { + visible: boolean; + title: string; + content?: string | null; + cancelProps: ButtonProps | null; + submitProps: ButtonProps | null; + onSubmit: () => Promise; +}; + +const emptyPromise = async () => {}; + +const defaults: StoreValues = { + visible: false, + title: 'Confirm action', + content: null, + submitProps: null, + cancelProps: null, + onSubmit: emptyPromise, +}; + +interface Store extends StoreValues { + open: (values: Partial) => void; + reset: () => void; +} + +export const useConfirmModal = create()((set) => ({ + ...defaults, + open: (values) => { + set({ ...defaults, ...values, visible: true }); + }, + reset: () => { + set(defaults); + }, +})); diff --git a/new-ui/src/shared/hooks/modalControls/modalTypes.ts b/new-ui/src/shared/hooks/modalControls/modalTypes.ts new file mode 100644 index 000000000..0d4793f2c --- /dev/null +++ b/new-ui/src/shared/hooks/modalControls/modalTypes.ts @@ -0,0 +1,22 @@ +import z from 'zod'; +import type { OpenUpdateInstanceModalData, OpenUpdateTunnelModalData } from './types'; + +export const ModalName = { + UpdateInstance: 'update-instance', + UpdateTunnel: 'update-tunnel', +} as const; + +export type ModalNameValue = (typeof ModalName)[keyof typeof ModalName]; + +const modalOpenArgsSchema = z.discriminatedUnion('name', [ + z.object({ + name: z.literal(ModalName.UpdateInstance), + data: z.custom(), + }), + z.object({ + name: z.literal(ModalName.UpdateTunnel), + data: z.custom(), + }), +]); + +export type ModalOpenEvent = z.infer; diff --git a/new-ui/src/shared/hooks/modalControls/modalsSubjects.ts b/new-ui/src/shared/hooks/modalControls/modalsSubjects.ts new file mode 100644 index 000000000..14e507327 --- /dev/null +++ b/new-ui/src/shared/hooks/modalControls/modalsSubjects.ts @@ -0,0 +1,53 @@ +import { filter, Subject, type Subscription } from 'rxjs'; +import type { ModalNameValue, ModalOpenEvent } from './modalTypes'; + +export const openModalSubject = new Subject(); + +export const closeModalSubject = new Subject(); + +export function subscribeOpenModal( + name: N, + handler: Extract extends { data: infer D } + ? (data: D) => void + : () => void, +): Subscription { + return openModalSubject + .pipe(filter((e): e is Extract => e.name === name)) + .subscribe((e) => { + if ('data' in e) (handler as (d: unknown) => void)(e.data); + else (handler as () => void)(); + }); +} + +export const subscribeCloseModal = ( + name: N, + handler: () => void, +) => { + return closeModalSubject.pipe(filter((e) => e === name)).subscribe(() => handler()); +}; + +export const closeModal = (name: T) => { + closeModalSubject.next(name); +}; + +type PayloadOf = + Extract extends { + data: infer D; + } + ? D + : undefined; + +export function openModal( + name: N, + ...args: PayloadOf extends undefined ? [] : [PayloadOf] +) { + const event = ( + args.length === 0 ? { name } : { name, data: args[0] } + ) as ModalOpenEvent; + + // blur whatever right now has focus + const activeElement = document.activeElement as HTMLElement | null; + activeElement?.blur(); + + openModalSubject.next(event); +} diff --git a/new-ui/src/shared/hooks/modalControls/types.ts b/new-ui/src/shared/hooks/modalControls/types.ts new file mode 100644 index 000000000..d8d0dc8e1 --- /dev/null +++ b/new-ui/src/shared/hooks/modalControls/types.ts @@ -0,0 +1,8 @@ +import type { TunnelInfo } from '../../rust-api/types'; + +export type OpenUpdateInstanceModalData = { + instanceId: number; + url: string; +}; + +export type OpenUpdateTunnelModalData = TunnelInfo; diff --git a/new-ui/src/shared/hooks/useDeferredCallback.tsx b/new-ui/src/shared/hooks/useDeferredCallback.tsx new file mode 100644 index 000000000..ca3cf3feb --- /dev/null +++ b/new-ui/src/shared/hooks/useDeferredCallback.tsx @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react'; +import { EMPTY, Subject, timer } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +export function useDeferredCallback(callback: () => void) { + const delay$ = useRef(new Subject()); + const callbackRef = useRef(callback); + + useEffect(() => { + const sub = delay$.current + .pipe(switchMap((ms) => (ms > 0 ? timer(ms) : EMPTY))) + .subscribe(() => { + callbackRef.current(); + }); + return () => { + sub.unsubscribe(); + }; + }, []); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + return { + start: (delayMs: number) => { + delay$.current.next(delayMs); + }, + cancel: () => { + delay$.current.next(0); // cancel current timer + }, + }; +} diff --git a/new-ui/src/shared/hooks/useUpdateAvailable.ts b/new-ui/src/shared/hooks/useUpdateAvailable.ts new file mode 100644 index 000000000..b4ce5f2a2 --- /dev/null +++ b/new-ui/src/shared/hooks/useUpdateAvailable.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { getVersion } from '@tauri-apps/api/app'; + +import { getLatestAppVersionQueryOptions } from '../rust-api/query'; +import { isVersionGreater } from '../utils/compareVersions'; + +export const useUpdateAvailable = (): boolean => { + const { data: latest } = useQuery(getLatestAppVersionQueryOptions); + const { data: currentVersion } = useQuery({ + queryKey: ['app-version'] as const, + queryFn: () => getVersion(), + }); + + return ( + latest !== undefined && + currentVersion !== undefined && + isVersionGreater(latest.version, currentVersion) + ); +}; diff --git a/new-ui/src/shared/layouts/FullPage/FullPage.tsx b/new-ui/src/shared/layouts/FullPage/FullPage.tsx new file mode 100644 index 000000000..34e8de71c --- /dev/null +++ b/new-ui/src/shared/layouts/FullPage/FullPage.tsx @@ -0,0 +1,22 @@ +import clsx from 'clsx'; +import type { PropsWithChildren } from 'react'; + +interface Props extends PropsWithChildren { + id?: string; + className?: string; + hideScrollContainer?: boolean; +} + +export const FullPage = ({ + children, + id, + className, + hideScrollContainer = false, +}: Props) => { + return ( +
+ {!hideScrollContainer &&
{children}
} + {hideScrollContainer && children} +
+ ); +}; diff --git a/new-ui/src/shared/layouts/FullPageLayout/FullPageLayout.tsx b/new-ui/src/shared/layouts/FullPageLayout/FullPageLayout.tsx new file mode 100644 index 000000000..fe2d0c9f6 --- /dev/null +++ b/new-ui/src/shared/layouts/FullPageLayout/FullPageLayout.tsx @@ -0,0 +1,14 @@ +import './style.scss'; +import type { PropsWithChildren } from 'react'; +import { WindowHeader } from '../../components/WindowHeader/WindowHeader'; +import { FullViewNavigation } from './components/FullViewNavigation/FullViewNavigation'; + +export const FullPageLayout = ({ children }: PropsWithChildren) => { + return ( +
+ + + {children} +
+ ); +}; diff --git a/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/FullViewNavigation.tsx b/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/FullViewNavigation.tsx new file mode 100644 index 000000000..0a5070df9 --- /dev/null +++ b/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/FullViewNavigation.tsx @@ -0,0 +1,88 @@ +import { Link, type LinkProps } from '@tanstack/react-router'; +import { type ReactNode, useMemo } from 'react'; +import { Icon, IconKind } from '../../../../components/Icon'; +import type { IconKindValue } from '../../../../components/Icon/icon-types'; +import { useUpdateAvailable } from '../../../../hooks/useUpdateAvailable'; +import { useAppData } from '../../../../providers/AppDataContext'; +import { NavBadge } from './components/NavBadge'; +import './style.scss'; + +type NavItemDef = LinkProps & { + icon: IconKindValue; + hidden?: boolean; + badge?: ReactNode; +}; + +export const FullViewNavigation = () => { + const { isEmpty } = useAppData(); + const updateAvailable = useUpdateAvailable(); + + const bottomLinks: NavItemDef[] = useMemo( + (): NavItemDef[] => [ + { + icon: IconKind.Refresh, + to: '/full/update', + badge: updateAvailable ? : undefined, + }, + { + icon: IconKind.Report, + to: '/full/support', + }, + ], + [updateAvailable], + ); + + const topLinks: NavItemDef[] = useMemo( + (): NavItemDef[] => [ + { + icon: IconKind.Analytics, + to: '/full/overview', + hidden: isEmpty, + }, + { + icon: IconKind.PlusCircle, + to: '/full/add', + }, + { + icon: IconKind.Settings, + to: '/full/settings', + }, + { + icon: IconKind.ActivityNotes, + to: '/full/log', + }, + ], + [isEmpty], + ); + + return ( + + ); +}; + +type NavItemProps = NavItemDef; + +const NavItem = ({ icon, hidden, badge, ...linkProps }: NavItemProps) => { + if (hidden) return null; + return ( + + + {badge} + + ); +}; diff --git a/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/components/NavBadge.tsx b/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/components/NavBadge.tsx new file mode 100644 index 000000000..94c8d0e00 --- /dev/null +++ b/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/components/NavBadge.tsx @@ -0,0 +1,12 @@ +export const NavBadge = () => ( + + + +); diff --git a/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/style.scss b/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/style.scss new file mode 100644 index 000000000..48cb7d5b7 --- /dev/null +++ b/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/style.scss @@ -0,0 +1,80 @@ +#navigation { + box-sizing: border-box; + padding: var(--spacing-md) var(--spacing-sm); + border-right: 1px solid var(--border-disabled); + height: 100%; + width: 100%; + + > .track { + display: flex; + flex-flow: column; + height: 100%; + width: 100%; + align-items: center; + justify-content: flex-start; + row-gap: var(--spacing-md); + + .bottom, + .top { + display: flex; + flex-flow: column; + align-items: center; + justify-content: flex-start; + row-gap: var(--spacing-md); + flex-grow: 0; + flex-shrink: 1; + min-height: 0; + } + + > .bottom { + margin-top: auto; + } + + a { + --icon: var(--fg-white-80); + --bg: transparent; + + position: relative; + background: var(--bg); + width: 36px; + height: 36px; + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 8px; + + @include animate(background); + + &:not(.active) { + &:hover { + --bg: var(--bg-white-5); + --icon: var(--fg-white-100); + } + } + + &.active { + --bg: var(--bg-white-10); + --icon: var(--fg-white-100); + + &:hover { + --bg: var(--bg-white-20); + --icon: var(--fg-white-100); + } + } + + .icon { + --icon-color: var(--icon); + } + + .nav-badge { + position: absolute; + top: 5px; + right: 7px; + width: 8px; + height: 8px; + } + } + } +} diff --git a/new-ui/src/shared/layouts/FullPageLayout/style.scss b/new-ui/src/shared/layouts/FullPageLayout/style.scss new file mode 100644 index 000000000..59e4fd14f --- /dev/null +++ b/new-ui/src/shared/layouts/FullPageLayout/style.scss @@ -0,0 +1,37 @@ +.full-page-layout { + display: grid; + grid-template-columns: 52px 1fr; + grid-template-rows: 50px 1fr; + height: calc(100dvh - var(--window-decorations-height)); + overflow: hidden; + + #window-header { + grid-row: 1; + grid-column: 1 / 3; + } + + #navigation { + grid-column: 1 / 2; + grid-row: 2; + } + + > .page-content { + grid-row: 2; + grid-column: 2 / 3; + height: 100%; + min-width: 0; + max-height: 100%; + overflow: hidden auto; + box-sizing: border-box; + padding: var(--spacing-md) var(--spacing-lg); + + .page-description { + font: var(--t-body-sm-400); + color: var(--fg-white-70); + } + + > .scroll-container { + min-height: 100%; + } + } +} diff --git a/new-ui/src/shared/providers/AppDataContext.tsx b/new-ui/src/shared/providers/AppDataContext.tsx new file mode 100644 index 000000000..e6b60c2c2 --- /dev/null +++ b/new-ui/src/shared/providers/AppDataContext.tsx @@ -0,0 +1,115 @@ +import { useQuery } from '@tanstack/react-query'; +import { clone } from 'radashi'; +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useEffect, +} from 'react'; +import { api } from '../rust-api/api'; +import { + getInstancesQueryOptions, + getSessionStateQueryOptions, + getTunnelsQueryOptions, +} from '../rust-api/query'; +import type { + ConnectionType, + InstanceInfo, + LocationInfo, + MfaMethodValue, + OverviewViewSelection, +} from '../rust-api/types'; +import type { SharedSessionStorage } from './types'; + +interface AppDataContextValue extends SharedSessionStorage { + instances: InstanceInfo[]; + tunnels: LocationInfo[]; + isEmpty: boolean; + setViewSelection: (selection: OverviewViewSelection | null) => void; + setConnectionMethod: ( + id: number, + connectionType: ConnectionType, + method: MfaMethodValue, + ) => void; +} + +const AppDataContext = createContext(null); + +export const useAppData = (): AppDataContextValue => { + const ctx = useContext(AppDataContext); + if (!ctx) throw new Error('useAppData must be used within an AppDataProvider'); + return ctx; +}; + +export const AppDataProvider = ({ children }: PropsWithChildren) => { + const { data: instances = [], isPending: instancesPending } = useQuery( + getInstancesQueryOptions, + ); + const { data: tunnels = [], isPending: tunnelsPending } = + useQuery(getTunnelsQueryOptions); + const { data: sessionState } = useQuery(getSessionStateQueryOptions); + const isEmpty = instances.length === 0 && tunnels.length === 0; + + const setViewSelection = useCallback((selection: OverviewViewSelection | null) => { + api.patchSessionState({ view_selection: selection }); + }, []); + + // keep the selection valid when its instance/tunnel gets removed + useEffect(() => { + if (instancesPending || tunnelsPending || sessionState === undefined) return; + + const selection = sessionState.view_selection ?? null; + const isValid = + selection !== null && + (selection.kind === 'instance' + ? instances.some((instance) => instance.id === selection.id) + : tunnels.some((tunnel) => tunnel.id === selection.id)); + + if (isValid) return; + + const fallback: OverviewViewSelection | null = + instances.length > 0 + ? { kind: 'instance', id: instances[0].id } + : tunnels.length > 0 + ? { kind: 'tunnel', id: tunnels[0].id } + : null; + + if (selection === null && fallback === null) return; + + setViewSelection(fallback); + }, [ + instances, + tunnels, + instancesPending, + tunnelsPending, + sessionState, + setViewSelection, + ]); + + const setConnectionMethod = useCallback( + (id: number, conType: ConnectionType, method: MfaMethodValue) => { + const cloned = clone(sessionState?.connection_mfa_method ?? {}); + const key = `${conType.toLowerCase()}-${id}`; + cloned[key] = method; + api.patchSessionState({ connection_mfa_method: cloned }); + }, + [sessionState?.connection_mfa_method], + ); + + return ( + + {children} + + ); +}; diff --git a/new-ui/src/shared/providers/TauriEventProvider.tsx b/new-ui/src/shared/providers/TauriEventProvider.tsx new file mode 100644 index 000000000..1b15eda53 --- /dev/null +++ b/new-ui/src/shared/providers/TauriEventProvider.tsx @@ -0,0 +1,188 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { listen } from '@tauri-apps/api/event'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { debug } from '@tauri-apps/plugin-log'; +import { Fragment, type PropsWithChildren, useEffect } from 'react'; +import { mfaMethodToConnectModalView } from '../../pages/full/OverviewPage/components/ConnectModal/hooks/types'; +import { useConnectModal } from '../../pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModal'; +import { WindowId } from '../consts'; +import { useAppData } from '../providers/AppDataContext'; +import { api } from '../rust-api/api'; +import { + type AddInstanceEventPayload, + ConnectionType, + type DeadConnectionDroppedPayload, + type DeadConnectionReconnectedPayload, + type LocationInfo, + MfaMethod, + TauriEvent, + type TunnelsDisabledPayload, +} from '../rust-api/types'; +import { useAppStore } from '../store/useAppStore'; +import { decideLocationMfaMethod } from '../utils/decideLocationMfaMethod'; + +export const TauriEventProvider = ({ children }: PropsWithChildren) => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { setViewSelection } = useAppData(); + + useEffect(() => { + const unlisteners = Promise.all([ + listen(TauriEvent.AddInstance, (event) => { + void debug(`UI Received event AddInstance (${event.payload.url})`); + const windowLabel = getCurrentWindow().label; + if (windowLabel === WindowId.FullView) { + const { token, url } = event.payload; + navigate({ + to: '/full/add/instance', + search: { + token, + url, + }, + }); + } + }), + // Backend requests the MFA flow (e.g. connecting to an MFA location from the + // tray or system settings). The location is emitted as the payload directly. + listen>( + TauriEvent.MfaTrigger, + (event) => { + void debug(`UI Received event MfaTrigger: ${JSON.stringify(event.payload)}`); + const windowLabel = getCurrentWindow().label; + + if (windowLabel === WindowId.CompactView) { + const { id: locationId, instance_id: instanceId } = event.payload; + setViewSelection({ kind: 'instance', id: instanceId }); + useAppStore.setState({ + expandedLocation: locationId, + mfaAutoStartLocationId: locationId, + }); + return; + } + + if (windowLabel === WindowId.FullView) { + const location: LocationInfo = { + ...event.payload, + connection_type: ConnectionType.Location, + active: false, + }; + void (async () => { + const appConfig = await api.getAppConfig(); + const mfaMethod = + decideLocationMfaMethod(location, location.mfa_method) ?? MfaMethod.Totp; + + await navigate({ to: '/full/overview' }); + useConnectModal.getState().open({ + view: mfaMethodToConnectModalView(mfaMethod), + location, + autoStartOpenId: appConfig.auto_start_openid_mfa, + mfaMethod, + }); + })(); + } + }, + ), + + listen(TauriEvent.ConnectionChanged, (event) => { + void debug( + `UI Received event ConnectionChanged: ${JSON.stringify(event.payload)}`, + ); + void queryClient.invalidateQueries({ queryKey: ['alive-connection'] }); + void queryClient.invalidateQueries({ queryKey: ['active-connection'] }); + void queryClient.invalidateQueries({ queryKey: ['locations'] }); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + void queryClient.invalidateQueries({ queryKey: ['location-details'] }); + void queryClient.invalidateQueries({ queryKey: ['last-connection'] }); + void queryClient.invalidateQueries({ queryKey: ['tunnels'] }); + void queryClient.invalidateQueries({ queryKey: ['tunnel-details'] }); + }), + + listen(TauriEvent.InstanceUpdate, (event) => { + void debug(`UI Received event InstanceUpdate: ${JSON.stringify(event.payload)}`); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + void queryClient.invalidateQueries({ queryKey: ['locations'] }); + void queryClient.invalidateQueries({ queryKey: ['has-any-visible-locations'] }); + }), + + listen(TauriEvent.LocationUpdate, (event) => { + void debug(`UI Received event LocationUpdate: ${JSON.stringify(event.payload)}`); + void queryClient.invalidateQueries({ queryKey: ['locations'] }); + void queryClient.invalidateQueries({ queryKey: ['location-details'] }); + void queryClient.invalidateQueries({ queryKey: ['has-any-visible-locations'] }); + void queryClient.invalidateQueries({ queryKey: ['tunnels'] }); + void queryClient.invalidateQueries({ queryKey: ['tunnel-details'] }); + }), + + listen(TauriEvent.AppVersionFetch, (event) => { + void debug(`UI Received event AppVersionFetch: ${JSON.stringify(event.payload)}`); + void queryClient.invalidateQueries({ queryKey: ['latest-app-version'] }); + }), + + listen(TauriEvent.ConfigChanged, (event) => { + void debug(`UI Received event ConfigChanged: ${JSON.stringify(event.payload)}`); + void queryClient.invalidateQueries({ queryKey: ['settings'] }); + void queryClient.invalidateQueries({ queryKey: ['provisioning-config'] }); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + void queryClient.invalidateQueries({ queryKey: ['has-any-visible-locations'] }); + }), + + listen(TauriEvent.DeadConnectionDropped, (event) => { + void debug( + `UI Received event DeadConnectionDropped: ${JSON.stringify(event.payload)}`, + ); + void queryClient.invalidateQueries({ queryKey: ['alive-connection'] }); + void queryClient.invalidateQueries({ queryKey: ['active-connection'] }); + void queryClient.invalidateQueries({ queryKey: ['locations'] }); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + }), + + listen( + TauriEvent.DeadConnectionReconnected, + (event) => { + void debug( + `UI Received event DeadConnectionReconnected: ${JSON.stringify(event.payload)}`, + ); + void queryClient.invalidateQueries({ queryKey: ['alive-connection'] }); + void queryClient.invalidateQueries({ queryKey: ['active-connection'] }); + void queryClient.invalidateQueries({ queryKey: ['locations'] }); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + }, + ), + + listen(TauriEvent.ApplicationConfigChanged, (event) => { + void debug( + `UI Received event ApplicationConfigChanged: ${JSON.stringify(event.payload)}`, + ); + void queryClient.invalidateQueries({ queryKey: ['settings'] }); + }), + + listen(TauriEvent.UuidMismatch, (event) => { + void debug(`UI Received event UuidMismatch: ${JSON.stringify(event.payload)}`); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + }), + + listen(TauriEvent.SessionStateChanged, () => { + void queryClient.invalidateQueries({ queryKey: ['session-state'] }); + }), + + listen(TauriEvent.TunnelsDisabled, (event) => { + void debug(`UI Received event TunnelsDisabled: ${JSON.stringify(event.payload)}`); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + void queryClient.invalidateQueries({ queryKey: ['tunnels'] }); + }), + + listen(TauriEvent.TunnelsEnabled, () => { + void debug('UI Received event TunnelsEnabled'); + void queryClient.invalidateQueries({ queryKey: ['instances'] }); + void queryClient.invalidateQueries({ queryKey: ['tunnels'] }); + }), + ]); + + return () => { + void unlisteners.then((fns) => fns.forEach((fn) => void fn())); + }; + }, [queryClient, navigate, setViewSelection]); + + return {children}; +}; diff --git a/new-ui/src/shared/providers/snackbar/SnackbarElement/SnackbarElement.tsx b/new-ui/src/shared/providers/snackbar/SnackbarElement/SnackbarElement.tsx new file mode 100644 index 000000000..89359b9a4 --- /dev/null +++ b/new-ui/src/shared/providers/snackbar/SnackbarElement/SnackbarElement.tsx @@ -0,0 +1,106 @@ +import './style.scss'; +import clsx from 'clsx'; +import { motion } from 'motion/react'; +import { useMemo } from 'react'; +import { Icon, IconKind } from '../../../components/Icon'; +import { InteractionBox } from '../../../components/InteractionBox/InteractionBox'; +import { LoaderSpinner } from '../../../components/LoaderSpinner/LoaderSpinner'; +import { motionTransitionStandard } from '../../../consts'; +import { isPresent } from '../../../utils/isPresent'; +import { type SnackbarConfig, SnackbarVariant } from '../types'; + +const positioningPadding = 20; +const elementHeight = 36; + +// hidden +const elementInitialPosition = elementHeight + 1; +// visible + shift for padding +const elementActivePosition = positioningPadding * -1; + +type StyleVariant = 'default' | 'success' | 'critical'; + +export const SnackbarElement = ({ + data, + onExitAnimationEnd, + onClose, +}: { + data: SnackbarConfig; + onClose: () => void; + onExitAnimationEnd: () => void; +}) => { + const styleVariant = useMemo((): StyleVariant => { + if (data.variant === SnackbarVariant.Error) return 'critical'; + if (data.variant === SnackbarVariant.Success) return 'success'; + return 'default'; + }, [data.variant]); + + const icon = useMemo(() => { + if (data.icon) return data.icon; + const variant = data.variant ?? SnackbarVariant.Default; + if (variant === SnackbarVariant.Success) return IconKind.Check; + if (variant === SnackbarVariant.Error) return IconKind.WarningFilled; + if (variant === SnackbarVariant.Default) return IconKind.CheckFilled; + return null; + }, [data.icon, data.variant]); + + const canClick = !data.dismissible && data.variant !== SnackbarVariant.Loading; + + return ( + { + if (target.opacity === 0) { + onExitAnimationEnd(); + } + }} + className={clsx('snackbar', `variant-${styleVariant}`, { + 'can-click': canClick, + })} + style={{ + height: elementHeight, + }} + onClick={() => { + if (canClick) { + onClose(); + } + }} + > +
+ {isPresent(icon) && ( +
+ +
+ )} + {data.variant === SnackbarVariant.Loading && } + {isPresent(data.customRender) && data.customRender()} + {isPresent(data.text) &&

{data.text}

} + {isPresent(data.action) && ( + + )} + {isPresent(data.dismissible) && ( + + + + )} +
+
+ ); +}; diff --git a/new-ui/src/shared/providers/snackbar/SnackbarElement/style.scss b/new-ui/src/shared/providers/snackbar/SnackbarElement/style.scss new file mode 100644 index 000000000..4f3a7abc7 --- /dev/null +++ b/new-ui/src/shared/providers/snackbar/SnackbarElement/style.scss @@ -0,0 +1,101 @@ +.snackbar { + --bg: var(--bg-white-100); + --color: var(--fg-faded); + --icon: var(--fg-faded); + --close: var(--fg-faded); + + display: flex; + border-radius: var(--radius-xl); + background-color: var(--bg); + user-select: none; + box-sizing: border-box; + padding: var(--spacing-xs) var(--spacing-md); + overflow: hidden; + min-height: 32px; + + @include animate(background-color); + + &.can-click { + cursor: pointer; + } + + &.variant-default { + --bg: var(--bg-white-100); + --color: var(--fg-faded); + --icon: var(--fg-faded); + --close: var(--fg-faded); + + .loader-spinner { + --spinner-track: var(--c-dark-neutral-400); + --spinner-indicator: var(--fg-faded); + } + } + + &.variant-success { + --bg: var(--bg-success); + --color: var(--fg-faded); + --icon: var(--fg-faded); + --close: var(--fg-action); + + .loader-spinner { + --spinner-track: var(--c-white-30); + --spinner-indicator: var(--c-white-100); + } + } + + &.variant-critical { + --bg: var(--bg-critical); + --color: var(--fg-white-100); + --icon: var(--fg-white-100); + --close: var(--fg-action); + + .loader-spinner { + --spinner-track: var(--c-white-30); + --spinner-indicator: var(--c-white-100); + } + } + + & > .content-track { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-md); + width: 100%; + + .snackbar-icon { + --icon-color: var(--icon); + + display: block; + width: 20px; + height: 20px; + } + + p, + span { + font: var(--t-body-xs-500); + color: var(--color); + + @include animate(color); + } + + .snackbar-action { + background-color: transparent; + border: 0; + border-radius: 0; + padding: 0; + margin: 0; + cursor: pointer; + + span { + text-decoration: underline; + text-decoration-color: var(--fg-action); + font: var(--t-body-xs-600); + } + } + + .interaction-box .icon { + --icon-color: var(--close); + } + } +} diff --git a/new-ui/src/shared/providers/snackbar/SnackbarManager.tsx b/new-ui/src/shared/providers/snackbar/SnackbarManager.tsx new file mode 100644 index 000000000..d8304af83 --- /dev/null +++ b/new-ui/src/shared/providers/snackbar/SnackbarManager.tsx @@ -0,0 +1,147 @@ +import { AnimatePresence } from 'motion/react'; +import { type PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'; +import { Fragment } from 'react/jsx-runtime'; +import { createPortal } from 'react-dom'; +import { useDeferredCallback } from '../../hooks/useDeferredCallback'; +import { isPresent } from '../../utils/isPresent'; +import { SnackbarElement } from './SnackbarElement/SnackbarElement'; +import { type SnackbarConfig, SnackbarVariant } from './types'; +import { useSnackbarStore } from './useSnackbarStore'; + +const portalTarget = document.getElementById('snackbar-root') as HTMLElement; + +// if something goes wrong or user ignores dismissible snackbar we will close it anyway +const fallbackTimeout = 90_000; + +const regularTimeout = 5_000; + +const RenderPortal = ({ children }: PropsWithChildren) => { + return createPortal(children, portalTarget); +}; + +export const SnackbarManager = ({ children }: PropsWithChildren) => { + const [visible, setVisible] = useState(false); + const [activeSnackbar, setActiveSnackbar] = useState(null); + // next in line, this should be always latest call from subject, anything in between should be lost + const nextSnackRef = useRef(null); + // if active is busy, so we won't need to update the effect + const activeBusyRef = useRef(false); + + const closeActiveSnackbar = useCallback(() => { + setVisible(false); + }, []); + + const { start: startAutoCloseSnackbar, cancel: cancelAutoClose } = + useDeferredCallback(closeActiveSnackbar); + + const setupAutoClose = useCallback( + (config: SnackbarConfig) => { + if (config.variant === SnackbarVariant.Loading || config.dismissible) { + startAutoCloseSnackbar(fallbackTimeout); + } else { + startAutoCloseSnackbar(regularTimeout); + } + }, + [startAutoCloseSnackbar], + ); + + const handleSnackbarDismiss = useCallback(() => { + cancelAutoClose(); + closeActiveSnackbar(); + }, [cancelAutoClose, closeActiveSnackbar]); + + const popActiveSnackbar = useCallback(() => { + if (!activeSnackbar) return; + if (nextSnackRef.current) { + const snackbarConfig = { ...nextSnackRef.current }; + setVisible(true); + setupAutoClose(snackbarConfig); + setActiveSnackbar(snackbarConfig); + nextSnackRef.current = null; + } else { + // no next snackbar + setActiveSnackbar(null); + activeBusyRef.current = false; + } + }, [activeSnackbar, setupAutoClose]); + + // process incoming requests for snackbar's + useEffect(() => { + const sub = useSnackbarStore.getState().snackSubject.subscribe((snackbar) => { + if (activeBusyRef.current) { + nextSnackRef.current = snackbar; + } else { + // there isn't any active snackbar + setActiveSnackbar(snackbar); + setupAutoClose(snackbar); + setVisible(true); + activeBusyRef.current = true; + } + }); + return () => { + sub.unsubscribe(); + }; + }, [setupAutoClose]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: updates active snackbar's + useEffect(() => { + const sub = useSnackbarStore.getState().updateSubject.subscribe((updateEvent) => { + setActiveSnackbar((currentState) => { + // ignore invalid updates + if (!currentState) return null; + if (!currentState.id) return currentState; + // ignore if update was meant for another snackbar then the current one + if (currentState.id !== updateEvent.id) return currentState; + const newState = { ...currentState, ...updateEvent.update }; + if (updateEvent.resetAutoDismiss) { + setupAutoClose(newState); + } + return newState; + }); + }); + return () => { + sub.unsubscribe(); + }; + }, [setActiveSnackbar]); + + useEffect(() => { + const sub = useSnackbarStore.getState().closeSubject.subscribe((closeTarget) => { + if (activeSnackbar?.id && activeSnackbar.id === closeTarget) { + handleSnackbarDismiss(); + } + }); + return () => { + sub.unsubscribe(); + }; + }, [activeSnackbar, handleSnackbarDismiss]); + + useEffect(() => { + const sub = useSnackbarStore.getState().clearSubject.subscribe(() => { + nextSnackRef.current = null; + activeBusyRef.current = false; + cancelAutoClose(); + setVisible(false); + setActiveSnackbar(null); + }); + return () => { + sub.unsubscribe(); + }; + }, [cancelAutoClose]); + + return ( + + {children} + + + {isPresent(activeSnackbar) && visible && ( + + )} + + + + ); +}; diff --git a/new-ui/src/shared/providers/snackbar/snackbar.ts b/new-ui/src/shared/providers/snackbar/snackbar.ts new file mode 100644 index 000000000..5a9d60ac4 --- /dev/null +++ b/new-ui/src/shared/providers/snackbar/snackbar.ts @@ -0,0 +1,71 @@ +import { type SnackbarConfig, SnackbarVariant, type SnackbarVariantValue } from './types'; +import { useSnackbarStore } from './useSnackbarStore'; + +export class SnackbarAnchor { + readonly id: string; + + constructor(id: string) { + this.id = id; + } + + dismiss() { + useSnackbarStore.getState().closeSubject.next(this.id); + } + + update(update: Partial>, resetAutoDismissTimer?: boolean) { + useSnackbarStore.getState().updateSubject.next({ + id: this.id, + update, + resetAutoDismiss: resetAutoDismissTimer, + }); + } +} + +type CustomSpawnArg = Omit & { + id: string; + variant: SnackbarVariantValue; +}; + +export const Snackbar = { + default: (text: string) => { + useSnackbarStore.getState().snackSubject.next({ + text, + variant: SnackbarVariant.Default, + }); + }, + success: (text: string) => { + useSnackbarStore.getState().snackSubject.next({ + text, + variant: SnackbarVariant.Success, + }); + }, + warning: (text: string) => { + useSnackbarStore.getState().snackSubject.next({ + text, + variant: SnackbarVariant.Warning, + }); + }, + error: (text: string) => { + useSnackbarStore.getState().snackSubject.next({ + text, + variant: SnackbarVariant.Error, + }); + }, + loading: (text: string, id: string) => { + const anchor = new SnackbarAnchor(id); + useSnackbarStore.getState().snackSubject.next({ + id, + text, + variant: SnackbarVariant.Loading, + }); + return anchor; + }, + custom: (customProps: CustomSpawnArg) => { + const anchor = new SnackbarAnchor(customProps.id); + useSnackbarStore.getState().snackSubject.next(customProps); + return anchor; + }, + clear: () => { + useSnackbarStore.getState().clearSubject.next(); + }, +} as const; diff --git a/new-ui/src/shared/providers/snackbar/types.ts b/new-ui/src/shared/providers/snackbar/types.ts new file mode 100644 index 000000000..8a04596c9 --- /dev/null +++ b/new-ui/src/shared/providers/snackbar/types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; +import type { IconKindValue } from '../../components/Icon'; + +export const SnackbarVariant = { + Success: 'success', + Warning: 'warning', + Error: 'error', + Loading: 'loading', + Default: 'default', +} as const; + +export type SnackbarVariantValue = (typeof SnackbarVariant)[keyof typeof SnackbarVariant]; + +export interface SnackbarAction { + text: string; + actionId?: string; + onClick?: () => void; +} + +export interface UpdateSnackbar { + id: string; + update: Partial; + resetAutoDismiss?: boolean; +} + +export interface SnackbarConfig { + id?: string; + icon?: IconKindValue; + variant?: SnackbarVariantValue; + text?: string; + action?: SnackbarAction; + dismissible?: boolean; + customRender?: () => ReactNode; +} diff --git a/new-ui/src/shared/providers/snackbar/useSnackbarStore.tsx b/new-ui/src/shared/providers/snackbar/useSnackbarStore.tsx new file mode 100644 index 000000000..ba33d6e9f --- /dev/null +++ b/new-ui/src/shared/providers/snackbar/useSnackbarStore.tsx @@ -0,0 +1,21 @@ +import { Subject } from 'rxjs'; +import { create } from 'zustand'; +import type { SnackbarConfig, UpdateSnackbar } from './types'; + +interface StoreValues { + snackSubject: Subject; + updateSubject: Subject; + closeSubject: Subject; + clearSubject: Subject; +} + +interface Store extends StoreValues {} + +const defaults: StoreValues = { + snackSubject: new Subject(), + updateSubject: new Subject(), + closeSubject: new Subject(), + clearSubject: new Subject(), +}; + +export const useSnackbarStore = create(() => ({ ...defaults })); diff --git a/new-ui/src/shared/providers/tooltip/TooltipContent.tsx b/new-ui/src/shared/providers/tooltip/TooltipContent.tsx new file mode 100644 index 000000000..953b5ff8d --- /dev/null +++ b/new-ui/src/shared/providers/tooltip/TooltipContent.tsx @@ -0,0 +1,38 @@ +import { FloatingPortal, useMergeRefs } from '@floating-ui/react'; +import clsx from 'clsx'; +import { AnimatePresence } from 'motion/react'; +import { Tooltip } from '../../components/Tooltip/Tooltip'; +import { useTooltipContext } from './TooltipContext'; +import type { ToolTipContentProps } from './types'; + +type Props = ToolTipContentProps; + +export const TooltipContent = ({ + style, + ref: propRef, + children, + variant, + ...props +}: Props) => { + const context = useTooltipContext(); + const ref = useMergeRefs([context.refs.setFloating, propRef]); + return ( + + {context.open && ( + + + {children} + + + )} + + ); +}; diff --git a/new-ui/src/shared/providers/tooltip/TooltipContext.tsx b/new-ui/src/shared/providers/tooltip/TooltipContext.tsx new file mode 100644 index 000000000..5aa798c7c --- /dev/null +++ b/new-ui/src/shared/providers/tooltip/TooltipContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react'; +import type { TooltipContextType, TooltipOptions } from './types'; +import { useTooltip } from './useTooltip'; + +const TooltipContext = createContext(null); + +export const useTooltipContext = () => { + const context = useContext(TooltipContext); + + if (context == null) { + throw new Error('Tooltip components must be wrapped in '); + } + + return context; +}; + +export function TooltipProvider({ + children, + ...options +}: { children: React.ReactNode } & TooltipOptions) { + // This can accept any props as options, e.g. `placement`, + // or other positioning options. + const tooltip = useTooltip(options); + return {children}; +} diff --git a/new-ui/src/shared/providers/tooltip/TooltipTrigger.tsx b/new-ui/src/shared/providers/tooltip/TooltipTrigger.tsx new file mode 100644 index 000000000..e50e94f67 --- /dev/null +++ b/new-ui/src/shared/providers/tooltip/TooltipTrigger.tsx @@ -0,0 +1,27 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: needs to be like this */ +import { useMergeRefs } from '@floating-ui/react'; +import { cloneElement, type HTMLProps, isValidElement, type Ref } from 'react'; +import { useTooltipContext } from './TooltipContext'; + +type Props = { + ref?: Ref; +} & HTMLProps; + +export const TooltipTrigger = ({ children, ref: propRef, ...props }: Props) => { + const context = useTooltipContext(); + const childrenRef = (children as any).ref; + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]); + + if (!isValidElement(children)) + throw new Error('Tooltip Trigger child is not an valid react element!'); + + return cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...(children as any).props, + 'data-state': context.open ? 'open' : 'closed', + }), + ); +}; diff --git a/new-ui/src/shared/providers/tooltip/types.ts b/new-ui/src/shared/providers/tooltip/types.ts new file mode 100644 index 000000000..411e2a674 --- /dev/null +++ b/new-ui/src/shared/providers/tooltip/types.ts @@ -0,0 +1,18 @@ +import type { Placement } from '@floating-ui/react'; +import type { HTMLProps, Ref } from 'react'; +import type { useTooltip } from './useTooltip'; + +export type TooltipContextType = ReturnType | null; + +export interface TooltipOptions { + disabled?: boolean; + initialOpen?: boolean; + placement?: Placement; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export type ToolTipContentProps = HTMLProps & { + ref?: Ref; + variant?: 'default' | 'light'; +}; diff --git a/new-ui/src/shared/providers/tooltip/useTooltip.tsx b/new-ui/src/shared/providers/tooltip/useTooltip.tsx new file mode 100644 index 000000000..bbe8597fa --- /dev/null +++ b/new-ui/src/shared/providers/tooltip/useTooltip.tsx @@ -0,0 +1,67 @@ +import { + autoUpdate, + flip, + offset, + shift, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { useMemo, useState } from 'react'; +import type { TooltipOptions } from './types'; + +export function useTooltip({ + disabled = false, + initialOpen = false, + placement = 'top', + open: controlledOpen, + onOpenChange: setControlledOpen, +}: TooltipOptions = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen); + + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = setControlledOpen ?? setUncontrolledOpen; + + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(8), + flip({ + crossAxis: placement.includes('-'), + fallbackAxisSideDirection: 'start', + padding: 5, + }), + shift({ padding: 5 }), + ], + }); + + const context = data.context; + + const hover = useHover(context, { + move: false, + enabled: controlledOpen == null && !disabled, + }); + const focus = useFocus(context, { + enabled: controlledOpen == null && !disabled, + }); + const dismiss = useDismiss(context); + const role = useRole(context, { role: 'tooltip' }); + + const interactions = useInteractions([hover, focus, dismiss, role]); + + return useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + }), + [open, setOpen, interactions, data], + ); +} diff --git a/new-ui/src/shared/providers/types.ts b/new-ui/src/shared/providers/types.ts new file mode 100644 index 000000000..44398a7ec --- /dev/null +++ b/new-ui/src/shared/providers/types.ts @@ -0,0 +1,8 @@ +import type { MfaMethodValue, OverviewViewSelection } from '../rust-api/types'; + +export type { OverviewViewSelection }; + +export type SharedSessionStorage = { + viewSelection: OverviewViewSelection | null; + connectionMfaMethod: Record; +}; diff --git a/new-ui/src/shared/rust-api/api.ts b/new-ui/src/shared/rust-api/api.ts new file mode 100644 index 000000000..98c23ff6f --- /dev/null +++ b/new-ui/src/shared/rust-api/api.ts @@ -0,0 +1,291 @@ +import { invoke } from '@tauri-apps/api/core'; +import { mfaToApi } from '../utils/mfa'; +import type { + ActiveConnectionSummary, + AppConfig, + AppConfigPatch, + Connection, + ConnectionArgs, + CreateDeviceResponse, + EnrollmentMfaFinishResult, + EnrollmentMfaStartResult, + EnrollmentStartResult, + InstanceInfo, + LocationDetails, + LocationDetailsArgs, + LocationInfo, + LocationStats, + MfaMethodValue, + MfaStartResult, + NewAppVersionInfo, + ProvisioningConfig, + RoutingArgs, + SaveConfigArgs, + SaveDeviceConfigResponse, + SessionState, + SessionStatePatch, + SetLocationMfaMethodArgs, + StatsArgs, + TunnelInfo, + TunnelRequest, + UpdateInstanceArgs, + UpdateTunnelRequest, +} from './types'; +import { TauriCommand } from './types'; + +const getInstances = (): Promise => invoke(TauriCommand.AllInstances); + +const deleteInstance = (instanceId: number): Promise => + invoke(TauriCommand.DeleteInstance, { instanceId }); + +const updateInstance = (args: UpdateInstanceArgs): Promise => + invoke(TauriCommand.UpdateInstance, args); + +const saveDeviceConfig = (args: SaveConfigArgs): Promise => + invoke(TauriCommand.SaveDeviceConfig, args); + +const getLocations = (instanceId: number): Promise => + invoke(TauriCommand.AllLocations, { instanceId }); + +const hasAnyVisibleLocations = (): Promise => + invoke(TauriCommand.HasAnyVisibleLocations); + +const getLocationDetails = (args: LocationDetailsArgs): Promise => + invoke(TauriCommand.LocationInterfaceDetails, args); + +const updateLocationRouting = (args: RoutingArgs): Promise => + invoke(TauriCommand.UpdateLocationRouting, args); + +const setLocationMfaMethod = (args: SetLocationMfaMethodArgs): Promise => + invoke(TauriCommand.SetLocationMfaMethod, args); + +const connect = (args: ConnectionArgs): Promise => + invoke(TauriCommand.Connect, args); + +const disconnect = (args: ConnectionArgs): Promise => + invoke(TauriCommand.Disconnect, args); + +const getLastConnection = (args: ConnectionArgs): Promise => + invoke(TauriCommand.LastConnection, args); + +const getConnectionHistory = (args: ConnectionArgs): Promise => + invoke(TauriCommand.AllConnections, args); + +const getActiveConnection = (args: ConnectionArgs): Promise => + invoke(TauriCommand.ActiveConnection, args); + +const getLocationStats = (args: StatsArgs): Promise => + invoke(TauriCommand.LocationStats, args); + +const getTunnels = (): Promise => invoke(TauriCommand.AllTunnels); + +const getTunnelDetails = (tunnelId: number): Promise => + invoke(TauriCommand.TunnelDetails, { tunnelId }); + +const parseTunnelConfig = (data: { + filename: string; + config: string; +}): Promise> => invoke(TauriCommand.ParseTunnelConfig, data); + +const saveTunnel = (tunnel: TunnelRequest): Promise => + invoke(TauriCommand.SaveTunnel, { tunnel }); + +const updateTunnel = (tunnel: UpdateTunnelRequest): Promise => + invoke(TauriCommand.UpdateTunnel, { tunnel }); + +const deleteTunnel = (tunnelId: number): Promise => + invoke(TauriCommand.DeleteTunnel, { tunnelId }); + +const getAppConfig = (): Promise => invoke(TauriCommand.GetAppConfig); + +const setAppConfig = ( + configPatch: AppConfigPatch, + emitEvent: boolean, +): Promise => invoke(TauriCommand.SetAppConfig, { configPatch, emitEvent }); + +const getProvisioningConfig = (): Promise => + invoke(TauriCommand.GetProvisioningConfig); + +const getPlatformHeader = (): Promise => invoke(TauriCommand.GetPlatformHeader); + +const getLatestAppVersion = (): Promise => + invoke(TauriCommand.GetLatestAppVersion); + +const openLink = (link: string): Promise => invoke(TauriCommand.OpenLink, { link }); + +const startGlobalLogWatcher = (): Promise => + invoke(TauriCommand.StartGlobalLogWatcher); + +const stopGlobalLogWatcher = (): Promise => + invoke(TauriCommand.StopGlobalLogWatcher); + +const getAllActiveConnections = (): Promise => + invoke(TauriCommand.AllActiveConnections); + +const disconnectLocations = (locationIds: number[]): Promise => + invoke(TauriCommand.DisconnectLocations, { locationIds }); + +const getPostureData = async (): Promise => invoke(TauriCommand.GetPostureData); + +const swapToFullView = async () => invoke(TauriCommand.SwapToFullView); + +const swapToTray = async () => invoke(TauriCommand.SwapToTray); + +const closeTrayWindow = async () => invoke(TauriCommand.CloseTrayWindow); + +const closeWelcomeWindow = async () => invoke(TauriCommand.CloseWelcomeWindow); + +const getSessionState = (): Promise => invoke(TauriCommand.GetSessionState); + +const patchSessionState = (patch: SessionStatePatch): Promise => + invoke(TauriCommand.PatchSessionState, { patch }); + +// Enrollment + +const enrollmentStart = ( + proxyUrl: string, + token: string, +): Promise => + invoke(TauriCommand.EnrollmentStart, { proxyUrl, token }); + +const enrollmentCreateDevice = ( + sessionId: string, + name: string, + pubkey: string, +): Promise => + invoke(TauriCommand.EnrollmentCreateDevice, { sessionId, name, pubkey }); + +const enrollmentActivateUser = ( + sessionId: string, + password?: string | null, + phoneNumber?: string | null, +): Promise => + invoke(TauriCommand.EnrollmentActivateUser, { sessionId, password, phoneNumber }); + +const enrollmentRegisterMfaStart = ( + sessionId: string, + method: MfaMethodValue, +): Promise => + invoke(TauriCommand.EnrollmentRegisterMfaStart, { + sessionId, + method: mfaToApi(method), + }); + +const enrollmentRegisterMfaFinish = ( + sessionId: string, + code: string, + method: MfaMethodValue, +): Promise => + invoke(TauriCommand.EnrollmentRegisterMfaFinish, { + sessionId, + code, + method: mfaToApi(method), + }); + +const enrollmentNetworkInfo = ( + sessionId: string, + pubkey: string, +): Promise => + invoke(TauriCommand.EnrollmentNetworkInfo, { sessionId, pubkey }); + +const enrollmentFinish = (sessionId: string): Promise => + invoke(TauriCommand.EnrollmentFinish, { sessionId }); + +// MFA (connect-time) + +const mfaStart = ( + instanceId: number, + locationId: number, + method: string, +): Promise => + invoke(TauriCommand.MfaStart, { instanceId, locationId, method }); + +// Completes MFA and brings up the connection in the backend; the preshared key +// never crosses back to the frontend. +const mfaFinishCode = ( + instanceId: number, + locationId: number, + token: string, + code: string, +): Promise => + invoke(TauriCommand.MfaFinishCode, { instanceId, locationId, token, code }); + +const mfaPollOpenId = ( + instanceId: number, + locationId: number, + token: string, +): Promise => + invoke(TauriCommand.MfaPollOpenId, { instanceId, locationId, token }); + +const mfaConnectMobileApprove = ( + instanceId: number, + locationId: number, + token: string, +): Promise => + invoke(TauriCommand.MfaConnectMobileApprove, { instanceId, locationId, token }); + +const cancelMfa = (taskId: string): Promise => + invoke(TauriCommand.CancelMfa, { taskId }); + +export const api = { + closeWelcomeWindow, + // Instances + getInstances, + deleteInstance, + updateInstance, + saveDeviceConfig, + // Locations + getLocations, + hasAnyVisibleLocations, + getLocationDetails, + updateLocationRouting, + setLocationMfaMethod, + // Connections + connect, + disconnect, + getLastConnection, + getConnectionHistory, + getActiveConnection, + getLocationStats, + // Tunnels + getTunnels, + getTunnelDetails, + parseTunnelConfig, + saveTunnel, + updateTunnel, + deleteTunnel, + // App config + getAppConfig, + setAppConfig, + // Misc + getProvisioningConfig, + getPlatformHeader, + getLatestAppVersion, + openLink, + startGlobalLogWatcher, + stopGlobalLogWatcher, + getAllActiveConnections, + disconnectLocations, + getPostureData, + // Window + swapToFullView, + swapToTray, + closeTrayWindow, + // Session state + getSessionState, + patchSessionState, + // Enrollment + enrollmentStart, + enrollmentCreateDevice, + enrollmentActivateUser, + enrollmentRegisterMfaStart, + enrollmentRegisterMfaFinish, + enrollmentNetworkInfo, + enrollmentFinish, + // MFA + mfaStart, + mfaFinishCode, + mfaPollOpenId, + mfaConnectMobileApprove, + cancelMfa, +}; diff --git a/new-ui/src/shared/rust-api/enrollment.ts b/new-ui/src/shared/rust-api/enrollment.ts new file mode 100644 index 000000000..187739160 --- /dev/null +++ b/new-ui/src/shared/rust-api/enrollment.ts @@ -0,0 +1,169 @@ +import { invoke } from '@tauri-apps/api/core'; +import { generateWGKeys } from '../utils/generateWGKeys'; +import { api } from './api'; +import type { + AddInstanceRequest, + AddInstanceResult, + CreateDeviceResponse, + EnrollmentErrorKind, + InstanceInfo, + SaveDeviceConfigResponse, + UpdateInstanceRequest, + UpdateInstanceResult, +} from './types'; +import { TauriCommand } from './types'; + +const getInstances = (): Promise => invoke(TauriCommand.AllInstances); +const updateInstanceRecord = (args: { + instanceId: number; + response: CreateDeviceResponse; +}): Promise => invoke(TauriCommand.UpdateInstance, args); +const saveDeviceConfig = (args: { + privateKey: string; + response: CreateDeviceResponse; +}): Promise => invoke(TauriCommand.SaveDeviceConfig, args); + +/** Extract the raw Rust error string from a Tauri command rejection. */ +const rustErrorMessage = (err: unknown): string => + typeof err === 'object' && err !== null && 'message' in err + ? String((err as Record).message) + : String(err); + +/** Parse a Tauri command error that contains a serialized `EnrollmentError` + * JSON string into an `EnrollmentErrorKind` and human-readable message. */ +export const parseEnrollmentError = ( + err: unknown, +): { error?: string; errorKind: EnrollmentErrorKind } => { + const raw = rustErrorMessage(err); + try { + const parsed = JSON.parse(raw) as { type: string; message?: string; status?: number }; + switch (parsed.type) { + case 'token_expired': + return { errorKind: 'unauthorized' }; + case 'network_error': + return { errorKind: 'network' }; + case 'proxy_error': + return { error: parsed.message, errorKind: 'server' }; + default: + return { error: parsed.message ?? raw, errorKind: 'server' }; + } + } catch { + return { error: raw, errorKind: 'server' }; + } +}; + +/** True when a command error is a proxy 404 — the device was deleted + * server-side while a stale local record survived. */ +const isDeviceNotFound = (err: unknown): boolean => { + try { + const parsed = JSON.parse(rustErrorMessage(err)) as { + type?: string; + status?: number; + }; + return parsed.type === 'proxy_error' && parsed.status === 404; + } catch { + return false; + } +}; + +export const enrollmentCreateDevice = async ( + sessionId: string, + name: string, +): Promise<{ error?: string }> => { + try { + const { publicKey, privateKey } = generateWGKeys(); + const deviceResponse = await api.enrollmentCreateDevice(sessionId, name, publicKey); + await saveDeviceConfig({ + privateKey, + response: deviceResponse, + }); + return {}; + } catch (e) { + return { error: e instanceof Error ? e.message : String(e) }; + } +}; + +export const enrollmentAddInstance = async ( + values: AddInstanceRequest, +): Promise => { + let sessionId: string | undefined; + let handOffSession = false; + try { + const startResult = await api.enrollmentStart(values.url.trim(), values.token.trim()); + sessionId = startResult.session_id; + + const instances = await getInstances(); + const existing = instances.find((i) => i.uuid === startResult.instance.id); + if (existing) { + try { + const netInfo = await api.enrollmentNetworkInfo( + startResult.session_id, + existing.pubkey, + ); + await updateInstanceRecord({ + instanceId: existing.id, + response: netInfo, + }); + return {}; + } catch (e) { + if (!isDeviceNotFound(e)) throw e; + await api.deleteInstance(existing.id); + } + } + + const normalizedName = values.name.trim().toLowerCase(); + if ( + startResult.user.device_names.some((n) => n.trim().toLowerCase() === normalizedName) + ) { + return { error: `Device name '${values.name}' is already in use` }; + } + + handOffSession = true; + return { startResponse: startResult, session_id: startResult.session_id }; + } catch (e) { + const parsed = parseEnrollmentError(e); + return { error: parsed.error, errorKind: parsed.errorKind }; + } finally { + if (sessionId && !handOffSession) { + await api.enrollmentFinish(sessionId).catch(() => {}); + } + } +}; + +export const enrollmentUpdateInstance = async ( + values: UpdateInstanceRequest, +): Promise => { + let sessionId: string | undefined; + try { + const instances = await getInstances(); + const existing = instances.find((i) => i.id === values.instanceId); + if (!existing) return { error: 'Instance no longer exists.' }; + + const startResult = await api.enrollmentStart(values.url, values.token); + sessionId = startResult.session_id; + + if (startResult.instance.id !== existing.uuid) { + return { + error: 'Provided token belongs to a different instance.', + errorKind: 'unauthorized', + }; + } + + const netInfo = await api.enrollmentNetworkInfo( + startResult.session_id, + existing.pubkey, + ); + await updateInstanceRecord({ + instanceId: existing.id, + response: netInfo, + }); + return {}; + } catch (e) { + const parsed = parseEnrollmentError(e); + return { error: parsed.error, errorKind: parsed.errorKind }; + } finally { + if (sessionId) { + await api.enrollmentFinish(sessionId).catch(() => {}); + } + } +}; diff --git a/new-ui/src/shared/rust-api/mfaError.ts b/new-ui/src/shared/rust-api/mfaError.ts new file mode 100644 index 000000000..7abf47846 --- /dev/null +++ b/new-ui/src/shared/rust-api/mfaError.ts @@ -0,0 +1,56 @@ +import type { LocationInfo } from './types'; + +/** Shape of the tagged `MfaError` the Rust backend serializes to JSON. */ +export type ParsedMfaError = { + type: string; + message?: string; + status?: number; +}; + +/** Parse a structured `MfaError` (JSON) thrown by a command or carried on an + * event payload. Returns null for plain-string errors. */ +export const parseMfaError = (err: unknown): ParsedMfaError | null => { + try { + const parsed = JSON.parse(String(err)) as ParsedMfaError; + return parsed && typeof parsed.type === 'string' ? parsed : null; + } catch { + return null; + } +}; + +/** Best-effort human-readable message: the structured `message` when present, + * otherwise the raw error string. */ +export const mfaErrorMessage = (err: unknown): string => + parseMfaError(err)?.message ?? String(err); + +/** True when the error is a posture rejection for a posture-gated location. + * The backend maps only HTTP 403 (a failed device posture check) to + * `posture_rejected`; ordinary MFA rejections stay `mfa_rejected`. */ +export const isMfaPostureError = (err: unknown, location: LocationInfo): boolean => + location.posture_check_required && parseMfaError(err)?.type === 'posture_rejected'; + +/** The proxy session/token is no longer valid. */ +export const isSessionExpired = (message: string): boolean => + message.includes('invalid token') || message.includes('login session not found'); + +/** The MFA operation timed out (the backend poll deadline was reached). */ +export const isTimeout = (err: unknown): boolean => + parseMfaError(err)?.type === 'timeout'; + +/** A submitted one-time code was rejected. */ +export const isInvalidCode = (message: string): boolean => + message.includes('Unauthorized'); + +/** The proxy/edge service is unavailable (network error or 5xx response). + * Maps to `MfaError::NetworkError` (type: "network_error") and + * `MfaError::ProxyError` (type: "proxy_error") from the Rust backend. */ +export const isServiceUnavailable = (err: unknown): boolean => { + const parsed = parseMfaError(err); + if (!parsed) return false; + return parsed.type === 'network_error' || parsed.type === 'proxy_error'; +}; + +/** MFA succeeded but bringing up the VPN connection afterwards failed + * (see `connect_after_mfa` in the Rust backend). */ +export const isConnectFailure = (message: string): boolean => + message.includes('VPN connection failed'); diff --git a/new-ui/src/shared/rust-api/query.ts b/new-ui/src/shared/rust-api/query.ts new file mode 100644 index 000000000..dbf02caba --- /dev/null +++ b/new-ui/src/shared/rust-api/query.ts @@ -0,0 +1,114 @@ +import { queryOptions, skipToken } from '@tanstack/react-query'; + +import { isPresent } from '../utils/isPresent'; +import { api } from './api'; +import type { + ConnectionArgs, + InstanceInfo, + LocationDetailsArgs, + StatsArgs, +} from './types'; + +/** + * Single source of truth for the OR-across-instances "tunnels disabled" rule: + * tunnels are disabled when any enrolled instance has the flag set. + */ +export const tunnelsDisabled = (instances: InstanceInfo[]): boolean => + instances.some((i) => i.disable_tunnels); + +export const getAllActiveConnectionQueryOptions = queryOptions({ + queryKey: ['alive-connections'] as const, + queryFn: api.getAllActiveConnections, + refetchInterval: 5_000, +}); + +export const getInstancesQueryOptions = queryOptions({ + queryKey: ['instances'] as const, + queryFn: () => api.getInstances(), + refetchInterval: 30_000, +}); + +// Accepts an absent instance id and self-disables via skipToken, so callers +// (e.g. a tunnel selection with no backing instance) can pass through whatever +// they resolved without a sentinel id or a separate `enabled` flag. +export const getLocationsQueryOptions = (instanceId: number | undefined) => + queryOptions({ + queryKey: ['locations', instanceId] as const, + queryFn: isPresent(instanceId) ? () => api.getLocations(instanceId) : skipToken, + }); + +export const hasAnyVisibleLocationsQueryOptions = queryOptions({ + queryKey: ['has-any-visible-locations'] as const, + queryFn: () => api.hasAnyVisibleLocations(), +}); + +export const getLocationDetailsQueryOptions = (args: LocationDetailsArgs) => + queryOptions({ + queryKey: ['location-details', args.locationId, args.connectionType] as const, + queryFn: () => api.getLocationDetails(args), + }); + +export const getLastConnectionQueryOptions = (args: ConnectionArgs) => + queryOptions({ + queryKey: ['last-connection', args.locationId, args.connectionType] as const, + queryFn: () => api.getLastConnection(args), + }); + +export const getConnectionHistoryQueryOptions = (args: ConnectionArgs) => + queryOptions({ + queryKey: ['connection-history', args.locationId, args.connectionType] as const, + queryFn: () => api.getConnectionHistory(args), + }); + +export const getActiveConnectionQueryOptions = (args: ConnectionArgs) => + queryOptions({ + queryKey: ['active-connection', args.locationId, args.connectionType] as const, + queryFn: () => api.getActiveConnection(args), + }); + +export const getLocationStatsQueryOptions = (args: StatsArgs) => + queryOptions({ + queryKey: [ + 'location-stats', + args.locationId, + args.connectionType, + args.from, + ] as const, + queryFn: () => api.getLocationStats(args), + }); + +export const getTunnelsQueryOptions = queryOptions({ + queryKey: ['tunnels'] as const, + queryFn: () => api.getTunnels(), +}); + +export const getTunnelDetailsQueryOptions = (tunnelId: number) => + queryOptions({ + queryKey: ['tunnel-details', tunnelId] as const, + queryFn: () => api.getTunnelDetails(tunnelId), + }); + +export const getAppConfigQueryOptions = queryOptions({ + queryKey: ['settings'] as const, + queryFn: () => api.getAppConfig(), +}); + +export const getLatestAppVersionQueryOptions = queryOptions({ + queryKey: ['latest-app-version'] as const, + queryFn: () => api.getLatestAppVersion(), +}); + +export const getProvisioningConfigQueryOptions = queryOptions({ + queryKey: ['provisioning-config'] as const, + queryFn: () => api.getProvisioningConfig(), +}); + +export const getPlatformHeaderQueryOptions = queryOptions({ + queryKey: ['platform-header'] as const, + queryFn: () => api.getPlatformHeader(), +}); + +export const getSessionStateQueryOptions = queryOptions({ + queryKey: ['session-state'] as const, + queryFn: () => api.getSessionState(), +}); diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts new file mode 100644 index 000000000..f6fc68fd0 --- /dev/null +++ b/new-ui/src/shared/rust-api/types.ts @@ -0,0 +1,519 @@ +export const AppTheme = { + Light: 'light', + Dark: 'dark', +} as const; + +export type AppThemeValue = (typeof AppTheme)[keyof typeof AppTheme]; + +export const AppTrayTheme = { + Color: 'color', + White: 'white', + Black: 'black', + Gray: 'gray', +} as const; + +export type AppTrayTheme = (typeof AppTrayTheme)[keyof typeof AppTrayTheme]; + +export const LogLevel = { + Off: 'OFF', + Error: 'ERROR', + Warn: 'WARN', + Info: 'INFO', + Debug: 'DEBUG', + Trace: 'TRACE', +} as const; + +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; + +export const LogSource = { + All: 'All', + Client: 'Client', + Vpn: 'VPN', +} as const; + +export type LogSource = (typeof LogSource)[keyof typeof LogSource]; + +export type LogItem = { + // datetime UTC + timestamp: string; + level: LogLevel; + target: string; + fields: { + message: string; + interface_name?: string; + }; + source: LogSource; +}; + +export const ClientTrafficPolicy = { + None: 'none', + DisableAllTraffic: 'disable_all_traffic', + ForceAllTraffic: 'force_all_traffic', +} as const; + +export type ClientTrafficPolicy = + (typeof ClientTrafficPolicy)[keyof typeof ClientTrafficPolicy]; + +export const LocationMfaMode = { + Disabled: 'disabled', + Internal: 'internal', + External: 'external', +} as const; + +export type LocationMfaMode = (typeof LocationMfaMode)[keyof typeof LocationMfaMode]; + +export const MfaMethod = { + Totp: 'totp', + Email: 'email', + Oidc: 'oidc', + Biometric: 'biometric', + MobileApprove: 'mobileapprove', +} as const; + +export type MfaMethodValue = (typeof MfaMethod)[keyof typeof MfaMethod]; + +export const ConnectionType = { + Location: 'Location', + Tunnel: 'Tunnel', +} as const; + +export type ConnectionType = (typeof ConnectionType)[keyof typeof ConnectionType]; + +/** Typed enum for every Tauri command available on the backend. */ +export const TauriCommand = { + // Enrollment + EnrollmentStart: 'enrollment_start', + EnrollmentCreateDevice: 'enrollment_create_device', + EnrollmentActivateUser: 'enrollment_activate_user', + EnrollmentRegisterMfaStart: 'enrollment_register_mfa_start', + EnrollmentRegisterMfaFinish: 'enrollment_register_mfa_finish', + EnrollmentNetworkInfo: 'enrollment_network_info', + EnrollmentFinish: 'enrollment_finish', + // MFA + MfaStart: 'mfa_start', + MfaFinishCode: 'mfa_finish_code', + MfaPollOpenId: 'mfa_poll_openid', + MfaConnectMobileApprove: 'mfa_connect_mobile_approve', + CancelMfa: 'cancel_mfa', + // Instances + AllInstances: 'all_instances', + DeleteInstance: 'delete_instance', + UpdateInstance: 'update_instance', + SaveDeviceConfig: 'save_device_config', + // Locations + AllLocations: 'all_locations', + HasAnyVisibleLocations: 'has_any_visible_locations', + LocationInterfaceDetails: 'location_interface_details', + UpdateLocationRouting: 'update_location_routing', + SetLocationMfaMethod: 'set_location_mfa_method', + // Connections + Connect: 'connect', + Disconnect: 'disconnect', + LastConnection: 'last_connection', + AllConnections: 'all_connections', + ActiveConnection: 'active_connection', + LocationStats: 'location_stats', + // Tunnels + AllTunnels: 'all_tunnels', + TunnelDetails: 'tunnel_details', + ParseTunnelConfig: 'parse_tunnel_config', + SaveTunnel: 'save_tunnel', + UpdateTunnel: 'update_tunnel', + DeleteTunnel: 'delete_tunnel', + // App config + GetAppConfig: 'command_get_app_config', + SetAppConfig: 'command_set_app_config', + // Misc + GetProvisioningConfig: 'get_provisioning_config', + GetPlatformHeader: 'get_platform_header', + GetLatestAppVersion: 'get_latest_app_version', + OpenLink: 'open_link', + StartGlobalLogWatcher: 'start_global_logwatcher', + StopGlobalLogWatcher: 'stop_global_logwatcher', + AllActiveConnections: 'all_active_connections', + DisconnectLocations: 'disconnect_locations', + GetPostureData: 'get_posture_data', + //Window + SwapToFullView: 'swap_to_full_view', + SwapToTray: 'swap_to_tray', + CloseTrayWindow: 'close_tray_window', + // Session state + GetSessionState: 'get_session_state', + PatchSessionState: 'patch_session_state', + CloseWelcomeWindow: 'close_welcome_window', +} as const; + +export type TauriCommand = (typeof TauriCommand)[keyof typeof TauriCommand]; + +/** Typed enum for every Tauri event emitted by the backend. */ +export const TauriEvent = { + ConnectionChanged: 'connection-changed', + InstanceUpdate: 'instance-update', + LocationUpdate: 'location-update', + AppVersionFetch: 'app-version-fetch', + ConfigChanged: 'config-changed', + DeadConnectionDropped: 'dead-connection-dropped', + DeadConnectionReconnected: 'dead-connection-reconnected', + ApplicationConfigChanged: 'application-config-changed', + AddInstance: 'add-instance', + MfaTrigger: 'mfa-trigger', + VersionMismatch: 'version-mismatch', + UuidMismatch: 'uuid-mismatch', + GlobalLogUpdate: 'log-update-global', + WindowSwapped: 'window-swapped', + SessionStateChanged: 'session-state-changed', + MfaOpenIdComplete: 'mfa-openid-complete', + MfaOpenIdError: 'mfa-openid-error', + MfaMobileComplete: 'mfa-mobile-complete', + MfaMobileError: 'mfa-mobile-error', + TunnelsDisabled: 'tunnel-disabled-by-policy', + TunnelsEnabled: 'tunnel-enabled-by-policy', +} as const; + +export type TauriEventValue = (typeof TauriEvent)[keyof typeof TauriEvent]; + +/** Payload for the `dead-connection-dropped` event. Mirrors `DeadConnDroppedOut` in events.rs. */ +export type DeadConnectionDroppedPayload = { + name: string; + con_type: ConnectionType; + peer_alive_period: number; +}; + +/** Payload for the `dead-connection-reconnected` event. Mirrors `DeadConnReconnected` in events.rs. */ +export type DeadConnectionReconnectedPayload = { + name: string; + con_type: ConnectionType; + peer_alive_period: number; +}; + +/** Payload for the `add-instance` event. Mirrors `AddInstancePayload` in events.rs. */ +export type AddInstanceEventPayload = { + token: string; + url: string; +}; + +/** Payload for the `tunnel-disabled-by-policy` event. Mirrors `TunnelsDisabled` in events.rs. */ +export type TunnelsDisabledPayload = { + names: string[]; +}; + +export type ActiveConnectionSummary = { + id: number; + name: string; + connection_type: ConnectionType; +}; + +export type AppConfig = { + theme: AppThemeValue; + tray_theme: AppTrayTheme; + check_for_updates: boolean; + log_level: LogLevel; + /** Idle seconds before the connection is automatically dropped. */ + peer_alive_period: number; + /** Maximum transmission unit; 0 means system default. */ + mtu: number; + auto_start_openid_mfa: boolean; +}; + +export type AppConfigPatch = Partial; + +export type InstanceInfo = { + id: number; + name: string; + /** Server-side UUID (not the SQLite row id). */ + uuid: string; + url: string; + proxy_url: string; + /** True when at least one location of this instance has an active connection. */ + active: boolean; + pubkey: string; + client_traffic_policy: ClientTrafficPolicy; + enterprise_enabled: boolean; + disable_tunnels: boolean; + openid_display_name: string | null; +}; + +export type LocationInfo = { + id: number; + instance_id: number; + name: string; + address: string; + endpoint: string; + active: boolean; + route_all_traffic: boolean; + connection_type: ConnectionType; + pubkey: string; + network_id: number; + location_mfa_mode: LocationMfaMode; + mfa_method?: MfaMethodValue; + posture_check_required: boolean; +}; + +export type LocationStats = { + collected_at: number; + download: number; + upload: number; +}; + +export type Connection = { + id: number; + location_id: number; + connected_from: string; + start: string; + end: string; + upload?: number; + download?: number; +}; + +export type TunnelInfo = { + id?: number; + name: string; + address: string; + endpoint: string; + route_all_traffic: boolean; + active: boolean; + connection_type: ConnectionType; + instance_id: number; + network_id: number; + pubkey: string; + prvkey: string; + server_pubkey: string; + preshared_key?: string; + allowed_ips?: string; + dns?: string; + persistent_keep_alive: number; + pre_up?: string; + post_up?: string; + pre_down?: string; + post_down?: string; +}; + +export type LocationDetails = { + location_id: number; + name: string; + pubkey: string; + address: string; + dns?: string; + listen_port: number; + peer_pubkey: string; + peer_endpoint: string; + allowed_ips: string; + persistent_keepalive_interval?: number; + last_handshake?: number; + mfa_method?: MfaMethodValue; +}; + +export type NewAppVersionInfo = { + version: string; + release_date: string; + release_notes_url: string; + update_url: string; + summary?: string | null; + notes?: string | null; +}; + +export type ProvisioningConfig = { + enrollment_token: string; + enrollment_url: string; +}; + +export type Device = { + id: number; + name: string; + pubkey: string; + privateKey?: string; + user_id: number; + created_at: number; +}; + +export type DeviceConfig = { + network_id: number; + network_name: string; + config: string; +}; + +export type CreateDeviceResponse = { + device: Device; + configs: DeviceConfig[]; + instance: InstanceInfo; +}; + +export type SaveDeviceConfigResponse = { + instance: InstanceInfo; + locations: LocationInfo[]; +}; + +export type ConnectionArgs = { + locationId: number; + connectionType: ConnectionType; +}; + +export type RoutingArgs = { + locationId: number; + connectionType: ConnectionType; + routeAllTraffic?: boolean; +}; + +export type StatsArgs = { + locationId: number; + connectionType: ConnectionType; + from?: string; +}; + +export type LocationDetailsArgs = { + locationId: number; + connectionType: ConnectionType; +}; + +export type TunnelRequest = { + name: string; + pubkey: string; + prvkey: string; + address: string; + server_pubkey: string; + preshared_key?: string; + allowed_ips?: string; + endpoint: string; + dns?: string; + persistent_keep_alive: number; + route_all_traffic: boolean; + pre_up?: string; + post_up?: string; + pre_down?: string; + post_down?: string; +}; + +export type UpdateTunnelRequest = TunnelRequest & { + id: number; + preshared_key?: string; + route_all_traffic: boolean; +}; + +export type SaveConfigArgs = { + privateKey: string; + response: CreateDeviceResponse; +}; + +export type UpdateInstanceArgs = { + instanceId: number; + response: CreateDeviceResponse; +}; + +export type SetLocationMfaMethodArgs = { + locationId: number; + mfaMethod: MfaMethodValue; +}; + +export type OverviewViewSelection = { + kind: 'instance' | 'tunnel'; + id: number; +}; + +export type SessionState = { + view_selection: OverviewViewSelection | null; + connection_mfa_method: Record; +}; + +export type SessionStatePatch = Partial; + +/** User information returned by enrollment_start. + * + * Mirrors `InitialUserInfo` from `client_types.proto`. */ +export type EnrollmentUserInfo = { + first_name: string; + last_name: string; + login: string; + email: string; + phone_number: string | null; + is_active: boolean; + device_names: string[]; + enrolled: boolean; + is_admin: boolean; + password_management_disabled: boolean; +}; + +/** Administrator information returned by enrollment_start. + * + * Mirrors `AdminInfo` from `client_types.proto`. */ +export type EnrollmentAdminInfo = { + name: string; + phone_number: string | null; + email: string; +}; + +/** Enrollment settings returned by enrollment_start. + * + * Mirrors `EnrollmentSettings` from `client_types.proto`. */ +export type EnrollmentSettings = { + vpn_setup_optional: boolean; + only_client_activation: boolean; + admin_device_management: boolean; + smtp_configured: boolean; + mfa_required: boolean; +}; + +/** Instance info returned by enrollment_start. + * + * Mirrors `InstanceInfo` from `client_types.proto`. */ +export type EnrollmentInstanceInfo = { + id: string; + name: string; + url: string; + proxy_url: string; + username: string; + enterprise_enabled: boolean; + openid_display_name: string | null; +}; + +/** Full result from the enrollment_start Tauri command. */ +export type EnrollmentStartResult = { + session_id: string; + user: EnrollmentUserInfo; + admin: EnrollmentAdminInfo; + settings: EnrollmentSettings; + instance: EnrollmentInstanceInfo; + deadline_timestamp: number; + final_page_content: string; +}; + +/** Result from enrollment_register_mfa_start. */ +export type EnrollmentMfaStartResult = { + totp_secret: string | null; +}; + +/** Result from enrollment_register_mfa_finish. */ +export type EnrollmentMfaFinishResult = { + recovery_codes: string[]; +}; + +/** Result from mfa_start Tauri command. */ +export type MfaStartResult = { + token: string; + challenge: string | null; +}; + +/** Payload for mfa-openid-error / mfa-mobile-error events. */ +export type MfaErrorPayload = { + error: string; +}; + +/** `network`: the request could not be sent, most likely a bad URL. + * `unauthorized`: the server responded 401, the token is invalid. + * `server`: any other failure response. */ +export type EnrollmentErrorKind = 'network' | 'unauthorized' | 'server'; + +export type AddInstanceRequest = { url: string; token: string; name: string }; + +export type AddInstanceResult = { + startResponse?: EnrollmentStartResult; + session_id?: string; + error?: string; + errorKind?: EnrollmentErrorKind; +}; + +export type UpdateInstanceRequest = { instanceId: number; url: string; token: string }; + +export type UpdateInstanceResult = { + error?: string; + errorKind?: EnrollmentErrorKind; +}; diff --git a/new-ui/src/shared/scss/_base.scss b/new-ui/src/shared/scss/_base.scss new file mode 100644 index 000000000..5cef2d1e7 --- /dev/null +++ b/new-ui/src/shared/scss/_base.scss @@ -0,0 +1,113 @@ +*:-moz-focus-inner { + border: 0; +} + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + transition: background-color 8553600s; + -webkit-text-fill-color: #fff !important; +} + +html, +body { + padding: 0; + margin: 0; + overscroll-behavior: none; +} + +// for apple +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: auto; +} + +#root, +#app { + min-height: 100dvh; + overflow: hidden; +} + +input[type='password']::-ms-reveal, +input[type='password']::-ms-clear { + display: none; +} + +input[type='number']::-webkit-outer-spin-button, +input[type='number']::-webkit-inner-spin-button { + appearance: none; + margin: 0; +} + +input[type='number'] { + appearance: textfield; +} + +*:focus { + outline: none; +} + +*:-moz-focus-inner { + border: 0; +} + +p, +span, +div, +section, +a, +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; + color: var(--fg-white-100); + font-family: + geist, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Open Sans', + 'Helvetica Neue', + sans-serif; +} + +ul, +ol { + margin: 0; + padding: 0; +} + +::-webkit-scrollbar { + width: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 100px; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--fg-white-50) transparent; +} + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + transition: background-color 8553600s; + -webkit-text-fill-color: #fff !important; +} diff --git a/new-ui/src/shared/scss/_fonts.scss b/new-ui/src/shared/scss/_fonts.scss new file mode 100644 index 000000000..88635eb6a --- /dev/null +++ b/new-ui/src/shared/scss/_fonts.scss @@ -0,0 +1,125 @@ +/* stylelint-disable font-family-name-quotes */ +// fonts.scss +@font-face { + font-family: 'Geist'; + src: url('/fonts/geist/Geist-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Geist'; + src: url('/fonts/geist/Geist-RegularItalic.woff2') format('woff2'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Geist'; + src: url('/fonts/geist/Geist-Medium.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Geist'; + src: url('/fonts/geist/Geist-MediumItalic.woff2') format('woff2'); + font-weight: 500; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Geist'; + src: url('/fonts/geist/Geist-SemiBold.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Geist'; + src: url('/fonts/geist/Geist-SemiBoldItalic.woff2') format('woff2'); + font-weight: 600; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Geist'; + src: url('/fonts/geist/Geist-Bold.woff2') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Geist'; + src: url('/fonts/geist/Geist-BoldItalic.woff2') format('woff2'); + font-weight: 700; + font-style: italic; + font-display: swap; +} + +// source-code-pro + +@font-face { + font-family: 'Source Code Pro'; + src: url('/fonts/source_code_pro/SourceCodePro-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +// JetBrains Mono + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains_mono/JetBrainsMono-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains_mono/JetBrainsMono-Italic.woff2') format('woff2'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains_mono/JetBrainsMono-Medium.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains_mono/JetBrainsMono-MediumItalic.woff2') format('woff2'); + font-weight: 500; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains_mono/JetBrainsMono-SemiBold.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains_mono/JetBrainsMono-SemiBoldItalic.woff2') format('woff2'); + font-weight: 600; + font-style: italic; + font-display: swap; +} diff --git a/new-ui/src/shared/scss/_shared_tokens.scss b/new-ui/src/shared/scss/_shared_tokens.scss new file mode 100644 index 000000000..70e43b415 --- /dev/null +++ b/new-ui/src/shared/scss/_shared_tokens.scss @@ -0,0 +1,268 @@ +$font-fallback: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Open Sans', + 'Helvetica Neue', + sans-serif; +$geist: + geist, + #{$font-fallback}; +$source-code-pro: + 'Source Code Pro', + #{$font-fallback}; +$jetbrains: + 'JetBrains Mono', + #{$font-fallback}; +/* stylelint-disable value-keyword-case */ +:root { + // sizings + // set by WindowDecorations component via effect on window metadata + // --window-decorations-height: 33px; + --window-header-height: 50px; + + // font settings + --font-family-title: #{$geist}; + --font-family-body: #{$geist}; + --font-family-component: #{$geist}; + --font-family-code: #{$source-code-pro}; + --font-family-jetbrains: #{$jetbrains}; + + --t-body-xxs-600: normal 600 11px/14px #{$geist}; + --t-body-xxs-500: normal 500 11px/14px #{$geist}; + --t-body-xxs-400: normal 400 11px/14px #{$geist}; + + --t-body-xs-600: normal 600 12px/16px #{$geist}; + --t-body-xs-500: normal 500 12px/16px #{$geist}; + --t-body-xs-400: normal 400 12px/16px #{$geist}; + + --t-body-sm-600: normal 600 14px/20px #{$geist}; + --t-body-sm-500: normal 500 14px/20px #{$geist}; + --t-body-sm-400: normal 400 14px/20px #{$geist}; + + --t-body-primary-400: normal 400 16px/24px #{$geist}; + --t-body-primary-600: normal 600 16px/24px #{$geist}; + --t-body-primary-500: normal 500 16px/24px #{$geist}; + + --t-h1: normal 600 32px/44px #{$geist}; + --t-h2: normal 600 28px/40px #{$geist}; + --t-h3: normal 600 24px/32px #{$geist}; + --t-h4: normal 600 20px/28px #{$geist}; + --t-h5: normal 600 18px/28px #{$geist}; + + --t-primary-400: normal 400 16px/24px #{$geist}; + --t-primary-500: normal 500 16px/24px #{$geist}; + --t-primary-600: normal 600 16px/24px #{$geist}; + + --t-small-400: normal 400 14px/20px #{$geist}; + --t-small-500: normal 500 14px/20px #{$geist}; + --t-small-600: normal 600 14px/20px #{$geist}; + + --t-tiny-400: normal 400 12px/16px #{$geist}; + --t-tiny-500: normal 500 12px/16px #{$geist}; + --t-tiny-600: normal 600 12px/16px #{$geist}; + + --t-smallest-400: normal 400 11px/16px #{$geist}; + --t-smallest-500: normal 500 11px/16px #{$geist}; + --t-smallest-600: normal 600 11px/16px #{$geist}; + + // border width + --border-1: 1px; + --border-2: 2px; + + // border-radius + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-xxl: 24px; + --radius-xxxl: 32px; + --radius-full: 100px; + + // scale + --size-xs: 16px; + --size-sm: 20px; + --size-md: 24px; + --size-xl: 32px; + --size-2xl: 36px; + --size-3xl: 40px; + --size-4xl: 44px; + --size-5xl: 60px; + + // spacing + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 20px; + --spacing-2xl: 24px; + --spacing-3xl: 32px; + --spacing-4xl: 40px; + --spacing-5xl: 48px; + --spacing-6xl: 64px; + --spacing-7xl: 80px; + --spacing-8xl: 96px; + --spacing-9xl: 120px; + + // color basis + --c-white-100: rgb(255 255 255); + --c-white-90: rgb(255 255 255 / 90%); + --c-white-80: rgb(255 255 255 / 80%); + --c-white-70: rgb(255 255 255 / 70%); + --c-white-60: rgb(255 255 255 / 60%); + --c-white-50: rgb(255 255 255 / 50%); + --c-white-40: rgb(255 255 255 / 40%); + --c-white-30: rgb(255 255 255 / 30%); + --c-white-20: rgb(255 255 255 / 20%); + --c-white-10: rgb(255 255 255 / 10%); + --c-white-5: rgb(255 255 255 / 5%); + --c-dark-neutral-1400: rgb(20 21 23); + --c-dark-neutral-1300: rgb(25 26 28); + --c-dark-neutral-1200: rgb(36 38 41); + --c-dark-neutral-1100: rgb(46 49 54); + --c-dark-neutral-1000: rgb(50 54 60); + --c-dark-neutral-900: rgb(61 67 75); + --c-dark-neutral-800: rgb(74 80 89); + --c-dark-neutral-700: rgb(94 102 114); + --c-dark-neutral-600: rgb(126 135 148); + --c-dark-neutral-500: rgb(147 156 169); + --c-dark-neutral-400: rgb(162 172 186); + --c-dark-neutral-300: rgb(184 192 205); + --c-dark-neutral-200: rgb(223 227 233); + --c-dark-neutral-100: rgb(240 242 245); + --c-dark-neutral-50: rgb(247 248 250); + --c-saturated-additional-error: rgb(204 60 60); + --c-saturated-additional-warning: rgb(255 149 0); + --c-saturated-additional-success: rgb(116 255 184); + --c-saturated-additional-blue-neutral: rgb(80 115 225); + --c-saturated-red-800: rgb(35 24 26); + --c-saturated-red-700: rgb(124 37 37); + --c-saturated-red-600: rgb(143 42 42); + --c-saturated-red-500: rgb(204 60 60); + --c-saturated-red-400: rgb(213 93 93); + --c-saturated-red-300: rgb(225 142 142); + --c-saturated-red-200: rgb(234 175 175); + --c-saturated-red-100: rgb(250 236 236); + --c-saturated-red-500-transparent: rgb(204 60 60 / 20%); + --c-saturated-dark-blue-100: rgb(0 25 137); + --c-saturated-dark-blue-90: rgb(0 25 137 / 90%); + --c-saturated-dark-blue-80: rgb(0 25 137 / 80%); + --c-saturated-dark-blue-70: rgb(0 25 137 / 70%); + --c-saturated-dark-blue-60: rgb(0 25 137 / 60%); + --c-saturated-dark-blue-50: rgb(0 25 137 / 50%); + --c-saturated-dark-blue-40: rgb(0 25 137 / 40%); + --c-saturated-dark-blue-30: rgb(0 25 137 / 30%); + --c-saturated-dark-blue-20: rgb(0 25 137 / 20%); + --c-saturated-dark-blue-10: rgb(0 25 137 / 10%); + --c-saturated-dark-blue-5: rgb(0 25 137 / 5%); + --c-saturated-green-700: rgb(2 78 23); + --c-saturated-green-600: rgb(2 90 27); + --c-saturated-green-500: rgb(3 128 38); + --c-saturated-green-400: rgb(46 150 75); + --c-saturated-green-300: rgb(109 181 129); + --c-saturated-green-200: rgb(152 203 166); + --c-saturated-green-100: rgb(230 242 233); + --c-saturated-green-500-transparent: rgb(3 128 38 / 8%); + --c-saturated-orange-700: rgb(156 91 0); + --c-saturated-orange-600: rgb(179 104 0); + --c-saturated-orange-500: rgb(255 149 0); + --c-saturated-orange-400: rgb(255 167 43); + --c-saturated-orange-300: rgb(255 194 107); + --c-saturated-orange-200: rgb(255 212 150); + --c-saturated-orange-100: rgb(255 244 230); + --c-saturated-orange-500-transparent: rgb(255 149 0 / 8%); + --c-saturated-violet-700: rgb(53 36 82); + --c-saturated-violet-600: rgb(83 55 128); + --c-saturated-violet-500: rgb(102 55 178); + --c-saturated-violet-400: rgb(136 106 186); + --c-saturated-violet-300: rgb(172 151 207); + --c-saturated-violet-200: rgb(196 181 221); + --c-saturated-violet-100: rgb(241 237 247); + --c-saturated-violet-transparent: rgb(102 55 178 / 8%); + --c-saturated-blue-800: rgb(21 24 31); + --c-saturated-blue-700: rgb(38 57 115); + --c-saturated-blue-600: rgb(51 77 156); + --c-saturated-blue-500: rgb(57 97 219); + --c-saturated-blue-400: rgb(87 119 217); + --c-saturated-blue-300: rgb(140 161 222); + --c-saturated-blue-200: rgb(180 196 242); + --c-saturated-blue-100: rgb(237 241 252); + --c-saturated-blue-50: rgb(249 250 254); + --c-saturated-blue-500-transparent: rgb(50 92 219 / 8%); + + // Components + + // Menu + --t-menu-title: 600 12px/24px #{$geist}; + --t-menu-text: 400 14px/24px #{$geist}; + --menu-spacing-icon: var(--spacing-md); + --menu-padding-sides: var(--spacing-sm); + --menu-border-radius-group: var(--radius-lg); + --menu-border-radius-item: var(--radius-md); + --menu-height: var(--size-2xl); + + // Icons + + --icon-size-lg: 32px; + --icon-size: 20px; + --icon-size-xs: 16px; + + // Modals + + --modal-title-size: 16px; + --modal-size-md: 640px; + --modal-size-sm: 360px; + --modal-spacing: var(--spacing-md); + --modal-spacing-sides: var(--spacing-lg); + --modal-border-radius: var(--radius-lg); + + // Buttons + + --t-button-label-big: normal 500 14px / normal #{$geist}; + --t-button-label-primary: normal 500 14px / normal #{$geist}; + + // Badge + --badge-border-radius: var(--radius-md); + --badge-spacing: var(--spacing-sm); + --badge-gap: var(--spacing-xs); + --t-badge: var(--t-body-xs-500); + --badge-height: 24px; + + // Inputs + --t-input-title: normal 500 12px / 16px #{$geist}; + --t-input-text-primary: normal 400 14px / 20px #{$geist}; + --t-input-text-big: normal 400 16px / 20px #{$geist}; + --t-input-error-message: normal 400 12px / 16px #{$geist}; + --input-border-radius: var(--radius-md); + --input-size-primary: 36px; + --input-size-lg: 44px; + --input-spacing-xs: var(--spacing-xs); + --input-spacing-sm: var(--spacing-sm); + --input-spacing-lg: var(--spacing-md); + + // Log view + --t-log-line: normal 400 12px/20px #{$jetbrains}; + + // Tooltip + --t-tooltip: normal 400 12px / 16px #{$geist}; + --tooltip-letter-spacing: 0.3; + --tooltip-spacing: var(--spacing-sm) var(--spacing-md); + + // custom + + // how much space does error message in all takes + --form-field-error-space: 24px; + + // how much space error should gap from main container + --form-field-error-gap: 8px; + + // navigation + --nav-width: 270px; + + --menu-shadow: 0 4px 12px 0 rgb(0 0 0 / 7%); +} diff --git a/new-ui/src/shared/scss/_skeleton.scss b/new-ui/src/shared/scss/_skeleton.scss new file mode 100644 index 000000000..6bb25ae61 --- /dev/null +++ b/new-ui/src/shared/scss/_skeleton.scss @@ -0,0 +1,4 @@ +.react-loading-skeleton { + --base-color: var(--bg-disabled); + --highlight-color: var(--bg-active); +} diff --git a/new-ui/src/shared/scss/_snackbar.scss b/new-ui/src/shared/scss/_snackbar.scss new file mode 100644 index 000000000..2cd703fbe --- /dev/null +++ b/new-ui/src/shared/scss/_snackbar.scss @@ -0,0 +1,16 @@ +#snackbar-root { + z-index: 6; + position: fixed; + bottom: 0; + left: 0; + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; + width: 100%; + pointer-events: none; + + & > * { + pointer-events: auto; + } +} diff --git a/new-ui/src/shared/scss/_themes.scss b/new-ui/src/shared/scss/_themes.scss new file mode 100644 index 000000000..1746ccb87 --- /dev/null +++ b/new-ui/src/shared/scss/_themes.scss @@ -0,0 +1,56 @@ +:root[data-theme='light'] { + --bg-white-100: var(--c-white-100); + --bg-white-90: var(--c-white-90); + --bg-white-80: var(--c-white-80); + --bg-white-70: var(--c-white-70); + --bg-white-60: var(--c-white-60); + --bg-white-50: var(--c-white-50); + --bg-white-40: var(--c-white-40); + --bg-white-30: var(--c-white-30); + --bg-white-20: var(--c-white-20); + --bg-white-10: var(--c-white-10); + --bg-white-5: var(--c-white-5); + --fg-white-100: var(--c-white-100); + --fg-white-90: var(--c-white-90); + --fg-white-80: var(--c-white-80); + --fg-white-70: var(--c-white-70); + --fg-white-60: var(--c-white-60); + --fg-white-50: var(--c-white-50); + --fg-white-40: var(--c-white-40); + --fg-white-30: var(--c-white-30); + --fg-white-20: var(--c-white-20); + --fg-white-10: var(--c-white-10); + --fg-white-5: var(--c-white-5); + --bg-critical: var(--c-saturated-red-500); + --bg-neutral: var(--c-saturated-additional-blue-neutral); + --bg-dark-blue-60: var(--c-saturated-dark-blue-60); + --bg-dark-blue-40: var(--c-saturated-dark-blue-40); + --bg-dark-blue-30: var(--c-saturated-dark-blue-30); + --bg-dark-blue-20: var(--c-saturated-dark-blue-20); + --bg-success: var(--c-saturated-additional-success); + --bg-warning: var(--c-saturated-orange-500); + --bg-critical-faded: var(--c-saturated-red-400); + --bg-critical-muted: var(--c-saturated-red-200); + --bg-critical-disabled: var(--c-saturated-red-500-transparent); + --border-bg: var(--c-white-100); + --border-action: var(--fg-white-100); + --border-action-disabled: var(--fg-white-20); + --border-default: var(--c-white-40); + --border-disabled: var(--c-white-20); + --border-emphasis: var(--c-white-60); + --border-muted: var(--c-white-20); + --border-faded: var(--c-white-10); + --border-critical: var(--c-saturated-red-200); + --border-success: var(--c-saturated-additional-success); + --border-warning: var(--c-saturated-orange-300); + --fg-action: var(--c-saturated-blue-500); + --fg-attention: var(--c-saturated-orange-300); + --fg-critical: var(--c-saturated-red-200); + --fg-critical-muted: var(--c-saturated-red-100); + --fg-black: var(--c-dark-neutral-1400); + --fg-faded: var(--c-dark-neutral-900); + --fg-neutral: var(--c-dark-neutral-800); + --fg-muted: var(--c-dark-neutral-600); + --fg-disabled: var(--c-dark-neutral-500); + --fg-success: var(--c-saturated-additional-success); +} diff --git a/new-ui/src/shared/scss/global/_animate.scss b/new-ui/src/shared/scss/global/_animate.scss new file mode 100644 index 000000000..024cc9240 --- /dev/null +++ b/new-ui/src/shared/scss/global/_animate.scss @@ -0,0 +1,11 @@ +@use 'sass:list'; + +// for now no prop required +@mixin animate($properties...) { + transition-timing-function: ease-out; + transition-duration: 160ms; + + @if list.length($properties) > 0 { + transition-property: $properties; + } +} diff --git a/new-ui/src/shared/scss/global/_breakpoints.scss b/new-ui/src/shared/scss/global/_breakpoints.scss new file mode 100644 index 000000000..cda8eaa86 --- /dev/null +++ b/new-ui/src/shared/scss/global/_breakpoints.scss @@ -0,0 +1,112 @@ +@use 'sass:map'; +@use 'sass:list'; + +$grid-breakpoints: ( + xs: 0, + sm: 320px, + md: 768px, + lg: 992px, + xl: 1200px, + xxl: 1600px, +); + +@function break-next( + $name, + $breakpoints: $grid-breakpoints, + $breakpoint-names: map.keys($breakpoints) +) { + $n: list.index($breakpoint-names, $name); + + @if not $n { + @error "breakpoint `#{$name}` not found in `#{$breakpoints}`"; + } + + @if $n < list.length($breakpoint-names) { + @return list.nth($breakpoint-names, $n + 1); + } + + @return null; +} + +@function break-min($name, $breakpoints: $grid-breakpoints) { + $min: map.get($breakpoints, $name); + + @if $min != 0 { + @return $min; + } + + @return null; +} + +@function break-max($name, $breakpoints: $grid-breakpoints) { + $max: map.get($breakpoints, $name); + + @if $max and $max > 0 { + @return $max - 0.02; + } + + @return null; +} + +@mixin break-up($name, $breakpoints: $grid-breakpoints) { + $min: break-min($name, $breakpoints); + + @if $min { + @media (min-width: $min) { + @content; + } + } @else { + @content; + } +} + +@mixin break-down($name, $breakpoints: $grid-breakpoints) { + $max: break-max($name, $breakpoints); + + @if $max { + @media (max-width: $max) { + @content; + } + } @else { + @content; + } +} + +@mixin break-between($lower, $upper, $breakpoints: $grid-breakpoints) { + $min: break-min($lower, $breakpoints); + $max: break-max($upper, $breakpoints); + + @if $min != null and $max != null { + @media (min-width: $min) and (max-width: $max) { + @content; + } + } @else if $max == null { + @include break-up($lower, $breakpoints) { + @content; + } + } @else if $min == null { + @include break-down($upper, $breakpoints) { + @content; + } + } +} + +@mixin break-only($name, $breakpoints: $grid-breakpoints) { + $min: break-min($name, $breakpoints); + $next: break-next($name, $breakpoints); + $max: break-max($next, $breakpoints); + + @if $min != null and $max != null { + @media (min-width: $min) and (max-width: $max) { + @content; + } + } @else if $max == null { + @include break-up($name, $breakpoints) { + @content; + } + } @else if $min == null { + @include break-down($next, $breakpoints) { + @content; + } + } +} diff --git a/new-ui/src/shared/scss/global/index.scss b/new-ui/src/shared/scss/global/index.scss new file mode 100644 index 000000000..875467349 --- /dev/null +++ b/new-ui/src/shared/scss/global/index.scss @@ -0,0 +1,2 @@ +@forward './animate'; +@forward './breakpoints'; diff --git a/new-ui/src/shared/scss/index.scss b/new-ui/src/shared/scss/index.scss new file mode 100644 index 000000000..a64415de1 --- /dev/null +++ b/new-ui/src/shared/scss/index.scss @@ -0,0 +1,6 @@ +@use './base'; +@use './shared_tokens'; +@use './skeleton'; +@use './themes'; +@use './fonts'; +@use './snackbar'; diff --git a/new-ui/src/shared/store/useAppStore.tsx b/new-ui/src/shared/store/useAppStore.tsx new file mode 100644 index 000000000..b161e33ae --- /dev/null +++ b/new-ui/src/shared/store/useAppStore.tsx @@ -0,0 +1,26 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +interface StoreValues { + // only used in compact mode + expandedLocation: number | null; + // Location ID whose MFA flow should auto-start (e.g. triggered from the tray). + mfaAutoStartLocationId: number | null; +} + +interface Store extends StoreValues {} + +export const useAppStore = create()( + persist( + (_) => ({ + expandedLocation: null, + mfaAutoStartLocationId: null, + }), + { + name: 'app-store', + storage: createJSONStorage(() => localStorage), + version: 4, + partialize: (state) => ({ expandedLocation: state.expandedLocation }), + }, + ), +); diff --git a/new-ui/src/shared/types.ts b/new-ui/src/shared/types.ts new file mode 100644 index 000000000..70b7b15f8 --- /dev/null +++ b/new-ui/src/shared/types.ts @@ -0,0 +1,92 @@ +export const Direction = { + UP: 'up', + DOWN: 'down', + LEFT: 'left', + RIGHT: 'right', +} as const; + +export type DirectionValue = (typeof Direction)[keyof typeof Direction]; + +export const Orientation = { + Horizontal: 'horizontal', + Vertical: 'vertical', +} as const; + +export type OrientationValue = (typeof Orientation)[keyof typeof Orientation]; + +export const ThemeSpacing = { + Xs: 'var(--spacing-xs)', + Sm: 'var(--spacing-sm)', + Md: 'var(--spacing-md)', + Lg: 'var(--spacing-lg)', + Xl: 'var(--spacing-xl)', + Xl2: 'var(--spacing-2xl)', + Xl3: 'var(--spacing-3xl)', + Xl4: 'var(--spacing-4xl)', + Xl5: 'var(--spacing-5xl)', + Xl6: 'var(--spacing-6xl)', + Xl7: 'var(--spacing-7xl)', + Xl8: 'var(--spacing-8xl)', + Xl9: 'var(--spacing-9xl)', +} as const; + +export type ThemeSpacingValue = (typeof ThemeSpacing)[keyof typeof ThemeSpacing]; + +export const ThemeVariable = { + BgWhite100: 'var(--bg-white-100)', + BgWhite90: 'var(--bg-white-90)', + BgWhite80: 'var(--bg-white-80)', + BgWhite70: 'var(--bg-white-70)', + BgWhite60: 'var(--bg-white-60)', + BgWhite50: 'var(--bg-white-50)', + BgWhite40: 'var(--bg-white-40)', + BgWhite30: 'var(--bg-white-30)', + BgWhite20: 'var(--bg-white-20)', + BgWhite10: 'var(--bg-white-10)', + BgWhite5: 'var(--bg-white-5)', + FgWhite100: 'var(--fg-white-100)', + FgWhite90: 'var(--fg-white-90)', + FgWhite80: 'var(--fg-white-80)', + FgWhite70: 'var(--fg-white-70)', + FgWhite60: 'var(--fg-white-60)', + FgWhite50: 'var(--fg-white-50)', + FgWhite40: 'var(--fg-white-40)', + FgWhite30: 'var(--fg-white-30)', + FgWhite20: 'var(--fg-white-20)', + FgWhite10: 'var(--fg-white-10)', + FgWhite5: 'var(--fg-white-5)', + BgCritical: 'var(--bg-critical)', + BgNeutral: 'var(--bg-neutral)', + BgDarkBlue60: 'var(--bg-dark-blue-60)', + BgDarkBlue40: 'var(--bg-dark-blue-40)', + BgDarkBlue30: 'var(--bg-dark-blue-30)', + BgDarkBlue20: 'var(--bg-dark-blue-20)', + BgSuccess: 'var(--bg-success)', + BgWarning: 'var(--bg-warning)', + BgCriticalFaded: 'var(--bg-critical-faded)', + BgCriticalMuted: 'var(--bg-critical-muted)', + BgCriticalDisabled: 'var(--bg-critical-disabled)', + BorderBg: 'var(--border-bg)', + BorderAction: 'var(--border-action)', + BorderActionDisabled: 'var(--border-action-disabled)', + BorderDefault: 'var(--border-default)', + BorderDisabled: 'var(--border-disabled)', + BorderEmphasis: 'var(--border-emphasis)', + BorderMuted: 'var(--border-muted)', + BorderFaded: 'var(--border-faded)', + BorderCritical: 'var(--border-critical)', + BorderSuccess: 'var(--border-success)', + BorderWarning: 'var(--border-warning)', + FgAction: 'var(--fg-action)', + FgAttention: 'var(--fg-attention)', + FgCritical: 'var(--fg-critical)', + FgCriticalMuted: 'var(--fg-critical-muted)', + FgBlack: 'var(--fg-black)', + FgFaded: 'var(--fg-faded)', + FgNeutral: 'var(--fg-neutral)', + FgMuted: 'var(--fg-muted)', + FgDisabled: 'var(--fg-disabled)', + FgSuccess: 'var(--fg-success)', +} as const; + +export type ThemeVariableValue = (typeof ThemeVariable)[keyof typeof ThemeVariable]; diff --git a/new-ui/src/shared/utils/compareVersions.ts b/new-ui/src/shared/utils/compareVersions.ts new file mode 100644 index 000000000..3fed21c8d --- /dev/null +++ b/new-ui/src/shared/utils/compareVersions.ts @@ -0,0 +1,19 @@ +// Compares dotted numeric versions, ignoring pre-release/build suffixes. +export const isVersionGreater = (version: string, than: string): boolean => { + const parse = (v: string) => + v + .trim() + .replace(/^v/, '') + .split(/[-+]/)[0] + .split('.') + .map((part) => Number.parseInt(part, 10) || 0); + + const a = parse(version); + const b = parse(than); + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0); + if (diff !== 0) return diff > 0; + } + return false; +}; diff --git a/new-ui/src/shared/utils/decideLocationMfaMethod.ts b/new-ui/src/shared/utils/decideLocationMfaMethod.ts new file mode 100644 index 000000000..247de109e --- /dev/null +++ b/new-ui/src/shared/utils/decideLocationMfaMethod.ts @@ -0,0 +1,17 @@ +import { type LocationInfo, MfaMethod, type MfaMethodValue } from '../rust-api/types'; + +export const decideLocationMfaMethod = ( + location: LocationInfo, + currentMethod: MfaMethodValue | null | undefined, +): MfaMethodValue | null => { + switch (location.location_mfa_mode) { + case 'disabled': + return location.mfa_method ?? null; + case 'external': + return MfaMethod.Oidc; + case 'internal': + if (currentMethod === MfaMethod.Oidc || !currentMethod) + return location.mfa_method ?? null; + return currentMethod; + } +}; diff --git a/src/shared/utils/detectClickOutside.ts b/new-ui/src/shared/utils/detectClickOutside.ts similarity index 100% rename from src/shared/utils/detectClickOutside.ts rename to new-ui/src/shared/utils/detectClickOutside.ts diff --git a/new-ui/src/shared/utils/download.ts b/new-ui/src/shared/utils/download.ts new file mode 100644 index 000000000..282e91649 --- /dev/null +++ b/new-ui/src/shared/utils/download.ts @@ -0,0 +1,29 @@ +import { save } from '@tauri-apps/plugin-dialog'; +import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs'; + +export const downloadText = async ( + content: string, + filename: string, + extension: 'txt' | 'pub' | 'conf' = 'txt', +): Promise => { + const path = await save({ + defaultPath: `${filename}.${extension}`, + filters: [{ name: 'Text files', extensions: [extension] }], + }); + if (path === null) return; + await writeTextFile(path, content); +}; + +export const downloadFile = async ( + blob: Blob, + filename: string, + extension: string, +): Promise => { + const path = await save({ + defaultPath: `${filename}.${extension}`, + filters: [{ name: 'Files', extensions: [extension] }], + }); + if (path === null) return; + const buffer = await blob.arrayBuffer(); + await writeFile(path, new Uint8Array(buffer)); +}; diff --git a/new-ui/src/shared/utils/formatDuration.ts b/new-ui/src/shared/utils/formatDuration.ts new file mode 100644 index 000000000..34ebc4a96 --- /dev/null +++ b/new-ui/src/shared/utils/formatDuration.ts @@ -0,0 +1,8 @@ +import type { Duration } from 'dayjs/plugin/duration'; + +export function formatDuration(dur: Duration): string { + if (dur.days() > 0) return dur.format('D[d] H[h]'); + if (dur.hours() > 0) return dur.format('H[h] m[min]'); + if (dur.minutes() > 0) return dur.format('m[min] s[sec]'); + return dur.format('s[sec]'); +} diff --git a/new-ui/src/shared/utils/formatRequestBody.ts b/new-ui/src/shared/utils/formatRequestBody.ts new file mode 100644 index 000000000..87b22eb81 --- /dev/null +++ b/new-ui/src/shared/utils/formatRequestBody.ts @@ -0,0 +1,10 @@ +export const formatRequestBody = (value: T): T => { + if (typeof value === 'string') return value.trim() as T; + if (Array.isArray(value)) return value.map(formatRequestBody) as T; + if (value !== null && typeof value === 'object' && !(value instanceof Date)) { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, formatRequestBody(v)]), + ) as T; + } + return value; +}; diff --git a/src/shared/utils/generateWGKeys.ts b/new-ui/src/shared/utils/generateWGKeys copy.ts similarity index 100% rename from src/shared/utils/generateWGKeys.ts rename to new-ui/src/shared/utils/generateWGKeys copy.ts diff --git a/new-ui/src/shared/utils/generateWGKeys.ts b/new-ui/src/shared/utils/generateWGKeys.ts new file mode 100644 index 000000000..5c8588e03 --- /dev/null +++ b/new-ui/src/shared/utils/generateWGKeys.ts @@ -0,0 +1,10 @@ +import { encode } from '@stablelib/base64'; +import { generateKeyPair } from '@stablelib/x25519'; + +export const generateWGKeys = () => { + const keys = generateKeyPair(); + return { + publicKey: encode(keys.publicKey), + privateKey: encode(keys.secretKey), + }; +}; diff --git a/src/shared/utils/isComparable.ts b/new-ui/src/shared/utils/isComparable.ts similarity index 100% rename from src/shared/utils/isComparable.ts rename to new-ui/src/shared/utils/isComparable.ts diff --git a/new-ui/src/shared/utils/isPresent.ts b/new-ui/src/shared/utils/isPresent.ts new file mode 100644 index 000000000..510ce8d49 --- /dev/null +++ b/new-ui/src/shared/utils/isPresent.ts @@ -0,0 +1,3 @@ +export const isPresent = (value: T): value is NonNullable => { + return value !== null && value !== undefined; +}; diff --git a/new-ui/src/shared/utils/mergeRefs.ts b/new-ui/src/shared/utils/mergeRefs.ts new file mode 100644 index 000000000..5d13c288a --- /dev/null +++ b/new-ui/src/shared/utils/mergeRefs.ts @@ -0,0 +1,29 @@ +// extracted from https://github.com/gregberge/react-merge-refs +import type { Ref, RefCallback } from 'react'; + +function assignRef( + ref: Ref | undefined | null, + value: T | null, +): ReturnType> { + if (typeof ref === 'function') { + return ref(value); + } else if (ref) { + ref.current = value; + } +} + +export function mergeRefs(refs: (Ref | undefined)[]): Ref { + return (value: T | null) => { + const cleanups: (() => void)[] = []; + + for (const ref of refs) { + const cleanup = assignRef(ref, value); + const isCleanup = typeof cleanup === 'function'; + cleanups.push(isCleanup ? cleanup : () => assignRef(ref, null)); + } + + return () => { + for (const cleanup of cleanups) cleanup(); + }; + }; +} diff --git a/new-ui/src/shared/utils/mfa.ts b/new-ui/src/shared/utils/mfa.ts new file mode 100644 index 000000000..c29b07d6c --- /dev/null +++ b/new-ui/src/shared/utils/mfa.ts @@ -0,0 +1,37 @@ +import { + ConnectionType, + type LocationInfo, + LocationMfaMode, + MfaMethod, + type MfaMethodValue, +} from '../rust-api/types'; + +const mfaMethodLabels: Record = { + [MfaMethod.Email]: 'Email', + [MfaMethod.MobileApprove]: 'Mobile Client', + [MfaMethod.Oidc]: 'OpenID', + [MfaMethod.Totp]: 'Authenticator app', + [MfaMethod.Biometric]: 'Biometric', +}; + +export const mfaToText = (factor: MfaMethodValue): string => mfaMethodLabels[factor]; + +export const mfaMethodApiValues: Record = { + [MfaMethod.Email]: 'Email', + [MfaMethod.MobileApprove]: 'MobileApprove', + [MfaMethod.Oidc]: 'Oidc', + [MfaMethod.Totp]: 'Totp', + [MfaMethod.Biometric]: 'Biometric', +}; + +export const mfaToApi = (factor: MfaMethodValue): string => mfaMethodApiValues[factor]; + +/** + * Whether connecting this location should trigger the MFA flow: only for + * server-managed locations (never bare tunnels) that have MFA enabled. + */ +export const shouldStartMfa = ( + location: Pick, +): boolean => + location.connection_type !== ConnectionType.Tunnel && + location.location_mfa_mode !== LocationMfaMode.Disabled; diff --git a/src/shared/patterns.ts b/new-ui/src/shared/utils/patterns.ts similarity index 86% rename from src/shared/patterns.ts rename to new-ui/src/shared/utils/patterns.ts index 0dc65fbba..a167119b1 100644 --- a/src/shared/patterns.ts +++ b/new-ui/src/shared/utils/patterns.ts @@ -68,17 +68,18 @@ export const patternValidDomain = /^(?:(?:(?:[a-zA-z-]+):\/{1,3})?(?:[a-zA-Z0-9])(?:[a-zA-Z0-9\-.]){1,61}(?:\.[a-zA-Z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))(?::[0-9]{1,5})?$/; export const patternValidIp = - /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:\/32)?$/; + /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -export const cidrRegex = - /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}|[0-9a-fA-F:.]+\/\d{1,3})$/; -// Regular expression to match IPv4, IPv6, domain name, or localhost with port +// Regular expression to match a WireGuard endpoint. A bare IPv4 literal must +// include a port (a port-less IP is almost always a mistake), while domain names +// and localhost may omit it. IPv6 endpoints are validated separately via +// patternValidIpV6WithPort (port required there too). export const patternValidEndpoint = - /^(localhost|\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b|\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b)(?::(\d+))?$/; + /^(?:(?:localhost|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(?::\d+)?|(?:[0-9]{1,3}\.){3}[0-9]{1,3}:\d+)$/; -// Copied from zod source code and added optional mask at the end to match WireguardRequirements +// Copied from zod source code export const patternValidIpV6 = - /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))(?:\/128)?$/; + /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/; // Reuse pattern from above to support format [ipv6]:port export const patternValidIpV6WithPort = diff --git a/new-ui/src/shared/utils/sortByLabel.ts b/new-ui/src/shared/utils/sortByLabel.ts new file mode 100644 index 000000000..ede65fc95 --- /dev/null +++ b/new-ui/src/shared/utils/sortByLabel.ts @@ -0,0 +1,7 @@ +const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + +export const sortByLabel = (items: readonly T[], selector: (item: T) => string): T[] => + [...items].sort((a, b) => collator.compare(selector(a), selector(b))); diff --git a/new-ui/src/shared/utils/zod.ts b/new-ui/src/shared/utils/zod.ts new file mode 100644 index 000000000..5200740b5 --- /dev/null +++ b/new-ui/src/shared/utils/zod.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; +import { + patternValidEndpoint, + patternValidIpV6WithPort, + patternValidWireguardKey, +} from './patterns'; + +export const createZodIssue = ( + message: string, + path: PropertyKey[], +): z.core.$ZodIssueCustom => ({ + code: 'custom', + message, + path, +}); + +// Shared field schemas for the WireGuard tunnel forms (add/edit). Kept here so +// the tunnel wizard and the edit-tunnel modal validate identically. + +// WireGuard endpoint (host:port): IPv4:port, domain[:port], or [IPv6]:port. +export const endpointSchema = z + .string() + .refine( + (v) => patternValidEndpoint.test(v) || patternValidIpV6WithPort.test(v), + 'Invalid address', + ); + +// A required WireGuard key. +export const wireguardKeySchema = z + .string() + .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'); + +// An optional WireGuard key - an empty value is allowed (e.g. preshared key). +export const optionalWireguardKeySchema = z + .string() + .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'); + +const ipOrCidrSchema = z.union([z.ipv4(), z.ipv6(), z.cidrv4(), z.cidrv6()]); + +const isValidIpList = (value: string) => + value + .split(',') + .map((ip) => ip.trim()) + .every((ip) => ipOrCidrSchema.safeParse(ip).success); + +// A required comma-separated list of interface addresses or CIDR ranges. +export const interfaceAddressesSchema = z + .string() + .refine((value) => Boolean(value) && isValidIpList(value), 'Field is invalid'); + +// Comma-separated list of allowed IP addresses or CIDR ranges; an empty value is allowed. +export const allowedIpsSchema = z + .string() + .refine( + (value) => !value || isValidIpList(value), + 'Invalid IP address or CIDR notation', + ); diff --git a/new-ui/tsconfig.app.json b/new-ui/tsconfig.app.json new file mode 100644 index 000000000..7f42e5f7c --- /dev/null +++ b/new-ui/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/new-ui/tsconfig.json b/new-ui/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/new-ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/new-ui/tsconfig.node.json b/new-ui/tsconfig.node.json new file mode 100644 index 000000000..d3c52ea64 --- /dev/null +++ b/new-ui/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/new-ui/vite.config.ts b/new-ui/vite.config.ts new file mode 100644 index 000000000..fd6c80f70 --- /dev/null +++ b/new-ui/vite.config.ts @@ -0,0 +1,51 @@ +import react from '@vitejs/plugin-react'; +import autoprefixer from 'autoprefixer'; +import * as path from 'path'; +import { defineConfig } from 'vite'; +import { tanstackRouter } from '@tanstack/router-plugin/vite'; +import { devtools } from '@tanstack/devtools-vite'; + +const host = process.env.TAURI_DEV_HOST; + +// https://vitejs.dev/config/ +export default defineConfig(async () => ({ + plugins: [devtools(), tanstackRouter(), react()], + clearScreen: false, + server: { + strictPort: true, + port: 5072, + host: host || false, + hmr: host + ? { + protocol: 'ws', + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell Vite to ignore watching `src-tauri` + ignored: ['**/src-tauri/**'], + }, + }, + resolve: { + alias: { + '@scssutils': path.resolve('./src/shared/scss/global'), + }, + }, + css: { + preprocessorOptions: { + scss: { + additionalData: `@use "@scssutils" as *;\n`, + }, + }, + postcss: { + plugins: [autoprefixer], + }, + }, + envPrefix: ['VITE_', 'TAURI_'], + base: '/', + build: { + outDir: '../dist', + emptyOutDir: true, + }, +})); diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix index f3833d09f..0c62d7ba3 100644 --- a/nix/nixos-module.nix +++ b/nix/nixos-module.nix @@ -1,61 +1,100 @@ -{ +{mkCraneLib}: { config, lib, pkgs, ... -}: -with lib; let - defguard-client = pkgs.callPackage ./package.nix {}; - cfg = config.programs.defguard-client; +}: let + inherit (lib) mkDefault mkEnableOption mkIf mkMerge mkOption optional types; + + craneLib = mkCraneLib pkgs; + defguard-client = pkgs.callPackage ./package.nix {inherit pkgs craneLib;}; + + svcCfg = config.services.defguard-client-daemon; + clientCfg = config.programs.defguard-client; in { - options.programs.defguard-client = { - enable = mkEnableOption "Defguard VPN client and service"; + options.services.defguard-client-daemon = { + enable = mkEnableOption "Defguard VPN client background service (required by both the desktop client and CLI)"; package = mkOption { type = types.package; default = defguard-client; - description = "defguard-client package to use"; + description = "Package that provides the defguard-service binary."; }; logLevel = mkOption { type = types.str; default = "info"; - description = "Log level for defguard-service"; + description = "Log level for defguard-service (--log-level)"; + }; + + logDir = mkOption { + type = types.str; + default = "/var/log/defguard-service"; + description = "Directory for defguard-service logs (--log-dir)"; }; statsPeriod = mkOption { type = types.int; default = 30; - description = "Interval in seconds for interface statistics updates"; + description = "Interval in seconds for interface statistics updates (--stats-period)"; }; }; - config = mkIf cfg.enable { - # Add client package - environment.systemPackages = [cfg.package]; - - # Setup systemd service for the intrerface management daemon - systemd.services.defguard-service = { - description = "Defguard VPN Service"; - wantedBy = ["multi-user.target"]; - wants = ["network-online.target"]; - after = ["network-online.target"]; - serviceConfig = { - ExecStart = "${cfg.package}/bin/defguard-service --log-level ${cfg.logLevel} --stats-period ${toString cfg.statsPeriod}"; - ExecReload = "/bin/kill -HUP $MAINPID"; - Group = "defguard"; - Restart = "on-failure"; - RestartSec = 2; - KillMode = "process"; - KillSignal = "SIGINT"; - LimitNOFILE = 65536; - LimitNPROC = "infinity"; - TasksMax = "infinity"; - OOMScoreAdjust = -1000; - }; - }; + options.programs.defguard-client = { + enable = mkEnableOption "Defguard VPN desktop client"; - # Make sure the defguard group exists - users.groups.defguard = {}; + package = mkOption { + type = types.package; + default = defguard-client; + description = "defguard-client package to use"; + }; }; + + config = mkMerge [ + # Auto-enable the daemon when the desktop client is enabled. + # Users can override with services.defguard-client-daemon.enable = false. + { + services.defguard-client-daemon.enable = mkDefault clientCfg.enable; + } + + # Add the relevant packages to the system PATH. + (mkIf (svcCfg.enable || clientCfg.enable) { + environment.systemPackages = + [] + ++ optional svcCfg.enable svcCfg.package + ++ optional clientCfg.enable clientCfg.package; + }) + + # Daemon-only configuration: systemd service and dedicated group. + (mkIf svcCfg.enable { + systemd.services.defguard-service = { + description = "Defguard VPN Service"; + documentation = ["https://docs.defguard.net"]; + wantedBy = ["multi-user.target"]; + wants = ["network-online.target"]; + after = ["network-online.target"]; + serviceConfig = { + Group = "defguard"; + ExecStart = "${svcCfg.package}/bin/defguard-service --log-level ${svcCfg.logLevel} --log-dir ${svcCfg.logDir} --stats-period ${toString svcCfg.statsPeriod}"; + ExecReload = "kill -HUP $MAINPID"; + KillMode = "process"; + KillSignal = "SIGINT"; + LimitNOFILE = 65536; + LimitNPROC = "infinity"; + Restart = "on-failure"; + RestartSec = 2; + TasksMax = "infinity"; + OOMScoreAdjust = -1000; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectControlGroups = true; + ProtectKernelModules = true; + RestrictRealtime = true; + LockPersonality = true; + }; + }; + + users.groups.defguard = {}; + }) + ]; } diff --git a/nix/package.nix b/nix/package.nix index d6dd9a691..6b32856ad 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,8 +1,7 @@ { pkgs, lib, - stdenv, - rustPlatform, + craneLib, rustc, cargo, makeDesktopItem, @@ -10,7 +9,6 @@ fetchPnpmDeps, }: let pname = "defguard-client"; - # Automatically read version from Cargo.toml version = (fromTOML (builtins.readFile ../src-tauri/Cargo.toml)).workspace.package.version; desktopItem = makeDesktopItem { @@ -22,7 +20,7 @@ categories = ["Network" "Security"]; }; - pnpm = pkgs.pnpm_10; + inherit (import ./versions.nix pkgs) nodejs pnpm; buildInputs = with pkgs; [ at-spi2-atk @@ -43,60 +41,131 @@ libayatana-indicator ayatana-ido libdbusmenu-gtk3 - desktop-file-utils - iproute2 - lsb-release - openresolv ]; - nativeBuildInputs = [ + # Rust/cargo inputs shared by buildDepsOnly and the main build. + cargoNativeBuildInputs = [ rustc cargo pkgs.pkg-config pkgs.gobject-introspection pkgs.cargo-tauri - pkgs.nodejs_24 pkgs.protobuf - pnpm - # configures pnpm to use pre-fetched dependencies - pnpmConfigHook - # configures cargo to use pre-fetched dependencies - rustPlatform.cargoSetupHook - # helper to add runtime binary & library deps paths - pkgs.makeWrapper - pkgs.wrapGAppsHook3 ]; + + # Source filter for buildDepsOnly: Cargo files plus extras needed by build.rs + # (proto files, tauri configs, capabilities, sqlx offline cache). + depsSourceFilter = path: type: + (craneLib.filterCargoSources path type) + || (lib.hasSuffix ".proto" path) + || (lib.hasSuffix "tauri.conf.json" path) + || (lib.hasInfix "/capabilities/" path) + || (lib.hasInfix "/.sqlx/" path) + || (lib.hasSuffix ".sql" path); + + depsSrc = lib.cleanSourceWith { + src = craneLib.path ../src-tauri; + filter = depsSourceFilter; + }; + + cargoVendorDir = craneLib.vendorCargoDeps { + src = craneLib.path ../src-tauri; + }; + + # Pre-compile cargo dependencies; cached as long as Cargo.lock is unchanged. + # Features must match the main build. + cargoArtifacts = craneLib.buildDepsOnly { + inherit pname; + inherit version buildInputs cargoVendorDir; + src = depsSrc; + nativeBuildInputs = cargoNativeBuildInputs; + cargoExtraArgs = "--features custom-protocol"; + VERGEN_IDEMPOTENT = "true"; + SQLX_OFFLINE = "true"; + }; + + # Prefetch pnpm dependencies for the new UI (separate pnpm project). + newUiPnpmDeps = fetchPnpmDeps { + pname = "defguard-client-new-ui"; + inherit version pnpm; + src = ../new-ui; + fetcherVersion = 4; + hash = "sha256-Ka76Vy52+5ZpHAc7EEFXjJJVc1dTuIH4HBvrzU0CPW0="; + }; + + # Pre-build the new UI frontend so Tauri can serve it as WebviewUrl::App("compact/") and "full/". + newUiDist = pkgs.stdenv.mkDerivation { + pname = "defguard-client-new-ui"; + inherit version; + src = ../new-ui; + nativeBuildInputs = [nodejs pnpm pnpmConfigHook]; + pnpmDeps = newUiPnpmDeps; + buildPhase = '' + runHook preBuild + pnpm tsc -b + pnpm vite build --outDir "$out" + # Create entry points for compact, full, and welcome view windows. + mkdir -p "$out"/compact "$out"/full "$out"/welcome + cp "$out"/index.html "$out"/compact/ + cp "$out"/index.html "$out"/full/ + cp "$out"/index.html "$out"/welcome/ + runHook postBuild + ''; + installPhase = "true"; + }; in - stdenv.mkDerivation (finalAttrs: rec { - inherit pname version buildInputs nativeBuildInputs; + craneLib.mkCargoDerivation { + inherit pname version buildInputs cargoArtifacts cargoVendorDir newUiDist; src = ../.; - # prefetch cargo dependencies - cargoRoot = "src-tauri"; - buildAndTestSubdir = "src-tauri"; - - cargoDeps = rustPlatform.importCargoLock { - lockFile = ../src-tauri/Cargo.lock; - }; + nativeBuildInputs = + cargoNativeBuildInputs + ++ [ + pkgs.makeWrapper + pkgs.wrapGAppsHook3 + ]; + + # Pin CARGO_TARGET_DIR before crane's inheritCargoArtifacts hook runs so + # extraction and tauri's cargo invocation both land in src-tauri/target. + postUnpack = '' + export CARGO_TARGET_DIR="$NIX_BUILD_TOP/$sourceRoot/src-tauri/target" + ''; - # prefetch pnpm dependencies - pnpmDeps = fetchPnpmDeps { - inherit - (finalAttrs) - pname - version - src - ; - - fetcherVersion = 2; - hash = "sha256-vDLgpFaO+48s+tj1/2m2fgNJpCfnNkFJpQkC4Xah59E="; - }; + # Required by mkCargoDerivation even when buildPhase is fully overridden. + buildPhaseCargoCommand = ""; + + preBuild = '' + # Workspace-member build scripts were compiled in buildDepsOnly's source + # tree (/build/source/) with that path baked in; remove them so cargo + # recompiles them against the current tree. Dep .rlib/.rmeta are kept. + rm -rf src-tauri/target/release/build/defguard* + rm -rf src-tauri/target/release/build/common* + rm -rf src-tauri/target/release/.fingerprint/defguard* + rm -rf src-tauri/target/release/.fingerprint/common* + + # tauri_build::build() reads OUT_DIR metadata written by tauri's own + # build script during buildDepsOnly (pointing to /build/source/). + # Remove tauri's build outputs and build-script-run fingerprints so + # cargo re-runs the build script and refreshes OUT_DIR to the current + # path. libtauri*.rlib in deps/ is untouched. + rm -rf src-tauri/target/release/build/tauri-* + find src-tauri/target/release/.fingerprint \ + -maxdepth 1 -type d \( -name 'tauri-*' -o -name 'tauri_*' \) \ + -exec rm -f '{}/build-script-run' \; + ''; buildPhase = '' runHook preBuild - pnpm tauri build --verbose + # Copy in the pre-built new UI frontend. + mkdir -p dist + cp -r ${newUiDist}/* dist/ + chmod -R u+w dist/ + + # --config replaces the build section from tauri.linux.conf.json. + cargo tauri build \ + --config '{"build":{"beforeBuildCommand":""}}' runHook postBuild ''; @@ -104,55 +173,47 @@ in installPhase = '' runHook preInstall - mkdir -p $out/bin + # tauri always writes to src-tauri/target regardless of $CARGO_TARGET_DIR. + local targetDir="src-tauri/target/release" - # copy client binary - install -Dm755 src-tauri/target/release/${pname} $out/bin/${pname} - - # copy background service binary - install -Dm755 src-tauri/target/release/defguard-service $out/bin/defguard-service - - # copy CLI binary - install -Dm755 src-tauri/target/release/dg $out/bin/dg + mkdir -p $out/bin + install -Dm755 "$targetDir/${pname}" $out/bin/${pname} + install -Dm755 "$targetDir/defguard-service" $out/bin/defguard-service + install -Dm755 "$targetDir/dg" $out/bin/dg - # Copy resources directory (for tray icons, etc.) mkdir -p $out/lib/${pname} cp -r src-tauri/resources $out/lib/${pname}/ - # install desktop entry mkdir -p $out/share/applications cp ${desktopItem}/share/applications/* $out/share/applications/ - # install icon files mkdir -p $out/share/icons/hicolor/{32x32,128x128}/apps - install -Dm644 src-tauri/icons/32x32.png $out/share/icons/hicolor/32x32/apps/${pname}.png - install -Dm644 src-tauri/icons/128x128.png $out/share/icons/hicolor/128x128/apps/${pname}.png + install -Dm644 src-tauri/icons/windows/32x32.png $out/share/icons/hicolor/32x32/apps/${pname}.png + install -Dm644 src-tauri/icons/windows/128x128.png $out/share/icons/hicolor/128x128/apps/${pname}.png runHook postInstall ''; - # add extra args to wrapGAppsHook3 wrapper preFixup = '' gappsWrapperArgs+=( - --prefix PATH : ${ - lib.makeBinPath [ - # `defguard-service` needs `ip` to manage WireGuard - pkgs.iproute2 - # `defguard-service` needs `resolvconf` to manage DNS - pkgs.openresolv - # `defguard-client` needs `update-desktop-database` and `lsb_release` - pkgs.desktop-file-utils - pkgs.lsb-release - ] - } - --prefix LD_LIBRARY_PATH : ${ - lib.makeLibraryPath [ - pkgs.libayatana-appindicator - ] - } + --prefix PATH : ${lib.makeBinPath [pkgs.iproute2 pkgs.desktop-file-utils pkgs.lsb-release]} + --suffix PATH : ${lib.makeBinPath [pkgs.openresolv]} + --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [pkgs.libayatana-appindicator]} ) ''; + VERGEN_IDEMPOTENT = "true"; + SQLX_OFFLINE = "true"; + doInstallCargoArtifacts = false; + + # passthru attrs are ignored by the build but addressable by external tools. + # newUiPnpmDeps has its own pinned hash that must be kept current when + # new-ui/pnpm-lock.yaml changes. Any hash-refresh automation (e.g. an + # update-pnpm-hash workflow) must update it. + passthru = { + inherit newUiPnpmDeps; + }; + meta = with lib; { description = "Defguard VPN Client"; homepage = "https://defguard.net"; @@ -160,4 +221,4 @@ in maintainers = with maintainers; [wojcik91]; platforms = platforms.linux; }; - }) + } diff --git a/nix/shell.nix b/nix/shell.nix index 34c2be3a0..f3d5f7fa3 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,18 +1,37 @@ -{pkgs ? import {}}: let +{ + pkgs, + crane, +}: let # add development-related cargo tooling rustToolchain = pkgs.rust-bin.stable.latest.default.override { extensions = ["rust-analyzer" "rust-src" "rustfmt" "clippy"]; targets = ["x86_64-apple-darwin" "aarch64-apple-darwin" "x86_64-pc-windows-gnu"]; }; - # share custom toolchain with package - rustPlatform = pkgs.makeRustPlatform { - cargo = rustToolchain; - rustc = rustToolchain; - }; + # nightly rustfmt, needed only for the unstable import-grouping options that + # `fmt-imports` passes via --config. It is deliberately NOT placed on PATH + # (that would collide with the stable rustfmt above); the wrapper points + # stable `cargo fmt` at it via RUSTFMT, leaving the default toolchain alone. + # The unstable options live only here (not in a committed rustfmt.toml), so a + # normal `cargo fmt` sees no unstable keys and stays warning-free. + rustfmtNightly = pkgs.rust-bin.nightly.latest.rustfmt; + + # Usage: fmt-imports [cargo fmt flags] e.g. fmt-imports --check + fmtImports = pkgs.writeShellScriptBin "fmt-imports" '' + set -euo pipefail + root="$(${pkgs.git}/bin/git rev-parse --show-toplevel)" + cd "$root/src-tauri" + export RUSTFMT="${rustfmtNightly}/bin/rustfmt" + exec ${rustToolchain}/bin/cargo fmt "$@" -- \ + --config imports_granularity=Crate,group_imports=StdExternalCrate + ''; + + inherit (import ./versions.nix pkgs) nodejs pnpm; + + craneLib = crane.mkLib pkgs; defguard-client = pkgs.callPackage ./package.nix { - inherit rustPlatform; + inherit craneLib; cargo = rustToolchain; rustc = rustToolchain; }; @@ -29,9 +48,17 @@ in # add additional dev tools packages = with pkgs; [ rustToolchain + fmtImports trunk sqlx-cli + cargo-nextest vtsls + trivy + desktop-file-utils + xdg-utils + just + nodejs + pnpm ]; shellHook = with pkgs; '' diff --git a/nix/versions.nix b/nix/versions.nix new file mode 100644 index 000000000..37e1357cf --- /dev/null +++ b/nix/versions.nix @@ -0,0 +1,16 @@ +# Single source of truth for the Node.js + pnpm versions used by both the +# Nix build (newUiDist in package.nix) and the dev shell (shell.nix), so the +# two can't drift. +# +# Node is derived from new-ui/.nvmrc - the same file nvm/fnm and CI read - so +# non-Nix developers, CI, and Nix all track one version. .nvmrc holds the +# major (e.g. "26"); Nix maps it to the matching nixpkgs attribute. The +# concrete patch version still comes from flake.lock's nixpkgs pin. +pkgs: let + nodeMajor = builtins.head ( + builtins.match "[^0-9]*([0-9]+).*" (builtins.readFile ../new-ui/.nvmrc) + ); +in { + nodejs = pkgs."nodejs_${nodeMajor}"; + pnpm = pkgs.pnpm_11; +} diff --git a/package.json b/package.json deleted file mode 100644 index 087818775..000000000 --- a/package.json +++ /dev/null @@ -1,141 +0,0 @@ -{ - "name": "defguard-client", - "private": false, - "version": "1.6.8", - "type": "module", - "scripts": { - "dev": "npm-run-all --parallel vite typesafe-i18n", - "typecheck": "tsc --project ./tsconfig.app.json", - "build": "pnpm run typecheck && vite build", - "preview": "vite preview", - "typesafe-i18n": "typesafe-i18n", - "generate-translation-types": "typesafe-i18n --no-watch", - "fix": "biome check --fix && prettier src/**/*.scss -w --log-level silent", - "fix-unsafe": "biome check --fix --unsafe && prettier src/**/*.scss -w --log-level silent", - "lint": "biome lint && pnpm run typecheck && prettier src/**/*.scss --check --log-level error", - "lint-ci": "biome ci && pnpm run typecheck && prettier src/**/*.scss --check --log-level error", - "vite": "vite", - "prettier": "prettier", - "parse-client-svgs": "svgr --no-index --jsx-runtime automatic --svgo-config ./svgo.config.json --prettier-config ./.prettierrc --out-dir ./src/shared/components/svg/ --typescript ./src/shared/images/svg/", - "parse-ui-svgs": "svgr --no-index --jsx-runtime automatic --svgo-config ./svgo.config.json --prettier-config ./.prettierrc --out-dir ./src/shared/defguard-ui/components/svg/ --typescript ./src/shared/defguard-ui/images/svg/", - "parse-svgs": "pnpm parse-ui-svgs && pnpm parse-client-svgs", - "svgr": "svgr", - "tauri": "tauri", - "biome": "biome" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "pnpm": { - "peerDependencyRules": { - "ignoreMissing": [ - "react-native" - ] - }, - "onlyBuiltDependencies": [ - "@parcel/watcher", - "@swc/core", - "esbuild" - ], - "overrides": { - "mdast-util-to-hast": "13.2.1" - } - }, - "dependencies": { - "@floating-ui/react": "^0.27.19", - "@hookform/resolvers": "^3.10.0", - "@react-hook/resize-observer": "^2.0.2", - "@stablelib/base64": "^2.0.1", - "@stablelib/x25519": "^2.0.1", - "@tanstack/query-core": "^5.100.5", - "@tanstack/react-virtual": "^3.13.24", - "@tauri-apps/api": "^2.10.1", - "@tauri-apps/plugin-clipboard-manager": "^2.3.2", - "@tauri-apps/plugin-deep-link": "^2.4.8", - "@tauri-apps/plugin-dialog": "^2.7.0", - "@tauri-apps/plugin-fs": "^2.5.0", - "@tauri-apps/plugin-http": "^2.5.8", - "@tauri-apps/plugin-log": "^2.8.0", - "@tauri-apps/plugin-notification": "^2.3.3", - "@tauri-apps/plugin-opener": "^2.5.3", - "@tauri-apps/plugin-os": "^2.3.2", - "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-window-state": "^2.4.1", - "@types/byte-size": "^8.1.2", - "@use-gesture/react": "^10.3.1", - "byte-size": "^9.0.1", - "classnames": "^2.5.1", - "clsx": "^2.1.1", - "compare-versions": "^6.1.1", - "dayjs": "^1.11.20", - "deepmerge-ts": "^7.1.5", - "detect-browser": "^5.3.0", - "fast-deep-equal": "^3.1.3", - "file-saver": "^2.0.5", - "get-text-width": "^1.0.3", - "html-react-parser": "^5.2.17", - "itertools": "^2.6.0", - "js-base64": "^3.7.8", - "lodash-es": "^4.18.1", - "merge-refs": "^2.0.0", - "millify": "^6.1.0", - "motion": "^12.38.0", - "p-timeout": "^6.1.4", - "prop-types": "^15.8.1", - "radash": "^12.1.1", - "react": "^19.2.5", - "react-auth-code-input": "^3.2.1", - "react-click-away-listener": "^2.4.1", - "react-dom": "^19.2.5", - "react-hook-form": "^7.74.0", - "react-hotkeys-hook": "^5.2.4", - "react-loading-skeleton": "^3.5.0", - "react-markdown": "^10.1.0", - "react-qr-code": "^2.0.18", - "react-router-dom": "^6.30.3", - "react-use-websocket": "^4.13.0", - "react-virtualized-auto-sizer": "^1.0.26", - "recharts": "^3.8.1", - "rehype-sanitize": "^6.0.0", - "rxjs": "^7.8.2", - "use-breakpoint": "^4.0.10", - "zod": "^3.25.76", - "zustand": "^5.0.12" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.13", - "@hookform/devtools": "^4.4.0", - "@svgr/cli": "^8.1.0", - "@tanstack/react-query": "^5.100.5", - "@tanstack/react-query-devtools": "^5.100.5", - "@tauri-apps/cli": "^2.10.1", - "@types/file-saver": "^2.0.7", - "@types/lodash-es": "^4.17.12", - "@types/node": "^24.12.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.2.0", - "@vitejs/plugin-react-swc": "^4.3.0", - "autoprefixer": "^10.5.0", - "npm-run-all": "^4.1.5", - "postcss": "^8.5.12", - "prettier": "^3.8.3", - "sass": "~1.92.1", - "typedoc": "^0.28.19", - "typesafe-i18n": "^5.27.1", - "typescript": "^5.9.3", - "vite": "^7.3.2" - }, - "volta": { - "node": "20.5.1" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index ae5e3e41d..000000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,6020 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -overrides: - mdast-util-to-hast: 13.2.1 - -importers: - - .: - dependencies: - '@floating-ui/react': - specifier: ^0.27.19 - version: 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@hookform/resolvers': - specifier: ^3.10.0 - version: 3.10.0(react-hook-form@7.74.0(react@19.2.5)) - '@react-hook/resize-observer': - specifier: ^2.0.2 - version: 2.0.2(react@19.2.5) - '@stablelib/base64': - specifier: ^2.0.1 - version: 2.0.1 - '@stablelib/x25519': - specifier: ^2.0.1 - version: 2.0.1 - '@tanstack/query-core': - specifier: ^5.100.5 - version: 5.100.5 - '@tanstack/react-virtual': - specifier: ^3.13.24 - version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tauri-apps/api': - specifier: ^2.10.1 - version: 2.10.1 - '@tauri-apps/plugin-clipboard-manager': - specifier: ^2.3.2 - version: 2.3.2 - '@tauri-apps/plugin-deep-link': - specifier: ^2.4.8 - version: 2.4.8 - '@tauri-apps/plugin-dialog': - specifier: ^2.7.0 - version: 2.7.0 - '@tauri-apps/plugin-fs': - specifier: ^2.5.0 - version: 2.5.0 - '@tauri-apps/plugin-http': - specifier: ^2.5.8 - version: 2.5.8 - '@tauri-apps/plugin-log': - specifier: ^2.8.0 - version: 2.8.0 - '@tauri-apps/plugin-notification': - specifier: ^2.3.3 - version: 2.3.3 - '@tauri-apps/plugin-opener': - specifier: ^2.5.3 - version: 2.5.3 - '@tauri-apps/plugin-os': - specifier: ^2.3.2 - version: 2.3.2 - '@tauri-apps/plugin-process': - specifier: ^2.3.1 - version: 2.3.1 - '@tauri-apps/plugin-window-state': - specifier: ^2.4.1 - version: 2.4.1 - '@types/byte-size': - specifier: ^8.1.2 - version: 8.1.2 - '@use-gesture/react': - specifier: ^10.3.1 - version: 10.3.1(react@19.2.5) - byte-size: - specifier: ^9.0.1 - version: 9.0.1 - classnames: - specifier: ^2.5.1 - version: 2.5.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - compare-versions: - specifier: ^6.1.1 - version: 6.1.1 - dayjs: - specifier: ^1.11.20 - version: 1.11.20 - deepmerge-ts: - specifier: ^7.1.5 - version: 7.1.5 - detect-browser: - specifier: ^5.3.0 - version: 5.3.0 - fast-deep-equal: - specifier: ^3.1.3 - version: 3.1.3 - file-saver: - specifier: ^2.0.5 - version: 2.0.5 - get-text-width: - specifier: ^1.0.3 - version: 1.0.3 - html-react-parser: - specifier: ^5.2.17 - version: 5.2.17(@types/react@19.2.14)(react@19.2.5) - itertools: - specifier: ^2.6.0 - version: 2.6.0 - js-base64: - specifier: ^3.7.8 - version: 3.7.8 - lodash-es: - specifier: ^4.18.1 - version: 4.18.1 - merge-refs: - specifier: ^2.0.0 - version: 2.0.0(@types/react@19.2.14) - millify: - specifier: ^6.1.0 - version: 6.1.0 - motion: - specifier: ^12.38.0 - version: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - p-timeout: - specifier: ^6.1.4 - version: 6.1.4 - prop-types: - specifier: ^15.8.1 - version: 15.8.1 - radash: - specifier: ^12.1.1 - version: 12.1.1 - react: - specifier: ^19.2.5 - version: 19.2.5 - react-auth-code-input: - specifier: ^3.2.1 - version: 3.2.1(react@19.2.5) - react-click-away-listener: - specifier: ^2.4.1 - version: 2.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-dom: - specifier: ^19.2.5 - version: 19.2.5(react@19.2.5) - react-hook-form: - specifier: ^7.74.0 - version: 7.74.0(react@19.2.5) - react-hotkeys-hook: - specifier: ^5.2.4 - version: 5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-loading-skeleton: - specifier: ^3.5.0 - version: 3.5.0(react@19.2.5) - react-markdown: - specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.14)(react@19.2.5) - react-qr-code: - specifier: ^2.0.18 - version: 2.0.18(react@19.2.5) - react-router-dom: - specifier: ^6.30.3 - version: 6.30.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-use-websocket: - specifier: ^4.13.0 - version: 4.13.0 - react-virtualized-auto-sizer: - specifier: ^1.0.26 - version: 1.0.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - recharts: - specifier: ^3.8.1 - version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1) - rehype-sanitize: - specifier: ^6.0.0 - version: 6.0.0 - rxjs: - specifier: ^7.8.2 - version: 7.8.2 - use-breakpoint: - specifier: ^4.0.10 - version: 4.0.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - zod: - specifier: ^3.25.76 - version: 3.25.76 - zustand: - specifier: ^5.0.12 - version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) - devDependencies: - '@biomejs/biome': - specifier: ^2.4.13 - version: 2.4.13 - '@hookform/devtools': - specifier: ^4.4.0 - version: 4.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@svgr/cli': - specifier: ^8.1.0 - version: 8.1.0(typescript@5.9.3) - '@tanstack/react-query': - specifier: ^5.100.5 - version: 5.100.5(react@19.2.5) - '@tanstack/react-query-devtools': - specifier: ^5.100.5 - version: 5.100.5(@tanstack/react-query@5.100.5(react@19.2.5))(react@19.2.5) - '@tauri-apps/cli': - specifier: ^2.10.1 - version: 2.10.1 - '@types/file-saver': - specifier: ^2.0.7 - version: 2.0.7 - '@types/lodash-es': - specifier: ^4.17.12 - version: 4.17.12 - '@types/node': - specifier: ^24.12.2 - version: 24.12.2 - '@types/react': - specifier: ^19.2.14 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: ^5.2.0 - version: 5.2.0(vite@7.3.2(@types/node@24.12.2)(sass@1.92.1)(yaml@2.8.3)) - '@vitejs/plugin-react-swc': - specifier: ^4.3.0 - version: 4.3.0(vite@7.3.2(@types/node@24.12.2)(sass@1.92.1)(yaml@2.8.3)) - autoprefixer: - specifier: ^10.5.0 - version: 10.5.0(postcss@8.5.12) - npm-run-all: - specifier: ^4.1.5 - version: 4.1.5 - postcss: - specifier: ^8.5.12 - version: 8.5.12 - prettier: - specifier: ^3.8.3 - version: 3.8.3 - sass: - specifier: ~1.92.1 - version: 1.92.1 - typedoc: - specifier: ^0.28.19 - version: 0.28.19(typescript@5.9.3) - typesafe-i18n: - specifier: ^5.27.1 - version: 5.27.1(typescript@5.9.3) - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vite: - specifier: ^7.3.2 - version: 7.3.2(@types/node@24.12.2)(sass@1.92.1)(yaml@2.8.3) - -packages: - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@biomejs/biome@2.4.13': - resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==} - engines: {node: '>=14.21.3'} - hasBin: true - - '@biomejs/cli-darwin-arm64@2.4.13': - resolution: {integrity: sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [darwin] - - '@biomejs/cli-darwin-x64@2.4.13': - resolution: {integrity: sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [darwin] - - '@biomejs/cli-linux-arm64-musl@2.4.13': - resolution: {integrity: sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-arm64@2.4.13': - resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-linux-x64-musl@2.4.13': - resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-x64@2.4.13': - resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-win32-arm64@2.4.13': - resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [win32] - - '@biomejs/cli-win32-x64@2.4.13': - resolution: {integrity: sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [win32] - - '@emotion/babel-plugin@11.13.5': - resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} - - '@emotion/cache@11.14.0': - resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} - - '@emotion/hash@0.9.2': - resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - - '@emotion/is-prop-valid@1.4.0': - resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} - - '@emotion/memoize@0.9.0': - resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} - - '@emotion/react@11.14.0': - resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} - peerDependencies: - '@types/react': '*' - react: '>=16.8.0' - peerDependenciesMeta: - '@types/react': - optional: true - - '@emotion/serialize@1.3.3': - resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} - - '@emotion/sheet@1.4.0': - resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} - - '@emotion/styled@11.14.1': - resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} - peerDependencies: - '@emotion/react': ^11.0.0-rc.0 - '@types/react': '*' - react: '>=16.8.0' - peerDependenciesMeta: - '@types/react': - optional: true - - '@emotion/unitless@0.10.0': - resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} - - '@emotion/use-insertion-effect-with-fallbacks@1.2.0': - resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} - peerDependencies: - react: '>=16.8.0' - - '@emotion/utils@1.4.2': - resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} - - '@emotion/weak-memoize@0.4.0': - resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/react@0.27.19': - resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} - peerDependencies: - react: '>=17.0.0' - react-dom: '>=17.0.0' - - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - - '@gerrit0/mini-shiki@3.23.0': - resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} - - '@hookform/devtools@4.4.0': - resolution: {integrity: sha512-Mtlic+uigoYBPXlfvPBfiYYUZuyMrD3pTjDpVIhL6eCZTvQkHsKBSKeZCvXWUZr8fqrkzDg27N+ZuazLKq6Vmg==} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - react-dom: ^16.8.0 || ^17 || ^18 || ^19 - - '@hookform/resolvers@3.10.0': - resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} - peerDependencies: - react-hook-form: ^7.0.0 - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@parcel/watcher-android-arm64@2.5.6': - resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - - '@parcel/watcher-darwin-arm64@2.5.6': - resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - - '@parcel/watcher-darwin-x64@2.5.6': - resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - - '@parcel/watcher-freebsd-x64@2.5.6': - resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - - '@parcel/watcher-linux-arm-glibc@2.5.6': - resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm-musl@2.5.6': - resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-x64-glibc@2.5.6': - resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@parcel/watcher-win32-arm64@2.5.6': - resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - - '@parcel/watcher-win32-ia32@2.5.6': - resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.6': - resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - - '@parcel/watcher@2.5.6': - resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} - engines: {node: '>= 10.0.0'} - - '@react-hook/latest@1.0.3': - resolution: {integrity: sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==} - peerDependencies: - react: '>=16.8' - - '@react-hook/passive-layout-effect@1.2.1': - resolution: {integrity: sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==} - peerDependencies: - react: '>=16.8' - - '@react-hook/resize-observer@2.0.2': - resolution: {integrity: sha512-tzKKzxNpfE5TWmxuv+5Ae3IF58n0FQgQaWJmcbYkjXTRZATXxClnTprQ2uuYygYTpu1pqbBskpwMpj6jpT1djA==} - peerDependencies: - react: '>=18' - - '@reduxjs/toolkit@2.11.2': - resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} - peerDependencies: - react: ^16.9.0 || ^17.0.0 || ^18 || ^19 - react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 - peerDependenciesMeta: - react: - optional: true - react-redux: - optional: true - - '@remix-run/router@1.23.2': - resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} - engines: {node: '>=14.0.0'} - - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rolldown/pluginutils@1.0.0-rc.7': - resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} - - '@rollup/rollup-android-arm-eabi@4.60.2': - resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.2': - resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.60.2': - resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.60.2': - resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.60.2': - resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.2': - resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.60.2': - resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.60.2': - resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.60.2': - resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.60.2': - resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.60.2': - resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.60.2': - resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.60.2': - resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.60.2': - resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.60.2': - resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.60.2': - resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.2': - resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.60.2': - resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.60.2': - resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.60.2': - resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.60.2': - resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} - cpu: [x64] - os: [win32] - - '@shikijs/engine-oniguruma@3.23.0': - resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} - - '@shikijs/langs@3.23.0': - resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} - - '@shikijs/themes@3.23.0': - resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} - - '@shikijs/types@3.23.0': - resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} - - '@shikijs/vscode-textmate@10.0.2': - resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - - '@stablelib/base64@2.0.1': - resolution: {integrity: sha512-P2z89A7N1ETt6RxgpVdDT2xlg8cnm3n6td0lY9gyK7EiWK3wdq388yFX/hLknkCC0we05OZAD1rfxlQJUbl5VQ==} - - '@stablelib/binary@2.0.1': - resolution: {integrity: sha512-U9iAO8lXgEDONsA0zPPSgcf3HUBNAqHiJmSHgZz62OvC3Hi2Bhc5kTnQ3S1/L+sthDTHtCMhcEiklmIly6uQ3w==} - - '@stablelib/bytes@2.0.1': - resolution: {integrity: sha512-QIzI6V7nkJA5CjOZ7GoceBd4CIKrJoC471VaI6jh1xPQ2cMhkhQK4HddyzCXOR2y+fBF3/5B2HO3FXXI9C+Xzg==} - - '@stablelib/int@2.0.1': - resolution: {integrity: sha512-Ht63fQp3wz/F8U4AlXEPb7hfJOIILs8Lq55jgtD7KueWtyjhVuzcsGLSTAWtZs3XJDZYdF1WcSKn+kBtbzupww==} - - '@stablelib/keyagreement@2.0.1': - resolution: {integrity: sha512-2+tWBLCMtWlHQ7GqjD5L+lQRyWtun4Lou0IOdTML8zuTuAS0EgihnHFx+4uMZwYU1In40J/WlpyKSLidHfStRQ==} - - '@stablelib/random@2.0.1': - resolution: {integrity: sha512-W6GAtXEEs7r+dSbuBsvoFmlyL3gLxle41tQkjKu17dDWtDdjhVUbtRfRCQcCUeczwkgjQxMPopgwYEvxXtHXGw==} - - '@stablelib/wipe@2.0.1': - resolution: {integrity: sha512-1eU2K9EgOcV4qc9jcP6G72xxZxEm5PfeI5H55l08W95b4oRJaqhmlWRc4xZAm6IVSKhVNxMi66V67hCzzuMTAg==} - - '@stablelib/x25519@2.0.1': - resolution: {integrity: sha512-qi04HS2puHaBf50kM/kes5QcZFGsx8yF0YmCjLCOa/LPmnBaKEKX9ZR82OnnCwMn72YH13R/bBZgr/UP0aPFfA==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@standard-schema/utils@0.3.0': - resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - - '@svgr/babel-plugin-add-jsx-attribute@8.0.0': - resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} - engines: {node: '>=14'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@svgr/babel-plugin-remove-jsx-attribute@8.0.0': - resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} - engines: {node: '>=14'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0': - resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} - engines: {node: '>=14'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0': - resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} - engines: {node: '>=14'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@svgr/babel-plugin-svg-dynamic-title@8.0.0': - resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} - engines: {node: '>=14'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@svgr/babel-plugin-svg-em-dimensions@8.0.0': - resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} - engines: {node: '>=14'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@svgr/babel-plugin-transform-react-native-svg@8.1.0': - resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} - engines: {node: '>=14'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@svgr/babel-plugin-transform-svg-component@8.0.0': - resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} - engines: {node: '>=12'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@svgr/babel-preset@8.1.0': - resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} - engines: {node: '>=14'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@svgr/cli@8.1.0': - resolution: {integrity: sha512-SnlaLspB610XFXvs3PmhzViHErsXp0yIy4ERyZlHDlO1ro2iYtHMWYk2mztdLD/lBjiA4ZXe4RePON3qU/Tc4A==} - engines: {node: '>=14'} - hasBin: true - - '@svgr/core@8.1.0': - resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} - engines: {node: '>=14'} - - '@svgr/hast-util-to-babel-ast@8.0.0': - resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} - engines: {node: '>=14'} - - '@svgr/plugin-jsx@8.1.0': - resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} - engines: {node: '>=14'} - peerDependencies: - '@svgr/core': '*' - - '@svgr/plugin-prettier@8.1.0': - resolution: {integrity: sha512-o4/uFI8G64tAjBZ4E7gJfH+VP7Qi3T0+M4WnIsP91iFnGPqs5WvPDkpZALXPiyWEtzfYs1Rmwy1Zdfu8qoZuKw==} - engines: {node: '>=14'} - peerDependencies: - '@svgr/core': '*' - - '@svgr/plugin-svgo@8.1.0': - resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==} - engines: {node: '>=14'} - peerDependencies: - '@svgr/core': '*' - - '@swc/core-darwin-arm64@1.15.30': - resolution: {integrity: sha512-VvpP+vq08HmGYewMWvrdsxh9s2lthz/808zXm8Yu5kaqeR8Yia2b0eYXleHQ3VAjoStUDk6LzTheBW9KXYQdMA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [darwin] - - '@swc/core-darwin-x64@1.15.30': - resolution: {integrity: sha512-WiJA0hiZI3nwQAO6mu5RqigtWGDtth4Hiq6rbZxAaQyhIcqKIg5IoMRc1Y071lrNJn29eEDMC86Rq58xgUxlDg==} - engines: {node: '>=10'} - cpu: [x64] - os: [darwin] - - '@swc/core-linux-arm-gnueabihf@1.15.30': - resolution: {integrity: sha512-YANuFUo48kIT6plJgCD0keae9HFXfjxsbvsgevqc0hr/07X/p7sAWTFOGYEc2SXcASaK7UvuQqzlbW8pr7R79g==} - engines: {node: '>=10'} - cpu: [arm] - os: [linux] - - '@swc/core-linux-arm64-gnu@1.15.30': - resolution: {integrity: sha512-VndG8jaR4ugY6u+iVOT0Q+d2fZd7sLgjPgN8W/Le+3EbZKl+cRfFxV7Eoz4gfLqhmneZPdcIzf9T3LkgkmqNLg==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@swc/core-linux-arm64-musl@1.15.30': - resolution: {integrity: sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@swc/core-linux-ppc64-gnu@1.15.30': - resolution: {integrity: sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==} - engines: {node: '>=10'} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@swc/core-linux-s390x-gnu@1.15.30': - resolution: {integrity: sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==} - engines: {node: '>=10'} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@swc/core-linux-x64-gnu@1.15.30': - resolution: {integrity: sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@swc/core-linux-x64-musl@1.15.30': - resolution: {integrity: sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@swc/core-win32-arm64-msvc@1.15.30': - resolution: {integrity: sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==} - engines: {node: '>=10'} - cpu: [arm64] - os: [win32] - - '@swc/core-win32-ia32-msvc@1.15.30': - resolution: {integrity: sha512-30VdLeGk6fugiUs/kUdJ/pAg7z/zpvVbR11RH60jZ0Z42WIeIniYx0rLEWN7h/pKJ3CopqsQ3RsogCAkRKiA2g==} - engines: {node: '>=10'} - cpu: [ia32] - os: [win32] - - '@swc/core-win32-x64-msvc@1.15.30': - resolution: {integrity: sha512-4iObHPR+Q4oDY110EF5SF5eIaaVJNpMdG9C0q3Q92BsJ5y467uHz7sYQhP60WYlLFsLQ1el2YrIPUItUAQGOKg==} - engines: {node: '>=10'} - cpu: [x64] - os: [win32] - - '@swc/core@1.15.30': - resolution: {integrity: sha512-R8VQbQY1BZcbIF2p3gjlTCwAQzx1A194ugWfwld5y+WgVVWqVKm7eURGGOVbQVubgKWzidP2agomBbg96rZilQ==} - engines: {node: '>=10'} - peerDependencies: - '@swc/helpers': '>=0.5.17' - peerDependenciesMeta: - '@swc/helpers': - optional: true - - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - - '@swc/types@0.1.26': - resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} - - '@tanstack/query-core@5.100.5': - resolution: {integrity: sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==} - - '@tanstack/query-devtools@5.100.5': - resolution: {integrity: sha512-SuCkVCqqliRYJvm+LEL2U/TcFv92zTnHj6OGrJFHp1v/RsiwamI+ZDgQzbeUrLsJb8/Nj/52aIw0NyDMcVHl4A==} - - '@tanstack/react-query-devtools@5.100.5': - resolution: {integrity: sha512-bItQERx7dJoiI0WEoS4tIrvNnmk4kUYsaQLdIpm4o9Kttmsi5B6xlY6JBDkavstR3hH/R2+VT5dr3L5LBFPW4g==} - peerDependencies: - '@tanstack/react-query': ^5.100.5 - react: ^18 || ^19 - - '@tanstack/react-query@5.100.5': - resolution: {integrity: sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==} - peerDependencies: - react: ^18 || ^19 - - '@tanstack/react-virtual@3.13.24': - resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@tanstack/virtual-core@3.14.0': - resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} - - '@tauri-apps/api@2.10.1': - resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} - - '@tauri-apps/cli-darwin-arm64@2.10.1': - resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tauri-apps/cli-darwin-x64@2.10.1': - resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': - resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tauri-apps/cli-linux-arm64-gnu@2.10.1': - resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tauri-apps/cli-linux-arm64-musl@2.10.1': - resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': - resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@tauri-apps/cli-linux-x64-gnu@2.10.1': - resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tauri-apps/cli-linux-x64-musl@2.10.1': - resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tauri-apps/cli-win32-arm64-msvc@2.10.1': - resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tauri-apps/cli-win32-ia32-msvc@2.10.1': - resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@tauri-apps/cli-win32-x64-msvc@2.10.1': - resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tauri-apps/cli@2.10.1': - resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==} - engines: {node: '>= 10'} - hasBin: true - - '@tauri-apps/plugin-clipboard-manager@2.3.2': - resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} - - '@tauri-apps/plugin-deep-link@2.4.8': - resolution: {integrity: sha512-Cd2Cs960MGuGONeIwxOPx9wqwedetAHOGlwK5boJ/SMTfAtAyfErpfVPEn+EJzgXsJun8EKzsEumHjr+64V4fw==} - - '@tauri-apps/plugin-dialog@2.7.0': - resolution: {integrity: sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==} - - '@tauri-apps/plugin-fs@2.5.0': - resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==} - - '@tauri-apps/plugin-http@2.5.8': - resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==} - - '@tauri-apps/plugin-log@2.8.0': - resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==} - - '@tauri-apps/plugin-notification@2.3.3': - resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} - - '@tauri-apps/plugin-opener@2.5.3': - resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} - - '@tauri-apps/plugin-os@2.3.2': - resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} - - '@tauri-apps/plugin-process@2.3.1': - resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} - - '@tauri-apps/plugin-window-state@2.4.1': - resolution: {integrity: sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/byte-size@8.1.2': - resolution: {integrity: sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==} - - '@types/d3-array@3.2.2': - resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.1.1': - resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - - '@types/d3-scale@4.0.9': - resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - - '@types/d3-shape@3.1.8': - resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} - - '@types/d3-time@3.0.4': - resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - - '@types/debug@4.1.13': - resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} - - '@types/estree-jsx@1.0.5': - resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/file-saver@2.0.7': - resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} - - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - - '@types/lodash-es@4.17.12': - resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - - '@types/lodash@4.17.24': - resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} - - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@24.12.2': - resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} - - '@types/parse-json@4.0.2': - resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - - '@types/unist@2.0.11': - resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} - - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - - '@types/use-sync-external-store@0.0.6': - resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - - '@use-gesture/core@10.3.1': - resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} - - '@use-gesture/react@10.3.1': - resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} - peerDependencies: - react: '>= 16.8.0' - - '@vitejs/plugin-react-swc@4.3.0': - resolution: {integrity: sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4 || ^5 || ^6 || ^7 || ^8 - - '@vitejs/plugin-react@5.2.0': - resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - autoprefixer@10.5.0: - resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - babel-plugin-macros@3.1.0: - resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} - engines: {node: '>=10', npm: '>=6'} - - bail@2.0.2: - resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.10.23: - resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} - engines: {node: '>=6.0.0'} - hasBin: true - - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - byte-size@9.0.1: - resolution: {integrity: sha512-YLe9x3rabBrcI0cueCdLS2l5ONUKywcRpTs02B8KP9/Cimhj7o3ZccGrPnRvcbyHMbb7W79/3MUJl7iGgTXKEw==} - engines: {node: '>=12.17'} - peerDependencies: - '@75lb/nature': latest - peerDependenciesMeta: - '@75lb/nature': - optional: true - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.9: - resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - caniuse-lite@1.0.30001791: - resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} - - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - - chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - - character-entities@2.0.2: - resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - - character-reference-invalid@2.0.1: - resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - classnames@2.5.1: - resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - - commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} - - compare-versions@6.1.1: - resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - convert-source-map@1.9.0: - resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cosmiconfig@7.1.0: - resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} - engines: {node: '>=10'} - - cosmiconfig@8.3.6: - resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - cross-spawn@6.0.6: - resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} - engines: {node: '>=4.8'} - - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} - - css-tree@2.2.1: - resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} - - csso@5.0.5: - resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-format@3.1.2: - resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - - dashify@2.0.0: - resolution: {integrity: sha512-hpA5C/YrPjucXypHPPc0oJ1l9Hf6wWbiOL7Ik42cxnsUOhWiCB/fylKbKqqJalW9FgkNQCw16YO8uW9Hs0Iy1A==} - engines: {node: '>=4'} - - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - - dayjs@1.11.20: - resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js-light@2.5.1: - resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - - decode-named-character-reference@1.3.0: - resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - - deepmerge-ts@7.1.5: - resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} - engines: {node: '>=16.0.0'} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - detect-browser@5.3.0: - resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - - dot-case@3.0.4: - resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - electron-to-chromium@1.5.344: - resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - - es-abstract@1.24.2: - resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - - es-toolkit@1.46.0: - resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==} - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - estree-util-is-identifier-name@3.0.0: - resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-saver@2.0.5: - resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} - - find-root@1.1.0: - resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} - - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - - framer-motion@12.38.0: - resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - - get-text-width@1.0.3: - resolution: {integrity: sha512-kv1MaexPcR/qaZ4kN8sUDjG5pRp5ptHvxcDGDBTeGld1cmo7MnlCMH22jevyvs/VV7Ran203o7qAOq2+kWw9cA==} - - glob@8.1.0: - resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} - engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - - has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} - engines: {node: '>= 0.4'} - - hast-util-sanitize@5.0.2: - resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} - - hast-util-to-jsx-runtime@2.3.6: - resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} - - hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - - hosted-git-info@2.8.9: - resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} - - html-dom-parser@5.1.8: - resolution: {integrity: sha512-MCIUng//mF2qTtGHXJWr6OLfHWmg3Pm8ezpfiltF83tizPWY17JxT4dRLE8lykJ5bChJELoY3onQKPbufJHxYA==} - - html-react-parser@5.2.17: - resolution: {integrity: sha512-m+K/7Moq1jodAB4VL0RXSOmtwLUYoAsikZhwd+hGQe5Vtw2dbWfpFd60poxojMU0Tsh9w59mN1QLEcoHz0Dx9w==} - peerDependencies: - '@types/react': 0.14 || 15 || 16 || 17 || 18 || 19 - react: 0.14 || 15 || 16 || 17 || 18 || 19 - peerDependenciesMeta: - '@types/react': - optional: true - - html-url-attributes@3.0.1: - resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - - immer@10.2.0: - resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} - - immer@11.1.4: - resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} - - immutable@5.1.5: - resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - inline-style-parser@0.2.7: - resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - - is-alphabetical@2.0.1: - resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - - is-alphanumerical@2.0.1: - resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-decimal@2.0.1: - resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-hexadecimal@2.0.1: - resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - itertools@2.6.0: - resolution: {integrity: sha512-nCqtnZTEGq8Bcs+W3kqdYL77tV6sGuQCA1WvKeA8L5Bx+BUMWcyHfJTCY38yNf51EE0/uB9UQHB84AM1j+djIw==} - - js-base64@3.7.8: - resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-parse-better-errors@1.0.2: - resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - - little-state-machine@4.8.1: - resolution: {integrity: sha512-liPHqaWMQ7rzZryQUDnbZ1Gclnnai3dIyaJ0nAgwZRXMzqbYrydrlCI0NDojRUbE5VYh5vu6hygEUZiH77nQkQ==} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - - load-json-file@4.0.0: - resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} - engines: {node: '>=4'} - - lodash-es@4.18.1: - resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} - - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - - longest-streak@3.1.0: - resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - lower-case@2.0.2: - resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lunr@2.3.9: - resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} - - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} - hasBin: true - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mdast-util-from-markdown@2.0.3: - resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} - - mdast-util-mdx-expression@2.0.1: - resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} - - mdast-util-mdx-jsx@3.2.0: - resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} - - mdast-util-mdxjs-esm@2.0.1: - resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} - - mdast-util-phrasing@4.1.0: - resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - - mdast-util-to-hast@13.2.1: - resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - - mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - - mdast-util-to-string@4.0.0: - resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - - mdn-data@2.0.28: - resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} - - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - - mdurl@2.0.0: - resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - - memorystream@0.3.1: - resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} - engines: {node: '>= 0.10.0'} - - merge-refs@2.0.0: - resolution: {integrity: sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - micromark-core-commonmark@2.0.3: - resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} - - micromark-factory-destination@2.0.1: - resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - - micromark-factory-label@2.0.1: - resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - - micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - - micromark-factory-title@2.0.1: - resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - - micromark-factory-whitespace@2.0.1: - resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-chunked@2.0.1: - resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - - micromark-util-classify-character@2.0.1: - resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - - micromark-util-combine-extensions@2.0.1: - resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - - micromark-util-decode-numeric-character-reference@2.0.2: - resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - - micromark-util-decode-string@2.0.1: - resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-html-tag-name@2.0.1: - resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - - micromark-util-normalize-identifier@2.0.1: - resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - - micromark-util-resolve-all@2.0.1: - resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-subtokenize@2.1.0: - resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - - micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - - millify@6.1.0: - resolution: {integrity: sha512-H/E3J6t+DQs/F2YgfDhxUVZz/dF8JXPPKTLHL/yHCcLZLtCXJDUaqvhJXQwqOVBvbyNn4T0WjLpIHd7PAw7fBA==} - hasBin: true - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimatch@5.1.9: - resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} - engines: {node: '>=10'} - - motion-dom@12.38.0: - resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} - - motion-utils@12.36.0: - resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} - - motion@12.38.0: - resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nice-try@1.0.5: - resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} - - no-case@3.0.4: - resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} - - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} - - normalize-package-data@2.5.0: - resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} - - npm-run-all@4.1.5: - resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} - engines: {node: '>= 4'} - hasBin: true - - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - - p-timeout@6.1.4: - resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} - engines: {node: '>=14.16'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - parse-entities@4.0.2: - resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} - - parse-json@4.0.0: - resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} - engines: {node: '>=4'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - path-key@2.0.1: - resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} - engines: {node: '>=4'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-type@3.0.0: - resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} - engines: {node: '>=4'} - - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - pidtree@0.3.1: - resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} - engines: {node: '>=0.10'} - hasBin: true - - pify@3.0.0: - resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} - engines: {node: '>=4'} - - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.12: - resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} - engines: {node: ^10 || ^12 || >=14} - - prettier@2.8.8: - resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} - engines: {node: '>=10.13.0'} - hasBin: true - - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} - engines: {node: '>=14'} - hasBin: true - - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - - property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - - punycode.js@2.3.1: - resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} - engines: {node: '>=6'} - - qr.js@0.0.0: - resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==} - - radash@12.1.1: - resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} - engines: {node: '>=14.18.0'} - - react-auth-code-input@3.2.1: - resolution: {integrity: sha512-oWWKbxDLU5g46gvE1DIvNFHsDx37JSAfB6WX8luG6TWZB3iDfKMjQnhUgb+0imL/6ykGVMqdZ426tQW1uj25kg==} - engines: {node: '>=10'} - peerDependencies: - react: '>=16.0.0' - - react-click-away-listener@2.4.1: - resolution: {integrity: sha512-tBquElBHme1xuE6VWTsBpdxu73QpLBshdpl0nCjYWDz7E5sokyy6yF0iSp/Y8ECLRtIy9TP8fDsEpZQEg0fZmw==} - engines: {node: '>=18'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} - peerDependencies: - react: ^19.2.5 - - react-hook-form@7.74.0: - resolution: {integrity: sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==} - engines: {node: '>=18.0.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - - react-hotkeys-hook@5.2.4: - resolution: {integrity: sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - - react-loading-skeleton@3.5.0: - resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} - peerDependencies: - react: '>=16.8.0' - - react-markdown@10.1.0: - resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} - peerDependencies: - '@types/react': '>=18' - react: '>=18' - - react-property@2.0.2: - resolution: {integrity: sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==} - - react-qr-code@2.0.18: - resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==} - peerDependencies: - react: '*' - - react-redux@9.2.0: - resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} - peerDependencies: - '@types/react': ^18.2.25 || ^19 - react: ^18.0 || ^19 - redux: ^5.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - redux: - optional: true - - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - - react-router-dom@6.30.3: - resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - react-router@6.30.3: - resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - - react-simple-animate@3.5.3: - resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==} - peerDependencies: - react-dom: ^16.8.0 || ^17 || ^18 || ^19 - - react-use-websocket@4.13.0: - resolution: {integrity: sha512-anMuVoV//g2N76Wxqvqjjo1X48r9Np3y1/gMl7arX84tAPXdy5R7sB5lO5hvCzQRYjqXwV8XMAiEBOUbyrZFrw==} - - react-virtualized-auto-sizer@1.0.26: - resolution: {integrity: sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==} - peerDependencies: - react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} - engines: {node: '>=0.10.0'} - - read-pkg@3.0.0: - resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} - engines: {node: '>=4'} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - recharts@3.8.1: - resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} - engines: {node: '>=18'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - redux-thunk@3.1.0: - resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} - peerDependencies: - redux: ^5.0.0 - - redux@5.0.1: - resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} - - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - - rehype-sanitize@6.0.0: - resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} - - remark-parse@11.0.0: - resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - - remark-rehype@11.1.2: - resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - reselect@5.1.1: - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - - rollup@4.60.2: - resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - - safe-array-concat@1.1.4: - resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} - engines: {node: '>=0.4'} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - sass@1.92.1: - resolution: {integrity: sha512-ffmsdbwqb3XeyR8jJR6KelIXARM9bFQe8A6Q3W4Klmwy5Ckd5gz7jgUNHo4UOqutU5Sk1DtKLbpDP0nLCg1xqQ==} - engines: {node: '>=14.0.0'} - hasBin: true - - sax@1.6.0: - resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} - engines: {node: '>=11.0.0'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@5.7.2: - resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} - hasBin: true - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - shebang-command@1.2.0: - resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} - engines: {node: '>=0.10.0'} - - shebang-regex@1.0.0: - resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} - engines: {node: '>=0.10.0'} - - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} - engines: {node: '>= 0.4'} - - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - snake-case@3.0.4: - resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map@0.5.7: - resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} - engines: {node: '>=0.10.0'} - - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-license-ids@3.0.23: - resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} - - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string.prototype.padend@3.1.6: - resolution: {integrity: sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==} - engines: {node: '>= 0.4'} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - style-to-js@1.1.21: - resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} - - style-to-object@1.0.14: - resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - - stylis@4.2.0: - resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} - - supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - svg-parser@2.0.4: - resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} - - svgo@3.3.3: - resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} - engines: {node: '>=14.0.0'} - hasBin: true - - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - - trough@2.2.0: - resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - typedoc@0.28.19: - resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==} - engines: {node: '>= 18', pnpm: '>= 10'} - hasBin: true - peerDependencies: - typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x - - typesafe-i18n@5.27.1: - resolution: {integrity: sha512-749uWo2ZXETT//kWjVYPm8QPYR8xLh8G0wLfoAyCAtAmysX67uCaAyLjAjAWojL6fuJpE5B6yIjwvO9orXzUPg==} - hasBin: true - peerDependencies: - typescript: '>=3.5.1' - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - uc.micro@2.1.0: - resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - unified@11.0.5: - resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - - unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - - unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - - unist-util-visit@5.1.0: - resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - use-breakpoint@4.0.10: - resolution: {integrity: sha512-rnUpZwCQCTtexbpM8S5aiJrfIx6NTvt0WwATiH4hCBN6gQNgkYPFoFt6g/3pAuyqU9D9tLKwXfsVqEWMBnwo6A==} - deprecated: This package is no longer maintained and will not be updated on registry.npmjs.com. You can copy the source directly into your own code to keep using it. - peerDependencies: - react: '>=18' - react-dom: '>=18' - - use-deep-compare-effect@1.8.1: - resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==} - engines: {node: '>=10', npm: '>=6'} - peerDependencies: - react: '>=16.13' - - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - hasBin: true - - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - - vfile-message@4.0.3: - resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - - vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - - victory-vendor@37.3.6: - resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - - vite@7.3.2: - resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.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 - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yaml@1.10.3: - resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} - engines: {node: '>= 6'} - - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} - engines: {node: '>= 14.6'} - hasBin: true - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - - zustand@5.0.12: - resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true - - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - -snapshots: - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/runtime@7.29.2': {} - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@biomejs/biome@2.4.13': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.13 - '@biomejs/cli-darwin-x64': 2.4.13 - '@biomejs/cli-linux-arm64': 2.4.13 - '@biomejs/cli-linux-arm64-musl': 2.4.13 - '@biomejs/cli-linux-x64': 2.4.13 - '@biomejs/cli-linux-x64-musl': 2.4.13 - '@biomejs/cli-win32-arm64': 2.4.13 - '@biomejs/cli-win32-x64': 2.4.13 - - '@biomejs/cli-darwin-arm64@2.4.13': - optional: true - - '@biomejs/cli-darwin-x64@2.4.13': - optional: true - - '@biomejs/cli-linux-arm64-musl@2.4.13': - optional: true - - '@biomejs/cli-linux-arm64@2.4.13': - optional: true - - '@biomejs/cli-linux-x64-musl@2.4.13': - optional: true - - '@biomejs/cli-linux-x64@2.4.13': - optional: true - - '@biomejs/cli-win32-arm64@2.4.13': - optional: true - - '@biomejs/cli-win32-x64@2.4.13': - optional: true - - '@emotion/babel-plugin@11.13.5': - dependencies: - '@babel/helper-module-imports': 7.28.6 - '@babel/runtime': 7.29.2 - '@emotion/hash': 0.9.2 - '@emotion/memoize': 0.9.0 - '@emotion/serialize': 1.3.3 - babel-plugin-macros: 3.1.0 - convert-source-map: 1.9.0 - escape-string-regexp: 4.0.0 - find-root: 1.1.0 - source-map: 0.5.7 - stylis: 4.2.0 - transitivePeerDependencies: - - supports-color - - '@emotion/cache@11.14.0': - dependencies: - '@emotion/memoize': 0.9.0 - '@emotion/sheet': 1.4.0 - '@emotion/utils': 1.4.2 - '@emotion/weak-memoize': 0.4.0 - stylis: 4.2.0 - - '@emotion/hash@0.9.2': {} - - '@emotion/is-prop-valid@1.4.0': - dependencies: - '@emotion/memoize': 0.9.0 - - '@emotion/memoize@0.9.0': {} - - '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5)': - dependencies: - '@babel/runtime': 7.29.2 - '@emotion/babel-plugin': 11.13.5 - '@emotion/cache': 11.14.0 - '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.5) - '@emotion/utils': 1.4.2 - '@emotion/weak-memoize': 0.4.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.5 - optionalDependencies: - '@types/react': 19.2.14 - transitivePeerDependencies: - - supports-color - - '@emotion/serialize@1.3.3': - dependencies: - '@emotion/hash': 0.9.2 - '@emotion/memoize': 0.9.0 - '@emotion/unitless': 0.10.0 - '@emotion/utils': 1.4.2 - csstype: 3.2.3 - - '@emotion/sheet@1.4.0': {} - - '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': - dependencies: - '@babel/runtime': 7.29.2 - '@emotion/babel-plugin': 11.13.5 - '@emotion/is-prop-valid': 1.4.0 - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) - '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.5) - '@emotion/utils': 1.4.2 - react: 19.2.5 - optionalDependencies: - '@types/react': 19.2.14 - transitivePeerDependencies: - - supports-color - - '@emotion/unitless@0.10.0': {} - - '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.5)': - dependencies: - react: 19.2.5 - - '@emotion/utils@1.4.2': {} - - '@emotion/weak-memoize@0.4.0': {} - - '@esbuild/aix-ppc64@0.27.7': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - - '@esbuild/linux-mips64el@0.27.7': - optional: true - - '@esbuild/linux-ppc64@0.27.7': - optional: true - - '@esbuild/linux-riscv64@0.27.7': - optional: true - - '@esbuild/linux-s390x@0.27.7': - optional: true - - '@esbuild/linux-x64@0.27.7': - optional: true - - '@esbuild/netbsd-arm64@0.27.7': - optional: true - - '@esbuild/netbsd-x64@0.27.7': - optional: true - - '@esbuild/openbsd-arm64@0.27.7': - optional: true - - '@esbuild/openbsd-x64@0.27.7': - optional: true - - '@esbuild/openharmony-arm64@0.27.7': - optional: true - - '@esbuild/sunos-x64@0.27.7': - optional: true - - '@esbuild/win32-arm64@0.27.7': - optional: true - - '@esbuild/win32-ia32@0.27.7': - optional: true - - '@esbuild/win32-x64@0.27.7': - optional: true - - '@floating-ui/core@1.7.5': - dependencies: - '@floating-ui/utils': 0.2.11 - - '@floating-ui/dom@1.7.6': - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 - - '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@floating-ui/dom': 1.7.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - '@floating-ui/react@0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@floating-ui/utils': 0.2.11 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - tabbable: 6.4.0 - - '@floating-ui/utils@0.2.11': {} - - '@gerrit0/mini-shiki@3.23.0': - dependencies: - '@shikijs/engine-oniguruma': 3.23.0 - '@shikijs/langs': 3.23.0 - '@shikijs/themes': 3.23.0 - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - - '@hookform/devtools@4.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) - '@types/lodash': 4.17.24 - little-state-machine: 4.8.1(react@19.2.5) - lodash: 4.18.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-simple-animate: 3.5.3(react-dom@19.2.5(react@19.2.5)) - use-deep-compare-effect: 1.8.1(react@19.2.5) - uuid: 8.3.2 - transitivePeerDependencies: - - '@types/react' - - supports-color - - '@hookform/resolvers@3.10.0(react-hook-form@7.74.0(react@19.2.5))': - dependencies: - react-hook-form: 7.74.0(react@19.2.5) - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@parcel/watcher-android-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-x64@2.5.6': - optional: true - - '@parcel/watcher-freebsd-x64@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-x64-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-x64-musl@2.5.6': - optional: true - - '@parcel/watcher-win32-arm64@2.5.6': - optional: true - - '@parcel/watcher-win32-ia32@2.5.6': - optional: true - - '@parcel/watcher-win32-x64@2.5.6': - optional: true - - '@parcel/watcher@2.5.6': - dependencies: - detect-libc: 2.1.2 - is-glob: 4.0.3 - node-addon-api: 7.1.1 - picomatch: 4.0.4 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.6 - '@parcel/watcher-darwin-arm64': 2.5.6 - '@parcel/watcher-darwin-x64': 2.5.6 - '@parcel/watcher-freebsd-x64': 2.5.6 - '@parcel/watcher-linux-arm-glibc': 2.5.6 - '@parcel/watcher-linux-arm-musl': 2.5.6 - '@parcel/watcher-linux-arm64-glibc': 2.5.6 - '@parcel/watcher-linux-arm64-musl': 2.5.6 - '@parcel/watcher-linux-x64-glibc': 2.5.6 - '@parcel/watcher-linux-x64-musl': 2.5.6 - '@parcel/watcher-win32-arm64': 2.5.6 - '@parcel/watcher-win32-ia32': 2.5.6 - '@parcel/watcher-win32-x64': 2.5.6 - optional: true - - '@react-hook/latest@1.0.3(react@19.2.5)': - dependencies: - react: 19.2.5 - - '@react-hook/passive-layout-effect@1.2.1(react@19.2.5)': - dependencies: - react: 19.2.5 - - '@react-hook/resize-observer@2.0.2(react@19.2.5)': - dependencies: - '@react-hook/latest': 1.0.3(react@19.2.5) - '@react-hook/passive-layout-effect': 1.2.1(react@19.2.5) - react: 19.2.5 - - '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@standard-schema/utils': 0.3.0 - immer: 11.1.4 - redux: 5.0.1 - redux-thunk: 3.1.0(redux@5.0.1) - reselect: 5.1.1 - optionalDependencies: - react: 19.2.5 - react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) - - '@remix-run/router@1.23.2': {} - - '@rolldown/pluginutils@1.0.0-rc.3': {} - - '@rolldown/pluginutils@1.0.0-rc.7': {} - - '@rollup/rollup-android-arm-eabi@4.60.2': - optional: true - - '@rollup/rollup-android-arm64@4.60.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.2': - optional: true - - '@rollup/rollup-darwin-x64@4.60.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.2': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.2': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.2': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.2': - optional: true - - '@shikijs/engine-oniguruma@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/langs@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - - '@shikijs/themes@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - - '@shikijs/types@3.23.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/vscode-textmate@10.0.2': {} - - '@stablelib/base64@2.0.1': {} - - '@stablelib/binary@2.0.1': - dependencies: - '@stablelib/int': 2.0.1 - - '@stablelib/bytes@2.0.1': {} - - '@stablelib/int@2.0.1': {} - - '@stablelib/keyagreement@2.0.1': - dependencies: - '@stablelib/bytes': 2.0.1 - - '@stablelib/random@2.0.1': - dependencies: - '@stablelib/binary': 2.0.1 - '@stablelib/wipe': 2.0.1 - - '@stablelib/wipe@2.0.1': {} - - '@stablelib/x25519@2.0.1': - dependencies: - '@stablelib/keyagreement': 2.0.1 - '@stablelib/random': 2.0.1 - '@stablelib/wipe': 2.0.1 - - '@standard-schema/spec@1.1.0': {} - - '@standard-schema/utils@0.3.0': {} - - '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - - '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - - '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - - '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - - '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - - '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - - '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - - '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - - '@svgr/babel-preset@8.1.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.29.0) - '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.29.0) - '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.29.0) - '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.29.0) - '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.29.0) - '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.29.0) - '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.29.0) - '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.29.0) - - '@svgr/cli@8.1.0(typescript@5.9.3)': - dependencies: - '@svgr/core': 8.1.0(typescript@5.9.3) - '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - '@svgr/plugin-prettier': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3) - camelcase: 6.3.0 - chalk: 4.1.2 - commander: 9.5.0 - dashify: 2.0.0 - glob: 8.1.0 - snake-case: 3.0.4 - transitivePeerDependencies: - - supports-color - - typescript - - '@svgr/core@8.1.0(typescript@5.9.3)': - dependencies: - '@babel/core': 7.29.0 - '@svgr/babel-preset': 8.1.0(@babel/core@7.29.0) - camelcase: 6.3.0 - cosmiconfig: 8.3.6(typescript@5.9.3) - snake-case: 3.0.4 - transitivePeerDependencies: - - supports-color - - typescript - - '@svgr/hast-util-to-babel-ast@8.0.0': - dependencies: - '@babel/types': 7.29.0 - entities: 4.5.0 - - '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': - dependencies: - '@babel/core': 7.29.0 - '@svgr/babel-preset': 8.1.0(@babel/core@7.29.0) - '@svgr/core': 8.1.0(typescript@5.9.3) - '@svgr/hast-util-to-babel-ast': 8.0.0 - svg-parser: 2.0.4 - transitivePeerDependencies: - - supports-color - - '@svgr/plugin-prettier@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': - dependencies: - '@svgr/core': 8.1.0(typescript@5.9.3) - deepmerge: 4.3.1 - prettier: 2.8.8 - - '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3)': - dependencies: - '@svgr/core': 8.1.0(typescript@5.9.3) - cosmiconfig: 8.3.6(typescript@5.9.3) - deepmerge: 4.3.1 - svgo: 3.3.3 - transitivePeerDependencies: - - typescript - - '@swc/core-darwin-arm64@1.15.30': - optional: true - - '@swc/core-darwin-x64@1.15.30': - optional: true - - '@swc/core-linux-arm-gnueabihf@1.15.30': - optional: true - - '@swc/core-linux-arm64-gnu@1.15.30': - optional: true - - '@swc/core-linux-arm64-musl@1.15.30': - optional: true - - '@swc/core-linux-ppc64-gnu@1.15.30': - optional: true - - '@swc/core-linux-s390x-gnu@1.15.30': - optional: true - - '@swc/core-linux-x64-gnu@1.15.30': - optional: true - - '@swc/core-linux-x64-musl@1.15.30': - optional: true - - '@swc/core-win32-arm64-msvc@1.15.30': - optional: true - - '@swc/core-win32-ia32-msvc@1.15.30': - optional: true - - '@swc/core-win32-x64-msvc@1.15.30': - optional: true - - '@swc/core@1.15.30': - dependencies: - '@swc/counter': 0.1.3 - '@swc/types': 0.1.26 - optionalDependencies: - '@swc/core-darwin-arm64': 1.15.30 - '@swc/core-darwin-x64': 1.15.30 - '@swc/core-linux-arm-gnueabihf': 1.15.30 - '@swc/core-linux-arm64-gnu': 1.15.30 - '@swc/core-linux-arm64-musl': 1.15.30 - '@swc/core-linux-ppc64-gnu': 1.15.30 - '@swc/core-linux-s390x-gnu': 1.15.30 - '@swc/core-linux-x64-gnu': 1.15.30 - '@swc/core-linux-x64-musl': 1.15.30 - '@swc/core-win32-arm64-msvc': 1.15.30 - '@swc/core-win32-ia32-msvc': 1.15.30 - '@swc/core-win32-x64-msvc': 1.15.30 - - '@swc/counter@0.1.3': {} - - '@swc/types@0.1.26': - dependencies: - '@swc/counter': 0.1.3 - - '@tanstack/query-core@5.100.5': {} - - '@tanstack/query-devtools@5.100.5': {} - - '@tanstack/react-query-devtools@5.100.5(@tanstack/react-query@5.100.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@tanstack/query-devtools': 5.100.5 - '@tanstack/react-query': 5.100.5(react@19.2.5) - react: 19.2.5 - - '@tanstack/react-query@5.100.5(react@19.2.5)': - dependencies: - '@tanstack/query-core': 5.100.5 - react: 19.2.5 - - '@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@tanstack/virtual-core': 3.14.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - '@tanstack/virtual-core@3.14.0': {} - - '@tauri-apps/api@2.10.1': {} - - '@tauri-apps/cli-darwin-arm64@2.10.1': - optional: true - - '@tauri-apps/cli-darwin-x64@2.10.1': - optional: true - - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': - optional: true - - '@tauri-apps/cli-linux-arm64-gnu@2.10.1': - optional: true - - '@tauri-apps/cli-linux-arm64-musl@2.10.1': - optional: true - - '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': - optional: true - - '@tauri-apps/cli-linux-x64-gnu@2.10.1': - optional: true - - '@tauri-apps/cli-linux-x64-musl@2.10.1': - optional: true - - '@tauri-apps/cli-win32-arm64-msvc@2.10.1': - optional: true - - '@tauri-apps/cli-win32-ia32-msvc@2.10.1': - optional: true - - '@tauri-apps/cli-win32-x64-msvc@2.10.1': - optional: true - - '@tauri-apps/cli@2.10.1': - optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.10.1 - '@tauri-apps/cli-darwin-x64': 2.10.1 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1 - '@tauri-apps/cli-linux-arm64-gnu': 2.10.1 - '@tauri-apps/cli-linux-arm64-musl': 2.10.1 - '@tauri-apps/cli-linux-riscv64-gnu': 2.10.1 - '@tauri-apps/cli-linux-x64-gnu': 2.10.1 - '@tauri-apps/cli-linux-x64-musl': 2.10.1 - '@tauri-apps/cli-win32-arm64-msvc': 2.10.1 - '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 - '@tauri-apps/cli-win32-x64-msvc': 2.10.1 - - '@tauri-apps/plugin-clipboard-manager@2.3.2': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-deep-link@2.4.8': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-dialog@2.7.0': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-fs@2.5.0': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-http@2.5.8': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-log@2.8.0': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-notification@2.3.3': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-opener@2.5.3': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-os@2.3.2': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-process@2.3.1': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-window-state@2.4.1': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/byte-size@8.1.2': {} - - '@types/d3-array@3.2.2': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.1.1': {} - - '@types/d3-scale@4.0.9': - dependencies: - '@types/d3-time': 3.0.4 - - '@types/d3-shape@3.1.8': - dependencies: - '@types/d3-path': 3.1.1 - - '@types/d3-time@3.0.4': {} - - '@types/d3-timer@3.0.2': {} - - '@types/debug@4.1.13': - dependencies: - '@types/ms': 2.1.0 - - '@types/estree-jsx@1.0.5': - dependencies: - '@types/estree': 1.0.8 - - '@types/estree@1.0.8': {} - - '@types/file-saver@2.0.7': {} - - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/lodash-es@4.17.12': - dependencies: - '@types/lodash': 4.17.24 - - '@types/lodash@4.17.24': {} - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/ms@2.1.0': {} - - '@types/node@24.12.2': - dependencies: - undici-types: 7.16.0 - - '@types/parse-json@4.0.2': {} - - '@types/react-dom@19.2.3(@types/react@19.2.14)': - dependencies: - '@types/react': 19.2.14 - - '@types/react@19.2.14': - dependencies: - csstype: 3.2.3 - - '@types/unist@2.0.11': {} - - '@types/unist@3.0.3': {} - - '@types/use-sync-external-store@0.0.6': {} - - '@ungap/structured-clone@1.3.0': {} - - '@use-gesture/core@10.3.1': {} - - '@use-gesture/react@10.3.1(react@19.2.5)': - dependencies: - '@use-gesture/core': 10.3.1 - react: 19.2.5 - - '@vitejs/plugin-react-swc@4.3.0(vite@7.3.2(@types/node@24.12.2)(sass@1.92.1)(yaml@2.8.3))': - dependencies: - '@rolldown/pluginutils': 1.0.0-rc.7 - '@swc/core': 1.15.30 - vite: 7.3.2(@types/node@24.12.2)(sass@1.92.1)(yaml@2.8.3) - transitivePeerDependencies: - - '@swc/helpers' - - '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@24.12.2)(sass@1.92.1)(yaml@2.8.3))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.2(@types/node@24.12.2)(sass@1.92.1)(yaml@2.8.3) - transitivePeerDependencies: - - supports-color - - ansi-regex@5.0.1: {} - - ansi-styles@3.2.1: - dependencies: - color-convert: 1.9.3 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - - async-function@1.0.0: {} - - autoprefixer@10.5.0(postcss@8.5.12): - dependencies: - browserslist: 4.28.2 - caniuse-lite: 1.0.30001791 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.12 - postcss-value-parser: 4.2.0 - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - babel-plugin-macros@3.1.0: - dependencies: - '@babel/runtime': 7.29.2 - cosmiconfig: 7.1.0 - resolve: 1.22.12 - - bail@2.0.2: {} - - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.10.23: {} - - boolbase@1.0.0: {} - - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.1.0: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.23 - caniuse-lite: 1.0.30001791 - electron-to-chromium: 1.5.344 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - byte-size@9.0.1: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.9: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - camelcase@6.3.0: {} - - caniuse-lite@1.0.30001791: {} - - ccount@2.0.1: {} - - chalk@2.4.2: - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 5.5.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - character-entities@2.0.2: {} - - character-reference-invalid@2.0.1: {} - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - classnames@2.5.1: {} - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clsx@2.1.1: {} - - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.3: {} - - color-name@1.1.4: {} - - comma-separated-tokens@2.0.3: {} - - commander@7.2.0: {} - - commander@9.5.0: {} - - compare-versions@6.1.1: {} - - concat-map@0.0.1: {} - - convert-source-map@1.9.0: {} - - convert-source-map@2.0.0: {} - - cosmiconfig@7.1.0: - dependencies: - '@types/parse-json': 4.0.2 - import-fresh: 3.3.1 - parse-json: 5.2.0 - path-type: 4.0.0 - yaml: 1.10.3 - - cosmiconfig@8.3.6(typescript@5.9.3): - dependencies: - import-fresh: 3.3.1 - js-yaml: 4.1.1 - parse-json: 5.2.0 - path-type: 4.0.0 - optionalDependencies: - typescript: 5.9.3 - - cross-spawn@6.0.6: - dependencies: - nice-try: 1.0.5 - path-key: 2.0.1 - semver: 5.7.2 - shebang-command: 1.2.0 - which: 1.3.1 - - css-select@5.2.2: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 - - css-tree@2.2.1: - dependencies: - mdn-data: 2.0.28 - source-map-js: 1.2.1 - - css-tree@2.3.1: - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.1 - - css-what@6.2.2: {} - - csso@5.0.5: - dependencies: - css-tree: 2.2.1 - - csstype@3.2.3: {} - - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-color@3.1.0: {} - - d3-ease@3.0.1: {} - - d3-format@3.1.2: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@3.1.0: {} - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.2 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@3.0.1: {} - - dashify@2.0.0: {} - - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - dayjs@1.11.20: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js-light@2.5.1: {} - - decode-named-character-reference@1.3.0: - dependencies: - character-entities: 2.0.2 - - deepmerge-ts@7.1.5: {} - - deepmerge@4.3.1: {} - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - dequal@2.0.3: {} - - detect-browser@5.3.0: {} - - detect-libc@2.1.2: - optional: true - - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - - domelementtype@2.3.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - - dot-case@3.0.4: - dependencies: - no-case: 3.0.4 - tslib: 2.8.1 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - electron-to-chromium@1.5.344: {} - - emoji-regex@8.0.0: {} - - entities@4.5.0: {} - - entities@7.0.1: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - es-abstract@1.24.2: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.4 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - es-toolkit@1.46.0: {} - - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - - escalade@3.2.0: {} - - escape-string-regexp@1.0.5: {} - - escape-string-regexp@4.0.0: {} - - estree-util-is-identifier-name@3.0.0: {} - - eventemitter3@5.0.4: {} - - extend@3.0.2: {} - - fast-deep-equal@3.1.3: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-saver@2.0.5: {} - - find-root@1.1.0: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - - fraction.js@5.3.4: {} - - framer-motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - motion-dom: 12.38.0 - motion-utils: 12.36.0 - tslib: 2.8.1 - optionalDependencies: - '@emotion/is-prop-valid': 1.4.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.3 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - - generator-function@2.0.1: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - - get-text-width@1.0.3: {} - - glob@8.1.0: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 5.1.9 - once: 1.4.0 - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - has-bigints@1.1.0: {} - - has-flag@3.0.0: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.3: - dependencies: - function-bind: 1.1.2 - - hast-util-sanitize@5.0.2: - dependencies: - '@types/hast': 3.0.4 - '@ungap/structured-clone': 1.3.0 - unist-util-position: 5.0.0 - - hast-util-to-jsx-runtime@2.3.6: - dependencies: - '@types/estree': 1.0.8 - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - style-to-js: 1.1.21 - unist-util-position: 5.0.0 - vfile-message: 4.0.3 - transitivePeerDependencies: - - supports-color - - hast-util-whitespace@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 - - hosted-git-info@2.8.9: {} - - html-dom-parser@5.1.8: - dependencies: - domhandler: 5.0.3 - htmlparser2: 10.1.0 - - html-react-parser@5.2.17(@types/react@19.2.14)(react@19.2.5): - dependencies: - domhandler: 5.0.3 - html-dom-parser: 5.1.8 - react: 19.2.5 - react-property: 2.0.2 - style-to-js: 1.1.21 - optionalDependencies: - '@types/react': 19.2.14 - - html-url-attributes@3.0.1: {} - - htmlparser2@10.1.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 7.0.1 - - immer@10.2.0: {} - - immer@11.1.4: {} - - immutable@5.1.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - inline-style-parser@0.2.7: {} - - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.3 - side-channel: 1.1.0 - - internmap@2.0.3: {} - - is-alphabetical@2.0.1: {} - - is-alphanumerical@2.0.1: - dependencies: - is-alphabetical: 2.0.1 - is-decimal: 2.0.1 - - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-arrayish@0.2.1: {} - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-callable@1.2.7: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.3 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-decimal@2.0.1: {} - - is-extglob@2.1.1: - optional: true - - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-fullwidth-code-point@3.0.0: {} - - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - optional: true - - is-hexadecimal@2.0.1: {} - - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-plain-obj@4.1.0: {} - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - isarray@2.0.5: {} - - isexe@2.0.0: {} - - itertools@2.6.0: {} - - js-base64@3.7.8: {} - - js-tokens@4.0.0: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - jsesc@3.1.0: {} - - json-parse-better-errors@1.0.2: {} - - json-parse-even-better-errors@2.3.1: {} - - json5@2.2.3: {} - - lines-and-columns@1.2.4: {} - - linkify-it@5.0.0: - dependencies: - uc.micro: 2.1.0 - - little-state-machine@4.8.1(react@19.2.5): - dependencies: - react: 19.2.5 - - load-json-file@4.0.0: - dependencies: - graceful-fs: 4.2.11 - parse-json: 4.0.0 - pify: 3.0.0 - strip-bom: 3.0.0 - - lodash-es@4.18.1: {} - - lodash@4.18.1: {} - - longest-streak@3.1.0: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - lower-case@2.0.2: - dependencies: - tslib: 2.8.1 - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lunr@2.3.9: {} - - markdown-it@14.1.1: - dependencies: - argparse: 2.0.1 - entities: 4.5.0 - linkify-it: 5.0.0 - mdurl: 2.0.0 - punycode.js: 2.3.1 - uc.micro: 2.1.0 - - math-intrinsics@1.1.0: {} - - mdast-util-from-markdown@2.0.3: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.2 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-expression@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-jsx@3.2.0: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - parse-entities: 4.0.2 - stringify-entities: 4.0.4 - unist-util-stringify-position: 4.0.0 - vfile-message: 4.0.3 - transitivePeerDependencies: - - supports-color - - mdast-util-mdxjs-esm@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-phrasing@4.1.0: - dependencies: - '@types/mdast': 4.0.4 - unist-util-is: 6.0.1 - - mdast-util-to-hast@13.2.1: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - - mdast-util-to-markdown@2.1.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.1 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.1.0 - zwitch: 2.0.4 - - mdast-util-to-string@4.0.0: - dependencies: - '@types/mdast': 4.0.4 - - mdn-data@2.0.28: {} - - mdn-data@2.0.30: {} - - mdurl@2.0.0: {} - - memorystream@0.3.1: {} - - merge-refs@2.0.0(@types/react@19.2.14): - optionalDependencies: - '@types/react': 19.2.14 - - micromark-core-commonmark@2.0.3: - dependencies: - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-factory-destination: 2.0.1 - micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.1 - micromark-factory-whitespace: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-destination@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-label@2.0.1: - dependencies: - devlop: 1.1.0 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 - - micromark-factory-title@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-whitespace@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-chunked@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-classify-character@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-combine-extensions@2.0.1: - dependencies: - micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-decode-numeric-character-reference@2.0.2: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-decode-string@2.0.1: - dependencies: - decode-named-character-reference: 1.3.0 - micromark-util-character: 2.1.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-symbol: 2.0.1 - - micromark-util-encode@2.0.1: {} - - micromark-util-html-tag-name@2.0.1: {} - - micromark-util-normalize-identifier@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-resolve-all@2.0.1: - dependencies: - micromark-util-types: 2.0.2 - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-subtokenize@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.2: {} - - micromark@4.0.2: - dependencies: - '@types/debug': 4.1.13 - debug: 4.4.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - transitivePeerDependencies: - - supports-color - - millify@6.1.0: - dependencies: - yargs: 17.7.2 - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.5 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - - minimatch@5.1.9: - dependencies: - brace-expansion: 2.1.0 - - motion-dom@12.38.0: - dependencies: - motion-utils: 12.36.0 - - motion-utils@12.36.0: {} - - motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - framer-motion: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - tslib: 2.8.1 - optionalDependencies: - '@emotion/is-prop-valid': 1.4.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - nice-try@1.0.5: {} - - no-case@3.0.4: - dependencies: - lower-case: 2.0.2 - tslib: 2.8.1 - - node-addon-api@7.1.1: - optional: true - - node-releases@2.0.38: {} - - normalize-package-data@2.5.0: - dependencies: - hosted-git-info: 2.8.9 - resolve: 1.22.12 - semver: 5.7.2 - validate-npm-package-license: 3.0.4 - - npm-run-all@4.1.5: - dependencies: - ansi-styles: 3.2.1 - chalk: 2.4.2 - cross-spawn: 6.0.6 - memorystream: 0.3.1 - minimatch: 3.1.5 - pidtree: 0.3.1 - read-pkg: 3.0.0 - shell-quote: 1.8.3 - string.prototype.padend: 3.1.6 - - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - - p-timeout@6.1.4: {} - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse-entities@4.0.2: - dependencies: - '@types/unist': 2.0.11 - character-entities-legacy: 3.0.0 - character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.3.0 - is-alphanumerical: 2.0.1 - is-decimal: 2.0.1 - is-hexadecimal: 2.0.1 - - parse-json@4.0.0: - dependencies: - error-ex: 1.3.4 - json-parse-better-errors: 1.0.2 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.29.0 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - path-key@2.0.1: {} - - path-parse@1.0.7: {} - - path-type@3.0.0: - dependencies: - pify: 3.0.0 - - path-type@4.0.0: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - pidtree@0.3.1: {} - - pify@3.0.0: {} - - possible-typed-array-names@1.1.0: {} - - postcss-value-parser@4.2.0: {} - - postcss@8.5.12: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prettier@2.8.8: {} - - prettier@3.8.3: {} - - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - - property-information@7.1.0: {} - - punycode.js@2.3.1: {} - - qr.js@0.0.0: {} - - radash@12.1.1: {} - - react-auth-code-input@3.2.1(react@19.2.5): - dependencies: - react: 19.2.5 - - react-click-away-listener@2.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - react-dom@19.2.5(react@19.2.5): - dependencies: - react: 19.2.5 - scheduler: 0.27.0 - - react-hook-form@7.74.0(react@19.2.5): - dependencies: - react: 19.2.5 - - react-hotkeys-hook@5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - react-is@16.13.1: {} - - react-loading-skeleton@3.5.0(react@19.2.5): - dependencies: - react: 19.2.5 - - react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/react': 19.2.14 - devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6 - html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.1 - react: 19.2.5 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - unified: 11.0.5 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - - react-property@2.0.2: {} - - react-qr-code@2.0.18(react@19.2.5): - dependencies: - prop-types: 15.8.1 - qr.js: 0.0.0 - react: 19.2.5 - - react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1): - dependencies: - '@types/use-sync-external-store': 0.0.6 - react: 19.2.5 - use-sync-external-store: 1.6.0(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - redux: 5.0.1 - - react-refresh@0.18.0: {} - - react-router-dom@6.30.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - '@remix-run/router': 1.23.2 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-router: 6.30.3(react@19.2.5) - - react-router@6.30.3(react@19.2.5): - dependencies: - '@remix-run/router': 1.23.2 - react: 19.2.5 - - react-simple-animate@3.5.3(react-dom@19.2.5(react@19.2.5)): - dependencies: - react-dom: 19.2.5(react@19.2.5) - - react-use-websocket@4.13.0: {} - - react-virtualized-auto-sizer@1.0.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - react@19.2.5: {} - - read-pkg@3.0.0: - dependencies: - load-json-file: 4.0.0 - normalize-package-data: 2.5.0 - path-type: 3.0.0 - - readdirp@4.1.2: {} - - recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1): - dependencies: - '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5) - clsx: 2.1.1 - decimal.js-light: 2.5.1 - es-toolkit: 1.46.0 - eventemitter3: 5.0.4 - immer: 10.2.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-is: 16.13.1 - react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) - reselect: 5.1.1 - tiny-invariant: 1.3.3 - use-sync-external-store: 1.6.0(react@19.2.5) - victory-vendor: 37.3.6 - transitivePeerDependencies: - - '@types/react' - - redux - - redux-thunk@3.1.0(redux@5.0.1): - dependencies: - redux: 5.0.1 - - redux@5.0.1: {} - - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - - rehype-sanitize@6.0.0: - dependencies: - '@types/hast': 3.0.4 - hast-util-sanitize: 5.0.2 - - remark-parse@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 - micromark-util-types: 2.0.2 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-rehype@11.1.2: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.1 - unified: 11.0.5 - vfile: 6.0.3 - - require-directory@2.1.1: {} - - reselect@5.1.1: {} - - resolve-from@4.0.0: {} - - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - rollup@4.60.2: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.2 - '@rollup/rollup-android-arm64': 4.60.2 - '@rollup/rollup-darwin-arm64': 4.60.2 - '@rollup/rollup-darwin-x64': 4.60.2 - '@rollup/rollup-freebsd-arm64': 4.60.2 - '@rollup/rollup-freebsd-x64': 4.60.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 - '@rollup/rollup-linux-arm-musleabihf': 4.60.2 - '@rollup/rollup-linux-arm64-gnu': 4.60.2 - '@rollup/rollup-linux-arm64-musl': 4.60.2 - '@rollup/rollup-linux-loong64-gnu': 4.60.2 - '@rollup/rollup-linux-loong64-musl': 4.60.2 - '@rollup/rollup-linux-ppc64-gnu': 4.60.2 - '@rollup/rollup-linux-ppc64-musl': 4.60.2 - '@rollup/rollup-linux-riscv64-gnu': 4.60.2 - '@rollup/rollup-linux-riscv64-musl': 4.60.2 - '@rollup/rollup-linux-s390x-gnu': 4.60.2 - '@rollup/rollup-linux-x64-gnu': 4.60.2 - '@rollup/rollup-linux-x64-musl': 4.60.2 - '@rollup/rollup-openbsd-x64': 4.60.2 - '@rollup/rollup-openharmony-arm64': 4.60.2 - '@rollup/rollup-win32-arm64-msvc': 4.60.2 - '@rollup/rollup-win32-ia32-msvc': 4.60.2 - '@rollup/rollup-win32-x64-gnu': 4.60.2 - '@rollup/rollup-win32-x64-msvc': 4.60.2 - fsevents: 2.3.3 - - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - - safe-array-concat@1.1.4: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - - sass@1.92.1: - dependencies: - chokidar: 4.0.3 - immutable: 5.1.5 - source-map-js: 1.2.1 - optionalDependencies: - '@parcel/watcher': 2.5.6 - - sax@1.6.0: {} - - scheduler@0.27.0: {} - - semver@5.7.2: {} - - semver@6.3.1: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - - shebang-command@1.2.0: - dependencies: - shebang-regex: 1.0.0 - - shebang-regex@1.0.0: {} - - shell-quote@1.8.3: {} - - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - snake-case@3.0.4: - dependencies: - dot-case: 3.0.4 - tslib: 2.8.1 - - source-map-js@1.2.1: {} - - source-map@0.5.7: {} - - space-separated-tokens@2.0.2: {} - - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.23 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.23 - - spdx-license-ids@3.0.23: {} - - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string.prototype.padend@3.1.6: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.1 - - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom@3.0.0: {} - - style-to-js@1.1.21: - dependencies: - style-to-object: 1.0.14 - - style-to-object@1.0.14: - dependencies: - inline-style-parser: 0.2.7 - - stylis@4.2.0: {} - - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - svg-parser@2.0.4: {} - - svgo@3.3.3: - dependencies: - commander: 7.2.0 - css-select: 5.2.2 - css-tree: 2.3.1 - css-what: 6.2.2 - csso: 5.0.5 - picocolors: 1.1.1 - sax: 1.6.0 - - tabbable@6.4.0: {} - - tiny-invariant@1.3.3: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - trim-lines@3.0.1: {} - - trough@2.2.0: {} - - tslib@2.8.1: {} - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - typedoc@0.28.19(typescript@5.9.3): - dependencies: - '@gerrit0/mini-shiki': 3.23.0 - lunr: 2.3.9 - markdown-it: 14.1.1 - minimatch: 10.2.5 - typescript: 5.9.3 - yaml: 2.8.3 - - typesafe-i18n@5.27.1(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - typescript@5.9.3: {} - - uc.micro@2.1.0: {} - - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - - undici-types@7.16.0: {} - - unified@11.0.5: - dependencies: - '@types/unist': 3.0.3 - bail: 2.0.2 - devlop: 1.1.0 - extend: 3.0.2 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 6.0.3 - - unist-util-is@6.0.1: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-visit@5.1.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - use-breakpoint@4.0.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - use-deep-compare-effect@1.8.1(react@19.2.5): - dependencies: - '@babel/runtime': 7.29.2 - dequal: 2.0.3 - react: 19.2.5 - - use-sync-external-store@1.6.0(react@19.2.5): - dependencies: - react: 19.2.5 - - uuid@8.3.2: {} - - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - - vfile-message@4.0.3: - dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 - - vfile@6.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.3 - - victory-vendor@37.3.6: - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-ease': 3.0.2 - '@types/d3-interpolate': 3.0.4 - '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.1.8 - '@types/d3-time': 3.0.4 - '@types/d3-timer': 3.0.2 - d3-array: 3.2.4 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-scale: 4.0.2 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-timer: 3.0.1 - - vite@7.3.2(@types/node@24.12.2)(sass@1.92.1)(yaml@2.8.3): - dependencies: - esbuild: 0.27.7 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.12 - rollup: 4.60.2 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 24.12.2 - fsevents: 2.3.3 - sass: 1.92.1 - yaml: 2.8.3 - - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - - which@1.3.1: - dependencies: - isexe: 2.0.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yaml@1.10.3: {} - - yaml@2.8.3: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - zod@3.25.76: {} - - zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): - optionalDependencies: - '@types/react': 19.2.14 - immer: 11.1.4 - react: 19.2.5 - use-sync-external-store: 1.6.0(react@19.2.5) - - zwitch@2.0.4: {} diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index a47ef4f95..000000000 --- a/postcss.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - plugins: { - autoprefixer: {}, - }, -}; diff --git a/resources-linux/defguard-client.spec b/resources-linux/defguard-client.spec index 55391de0d..479157073 100644 --- a/resources-linux/defguard-client.spec +++ b/resources-linux/defguard-client.spec @@ -14,21 +14,19 @@ Desktop client for managing WireGuard VPN connections %{__mkdir} -p %{buildroot}/%{_bindir} %{__mkdir} -p %{buildroot}/%{_sbindir} %{__mkdir} -p %{buildroot}/%{_prefix}/lib/systemd/system -%{__mkdir} -p %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons +%{__mkdir} -p %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray %{__mkdir} -p %{buildroot}/%{_datadir}/applications %{__mkdir} -p %{buildroot}/%{_datadir}/icons/hicolor/128x128/apps %{__mkdir} -p %{buildroot}/%{_datadir}/icons/hicolor/256x256@2/apps %{__mkdir} -p %{buildroot}/%{_datadir}/icons/hicolor/32x32/apps %{__install} -m 755 src-tauri/target/release/defguard-client %{buildroot}/%{_bindir}/ %{__install} -m 755 src-tauri/target/release/defguard-service %{buildroot}/%{_sbindir}/ -%{__install} -m 644 src-tauri/target/release/resources/icons/tray-32x32-black.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-black.png -%{__install} -m 644 src-tauri/target/release/resources/icons/tray-32x32-black-active.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-black-active.png -%{__install} -m 644 src-tauri/target/release/resources/icons/tray-32x32-color.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-color.png -%{__install} -m 644 src-tauri/target/release/resources/icons/tray-32x32-color-active.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-color-active.png -%{__install} -m 644 src-tauri/target/release/resources/icons/tray-32x32-gray.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-gray.png -%{__install} -m 644 src-tauri/target/release/resources/icons/tray-32x32-gray-active.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-gray-active.png -%{__install} -m 644 src-tauri/target/release/resources/icons/tray-32x32-white.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-white.png -%{__install} -m 644 src-tauri/target/release/resources/icons/tray-32x32-white-active.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-white-active.png +%{__install} -m 644 src-tauri/target/release/resources/icons/tray/blue.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray/blue.png +%{__install} -m 644 src-tauri/target/release/resources/icons/tray/blue-connected.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray/blue-connected.png +%{__install} -m 644 src-tauri/target/release/resources/icons/tray/dark.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray/dark.png +%{__install} -m 644 src-tauri/target/release/resources/icons/tray/dark-connected.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray/dark-connected.png +%{__install} -m 644 src-tauri/target/release/resources/icons/tray/white.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray/white.png +%{__install} -m 644 src-tauri/target/release/resources/icons/tray/white-connected.png %{buildroot}/%{_prefix}/lib/defguard-client/resources/icons/tray/white-connected.png %{__install} -m 644 resources-linux/defguard-service.service %{buildroot}/%{_prefix}/lib/systemd/system/ %{__install} -m 644 resources-linux/defguard-client.desktop %{buildroot}/%{_datadir}/applications/defguard-client.desktop %{__install} -m 644 src-tauri/icons/128x128.png %{buildroot}/%{_datadir}/icons/hicolor/128x128/apps/defguard-client.png @@ -57,14 +55,12 @@ systemctl daemon-reload %files %{_bindir}/defguard-client %{_sbindir}/defguard-service -%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-black.png -%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-black-active.png -%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-color.png -%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-color-active.png -%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-gray.png -%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-gray-active.png -%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-white.png -%{_prefix}/lib/defguard-client/resources/icons/tray-32x32-white-active.png +%{_prefix}/lib/defguard-client/resources/icons/tray/blue.png +%{_prefix}/lib/defguard-client/resources/icons/tray/blue-connected.png +%{_prefix}/lib/defguard-client/resources/icons/tray/dark.png +%{_prefix}/lib/defguard-client/resources/icons/tray/dark-connected.png +%{_prefix}/lib/defguard-client/resources/icons/tray/white.png +%{_prefix}/lib/defguard-client/resources/icons/tray/white-connected.png %{_prefix}/lib/systemd/system/defguard-service.service %{_datadir}/applications/defguard-client.desktop %{_datadir}/icons/hicolor/128x128/apps/defguard-client.png diff --git a/resources-linux/postinst b/resources-linux/postinst index 1dac4b2b9..b42221e18 100644 --- a/resources-linux/postinst +++ b/resources-linux/postinst @@ -76,8 +76,16 @@ case "$1" in # Enable service to start on boot systemctl enable "$SERVICE_NAME" - # Start the service now - systemctl start "$SERVICE_NAME" + # On a fresh install ($2 is empty) start the service; on an upgrade + # ($2 holds the previous version) restart it so the new daemon binary + # is loaded - otherwise an already-running old daemon keeps serving and + # lacks newly added RPCs (e.g. list_interfaces used by the CLI). + # try-restart leaves a deliberately-stopped service stopped. + if [ -z "$2" ]; then + systemctl start "$SERVICE_NAME" + else + systemctl try-restart "$SERVICE_NAME" + fi fi ;; diff --git a/src-tauri/.env b/src-tauri/.env new file mode 100644 index 000000000..b921a7124 --- /dev/null +++ b/src-tauri/.env @@ -0,0 +1,3 @@ +DATABASE_URL=sqlite:dev.db +DEFGUARD_CLIENT_LOG_LEVEL="debug" +DEFGUARD_CLIENT_DEV="1" \ No newline at end of file diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 78b6aae95..c093e9e49 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -2,3 +2,6 @@ # will have compiled files and executables /target/ defguard.db + +# Downloaded build artifacts +resources-macos/binaries/ diff --git a/src-tauri/.sqlx/query-0b161da55e36df9d0a10a52474a8cd49d4659d2d6faafdd2de0c985679d0703d.json b/src-tauri/.sqlx/query-0b161da55e36df9d0a10a52474a8cd49d4659d2d6faafdd2de0c985679d0703d.json deleted file mode 100644 index 0f1dbfc0f..000000000 --- a/src-tauri/.sqlx/query-0b161da55e36df9d0a10a52474a8cd49d4659d2d6faafdd2de0c985679d0703d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE instance SET name = $1, uuid = $2, url = $3, proxy_url = $4, username = $5, client_traffic_policy = $6, enterprise_enabled = $7, token = $8, openid_display_name = $9 WHERE id = $10;", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "0b161da55e36df9d0a10a52474a8cd49d4659d2d6faafdd2de0c985679d0703d" -} diff --git a/src-tauri/.sqlx/query-17a3b10441e63bde5b97c861747617eadddbc843e42545860afdd8ad6951b4ee.json b/src-tauri/.sqlx/query-17a3b10441e63bde5b97c861747617eadddbc843e42545860afdd8ad6951b4ee.json deleted file mode 100644 index 4c0ace380..000000000 --- a/src-tauri/.sqlx/query-17a3b10441e63bde5b97c861747617eadddbc843e42545860afdd8ad6951b4ee.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO tunnel_stats (tunnel_id, upload, download, last_handshake, collected_at, listen_port, persistent_keepalive_interval) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id \"id!\"", - "describe": { - "columns": [ - { - "name": "id!", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 7 - }, - "nullable": [ - true - ] - }, - "hash": "17a3b10441e63bde5b97c861747617eadddbc843e42545860afdd8ad6951b4ee" -} diff --git a/src-tauri/.sqlx/query-1c07ca7013959226ca9af064037bb64da07f7023f58ff8678d828a0ba50e2470.json b/src-tauri/.sqlx/query-1c07ca7013959226ca9af064037bb64da07f7023f58ff8678d828a0ba50e2470.json new file mode 100644 index 000000000..d5164870d --- /dev/null +++ b/src-tauri/.sqlx/query-1c07ca7013959226ca9af064037bb64da07f7023f58ff8678d828a0ba50e2470.json @@ -0,0 +1,104 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE instance_id = $1 AND service_location_mode <= $2 ORDER BY name ASC", + "describe": { + "columns": [ + { + "name": "id: _", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "instance_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "pubkey", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "endpoint", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "allowed_ips", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "dns", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "network_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "route_all_traffic", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "keepalive_interval", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "location_mfa_mode: LocationMfaMode", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "service_location_mode: ServiceLocationMode", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "mfa_method: _", + "ordinal": 13, + "type_info": "Integer" + }, + { + "name": "posture_check_required", + "ordinal": 14, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "1c07ca7013959226ca9af064037bb64da07f7023f58ff8678d828a0ba50e2470" +} diff --git a/src-tauri/.sqlx/query-1c996712f62a1005990733cd9eee7a94bdcf2ef01b559304aea1d642fab7ae22.json b/src-tauri/.sqlx/query-1c996712f62a1005990733cd9eee7a94bdcf2ef01b559304aea1d642fab7ae22.json deleted file mode 100644 index 0da21a4fc..000000000 --- a/src-tauri/.sqlx/query-1c996712f62a1005990733cd9eee7a94bdcf2ef01b559304aea1d642fab7ae22.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "DELETE FROM location WHERE id = $1;", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "1c996712f62a1005990733cd9eee7a94bdcf2ef01b559304aea1d642fab7ae22" -} diff --git a/src-tauri/.sqlx/query-2d0bcf92277e9a28d1b7732bd032cbbac0b5e5d916877eeb810df275488743b2.json b/src-tauri/.sqlx/query-2d0bcf92277e9a28d1b7732bd032cbbac0b5e5d916877eeb810df275488743b2.json new file mode 100644 index 000000000..ca3178afb --- /dev/null +++ b/src-tauri/.sqlx/query-2d0bcf92277e9a28d1b7732bd032cbbac0b5e5d916877eeb810df275488743b2.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id: _\", name, pubkey, prvkey, address, server_pubkey, preshared_key, allowed_ips, endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, post_up, pre_down, post_down FROM tunnel WHERE name = $1 ORDER BY name ASC", + "describe": { + "columns": [ + { + "name": "id: _", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "pubkey", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "prvkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "server_pubkey", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "preshared_key", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "allowed_ips", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "endpoint", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "dns", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "persistent_keep_alive", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "route_all_traffic", + "ordinal": 11, + "type_info": "Bool" + }, + { + "name": "pre_up", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "post_up", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "pre_down", + "ordinal": 14, + "type_info": "Text" + }, + { + "name": "post_down", + "ordinal": 15, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "2d0bcf92277e9a28d1b7732bd032cbbac0b5e5d916877eeb810df275488743b2" +} diff --git a/src-tauri/.sqlx/query-2d9b3c0595f2d385336d0a86cfdae1a4c327622977707117bf88b6a43e9e8f96.json b/src-tauri/.sqlx/query-2d9b3c0595f2d385336d0a86cfdae1a4c327622977707117bf88b6a43e9e8f96.json deleted file mode 100644 index 13579fc7c..000000000 --- a/src-tauri/.sqlx/query-2d9b3c0595f2d385336d0a86cfdae1a4c327622977707117bf88b6a43e9e8f96.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, openid_display_name FROM instance ORDER BY name ASC;", - "describe": { - "columns": [ - { - "name": "id: _", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "uuid", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "url", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "proxy_url", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "username", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "token?", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "client_traffic_policy", - "ordinal": 7, - "type_info": "Integer" - }, - { - "name": "enterprise_enabled", - "ordinal": 8, - "type_info": "Bool" - }, - { - "name": "openid_display_name", - "ordinal": 9, - "type_info": "Text" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - true, - false, - false, - true - ] - }, - "hash": "2d9b3c0595f2d385336d0a86cfdae1a4c327622977707117bf88b6a43e9e8f96" -} diff --git a/src-tauri/.sqlx/query-31e1b340bfbdf29dde642b657124a0705b638769ce6631c1b455cbf45e82bb14.json b/src-tauri/.sqlx/query-31e1b340bfbdf29dde642b657124a0705b638769ce6631c1b455cbf45e82bb14.json new file mode 100644 index 000000000..004d36d09 --- /dev/null +++ b/src-tauri/.sqlx/query-31e1b340bfbdf29dde642b657124a0705b638769ce6631c1b455cbf45e82bb14.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM location WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "31e1b340bfbdf29dde642b657124a0705b638769ce6631c1b455cbf45e82bb14" +} diff --git a/src-tauri/.sqlx/query-3a157b6bcdba07c456e924798f797cdbdd6290ef6fa0420ddc0682ab10e14727.json b/src-tauri/.sqlx/query-3a157b6bcdba07c456e924798f797cdbdd6290ef6fa0420ddc0682ab10e14727.json deleted file mode 100644 index 79b463d4e..000000000 --- a/src-tauri/.sqlx/query-3a157b6bcdba07c456e924798f797cdbdd6290ef6fa0420ddc0682ab10e14727.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token, client_traffic_policy, enterprise_enabled, openid_display_name FROM instance WHERE token IS NOT NULL ORDER BY name ASC;", - "describe": { - "columns": [ - { - "name": "id: _", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "uuid", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "url", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "proxy_url", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "username", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "token", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "client_traffic_policy", - "ordinal": 7, - "type_info": "Integer" - }, - { - "name": "enterprise_enabled", - "ordinal": 8, - "type_info": "Bool" - }, - { - "name": "openid_display_name", - "ordinal": 9, - "type_info": "Text" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - true, - false, - false, - true - ] - }, - "hash": "3a157b6bcdba07c456e924798f797cdbdd6290ef6fa0420ddc0682ab10e14727" -} diff --git a/src-tauri/.sqlx/query-3bedd8a0e3a8d4b76330ba0f81d82cf1590e6d15ba30360c41b0a5a3482df3df.json b/src-tauri/.sqlx/query-3bedd8a0e3a8d4b76330ba0f81d82cf1590e6d15ba30360c41b0a5a3482df3df.json new file mode 100644 index 000000000..9f7beee72 --- /dev/null +++ b/src-tauri/.sqlx/query-3bedd8a0e3a8d4b76330ba0f81d82cf1590e6d15ba30360c41b0a5a3482df3df.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS (SELECT 1 FROM location WHERE service_location_mode <= $1)", + "describe": { + "columns": [ + { + "name": "EXISTS (SELECT 1 FROM location WHERE service_location_mode <= $1)", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "3bedd8a0e3a8d4b76330ba0f81d82cf1590e6d15ba30360c41b0a5a3482df3df" +} diff --git a/src-tauri/.sqlx/query-498b544eb893fa8b1ecd10984601e9b4bc7bef591e480ede9964743091422baf.json b/src-tauri/.sqlx/query-498b544eb893fa8b1ecd10984601e9b4bc7bef591e480ede9964743091422baf.json new file mode 100644 index 000000000..f5ad6c655 --- /dev/null +++ b/src-tauri/.sqlx/query-498b544eb893fa8b1ecd10984601e9b4bc7bef591e480ede9964743091422baf.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id!\", tunnel_id, SUM(MAX(upload_diff, 0)) \"upload!: i64\", SUM(MAX(download_diff, 0)) \"download!: i64\", 0 \"upload_diff!: i64\", 0 \"download_diff!: i64\", last_handshake \"last_handshake!: i64\", strftime($1, collected_at) \"collected_at!: NaiveDateTime\", listen_port \"listen_port!: u32\", persistent_keepalive_interval \"persistent_keepalive_interval!: u16\" FROM tunnel_stats WHERE tunnel_id = $2 AND collected_at >= datetime(strftime($1, $3)) GROUP BY strftime($1, collected_at) ORDER BY collected_at", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tunnel_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "upload!: i64", + "ordinal": 2, + "type_info": "Null" + }, + { + "name": "download!: i64", + "ordinal": 3, + "type_info": "Null" + }, + { + "name": "upload_diff!: i64", + "ordinal": 4, + "type_info": "Null" + }, + { + "name": "download_diff!: i64", + "ordinal": 5, + "type_info": "Null" + }, + { + "name": "last_handshake!: i64", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "collected_at!: NaiveDateTime", + "ordinal": 7, + "type_info": "Null" + }, + { + "name": "listen_port!: u32", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "persistent_keepalive_interval!: u16", + "ordinal": 9, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + true, + false, + null, + null, + null, + null, + false, + null, + false, + false + ] + }, + "hash": "498b544eb893fa8b1ecd10984601e9b4bc7bef591e480ede9964743091422baf" +} diff --git a/src-tauri/.sqlx/query-56b89ac487ed011e33080a9058700f706a9860e6a6f3070305f8ea85b5ff8efb.json b/src-tauri/.sqlx/query-56b89ac487ed011e33080a9058700f706a9860e6a6f3070305f8ea85b5ff8efb.json new file mode 100644 index 000000000..c40c1f567 --- /dev/null +++ b/src-tauri/.sqlx/query-56b89ac487ed011e33080a9058700f706a9860e6a6f3070305f8ea85b5ff8efb.json @@ -0,0 +1,104 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "instance_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "pubkey", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "endpoint", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "allowed_ips", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "dns", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "network_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "route_all_traffic", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "keepalive_interval", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "location_mfa_mode: LocationMfaMode", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "service_location_mode: ServiceLocationMode", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "mfa_method: _", + "ordinal": 13, + "type_info": "Integer" + }, + { + "name": "posture_check_required", + "ordinal": 14, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "56b89ac487ed011e33080a9058700f706a9860e6a6f3070305f8ea85b5ff8efb" +} diff --git a/src-tauri/.sqlx/query-5aafcc34860d0cb6412a0ae1d94f7372ff389f8afc11d21baddc9aabe07692be.json b/src-tauri/.sqlx/query-5aafcc34860d0cb6412a0ae1d94f7372ff389f8afc11d21baddc9aabe07692be.json new file mode 100644 index 000000000..50a1a21af --- /dev/null +++ b/src-tauri/.sqlx/query-5aafcc34860d0cb6412a0ae1d94f7372ff389f8afc11d21baddc9aabe07692be.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "WITH prev_download AS (\n SELECT download\n FROM location_stats\n WHERE location_id = $1\n ORDER BY collected_at DESC\n LIMIT 1 OFFSET 1\n )\n SELECT ls.id \"id!: i64\",\n ls.location_id,\n ls.upload \"upload!: i64\",\n ls.download \"download!: i64\",\n ls.upload_diff,\n ls.download_diff,\n ls.last_handshake,\n ls.collected_at \"collected_at!: NaiveDateTime\",\n ls.listen_port \"listen_port!: u32\",\n ls.persistent_keepalive_interval \"persistent_keepalive_interval?: u16\"\n FROM location_stats ls\n LEFT JOIN prev_download pd\n WHERE ls.location_id = $1\n AND (pd.download IS NULL OR ls.download != pd.download)\n ORDER BY ls.collected_at DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "location_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "upload!: i64", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "download!: i64", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "upload_diff", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "download_diff", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "last_handshake", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "collected_at!: NaiveDateTime", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "listen_port!: u32", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "persistent_keepalive_interval?: u16", + "ordinal": 9, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "5aafcc34860d0cb6412a0ae1d94f7372ff389f8afc11d21baddc9aabe07692be" +} diff --git a/src-tauri/.sqlx/query-76adc350233d50db611cc827bd0f7537a9ed4111c1401021fe3813cb3cafc6ee.json b/src-tauri/.sqlx/query-76adc350233d50db611cc827bd0f7537a9ed4111c1401021fe3813cb3cafc6ee.json new file mode 100644 index 000000000..730494fa0 --- /dev/null +++ b/src-tauri/.sqlx/query-76adc350233d50db611cc827bd0f7537a9ed4111c1401021fe3813cb3cafc6ee.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name FROM instance ORDER BY name ASC;", + "describe": { + "columns": [ + { + "name": "id: _", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "uuid", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "proxy_url", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "username", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "token?", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "client_traffic_policy", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "enterprise_enabled", + "ordinal": 8, + "type_info": "Bool" + }, + { + "name": "disable_tunnels", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "openid_display_name", + "ordinal": 10, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "76adc350233d50db611cc827bd0f7537a9ed4111c1401021fe3813cb3cafc6ee" +} diff --git a/src-tauri/.sqlx/query-76c5c9b75df39afca9cd07530ab0569d3d6f9d8924458c8b357dd400966f4175.json b/src-tauri/.sqlx/query-76c5c9b75df39afca9cd07530ab0569d3d6f9d8924458c8b357dd400966f4175.json deleted file mode 100644 index 9c6f65ed8..000000000 --- a/src-tauri/.sqlx/query-76c5c9b75df39afca9cd07530ab0569d3d6f9d8924458c8b357dd400966f4175.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\" FROM location WHERE id = $1", - "describe": { - "columns": [ - { - "name": "id: _", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "instance_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "address", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "pubkey", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "endpoint", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "allowed_ips", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "dns", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "network_id", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "route_all_traffic", - "ordinal": 9, - "type_info": "Bool" - }, - { - "name": "keepalive_interval", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "location_mfa_mode: LocationMfaMode", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "service_location_mode: ServiceLocationMode", - "ordinal": 12, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false - ] - }, - "hash": "76c5c9b75df39afca9cd07530ab0569d3d6f9d8924458c8b357dd400966f4175" -} diff --git a/src-tauri/.sqlx/query-7a39250f44f7415c09362f62efcc0b2eded31017ac7c2a531a4917db5100bfda.json b/src-tauri/.sqlx/query-7a39250f44f7415c09362f62efcc0b2eded31017ac7c2a531a4917db5100bfda.json new file mode 100644 index 000000000..e0f1523f2 --- /dev/null +++ b/src-tauri/.sqlx/query-7a39250f44f7415c09362f62efcc0b2eded31017ac7c2a531a4917db5100bfda.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO location_stats (location_id, upload, download, upload_diff, download_diff, last_handshake, collected_at, listen_port, persistent_keepalive_interval) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id \"id!\"", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 9 + }, + "nullable": [ + true + ] + }, + "hash": "7a39250f44f7415c09362f62efcc0b2eded31017ac7c2a531a4917db5100bfda" +} diff --git a/src-tauri/.sqlx/query-7b9e30e8f67a024fb1ad94f76d16a22e221bdffc8ee37b777f26d02988f69bb1.json b/src-tauri/.sqlx/query-7b9e30e8f67a024fb1ad94f76d16a22e221bdffc8ee37b777f26d02988f69bb1.json deleted file mode 100644 index 3b65dac09..000000000 --- a/src-tauri/.sqlx/query-7b9e30e8f67a024fb1ad94f76d16a22e221bdffc8ee37b777f26d02988f69bb1.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, openid_display_name FROM instance WHERE id = $1;", - "describe": { - "columns": [ - { - "name": "id: _", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "uuid", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "url", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "proxy_url", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "username", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "token?", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "client_traffic_policy", - "ordinal": 7, - "type_info": "Integer" - }, - { - "name": "enterprise_enabled", - "ordinal": 8, - "type_info": "Bool" - }, - { - "name": "openid_display_name", - "ordinal": 9, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - true, - false, - false, - true - ] - }, - "hash": "7b9e30e8f67a024fb1ad94f76d16a22e221bdffc8ee37b777f26d02988f69bb1" -} diff --git a/src-tauri/.sqlx/query-7f97fe602e8d1c75f79d88abd2b20eca881e3d8682aaff17b2342f282e3916bf.json b/src-tauri/.sqlx/query-7f97fe602e8d1c75f79d88abd2b20eca881e3d8682aaff17b2342f282e3916bf.json deleted file mode 100644 index f826464a4..000000000 --- a/src-tauri/.sqlx/query-7f97fe602e8d1c75f79d88abd2b20eca881e3d8682aaff17b2342f282e3916bf.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "WITH prev_download AS (\n SELECT download\n FROM location_stats\n WHERE location_id = $1\n ORDER BY collected_at DESC\n LIMIT 1 OFFSET 1\n )\n SELECT ls.id \"id!: i64\",\n ls.location_id,\n ls.upload \"upload!: i64\",\n ls.download \"download!: i64\",\n ls.last_handshake,\n ls.collected_at \"collected_at!: NaiveDateTime\",\n ls.listen_port \"listen_port!: u32\",\n ls.persistent_keepalive_interval \"persistent_keepalive_interval?: u16\"\n FROM location_stats ls\n LEFT JOIN prev_download pd\n WHERE ls.location_id = $1\n AND (pd.download IS NULL OR ls.download != pd.download)\n ORDER BY ls.collected_at DESC\n LIMIT 1", - "describe": { - "columns": [ - { - "name": "id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "location_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "upload!: i64", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "download!: i64", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "last_handshake", - "ordinal": 4, - "type_info": "Integer" - }, - { - "name": "collected_at!: NaiveDateTime", - "ordinal": 5, - "type_info": "Datetime" - }, - { - "name": "listen_port!: u32", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "persistent_keepalive_interval?: u16", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - false, - false, - false, - false, - false, - false, - true - ] - }, - "hash": "7f97fe602e8d1c75f79d88abd2b20eca881e3d8682aaff17b2342f282e3916bf" -} diff --git a/src-tauri/.sqlx/query-85f8edf373d3bf1d405a8fed804d9d04839e69a6c2c5cb8ad5c2f8e19547a2f6.json b/src-tauri/.sqlx/query-85f8edf373d3bf1d405a8fed804d9d04839e69a6c2c5cb8ad5c2f8e19547a2f6.json deleted file mode 100644 index 1615e9b40..000000000 --- a/src-tauri/.sqlx/query-85f8edf373d3bf1d405a8fed804d9d04839e69a6c2c5cb8ad5c2f8e19547a2f6.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\" FROM location WHERE pubkey = $1;", - "describe": { - "columns": [ - { - "name": "id: _", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "instance_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "address", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "pubkey", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "endpoint", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "allowed_ips", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "dns", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "network_id", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "route_all_traffic", - "ordinal": 9, - "type_info": "Bool" - }, - { - "name": "keepalive_interval", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "location_mfa_mode: LocationMfaMode", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "service_location_mode: ServiceLocationMode", - "ordinal": 12, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false - ] - }, - "hash": "85f8edf373d3bf1d405a8fed804d9d04839e69a6c2c5cb8ad5c2f8e19547a2f6" -} diff --git a/src-tauri/.sqlx/query-8d99d5b737d20d54afb6e0708d7d1747a306bcd437b9adbad38b3627910a6185.json b/src-tauri/.sqlx/query-8d99d5b737d20d54afb6e0708d7d1747a306bcd437b9adbad38b3627910a6185.json new file mode 100644 index 000000000..f2b6d853e --- /dev/null +++ b/src-tauri/.sqlx/query-8d99d5b737d20d54afb6e0708d7d1747a306bcd437b9adbad38b3627910a6185.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE instance SET name = $1, uuid = $2, url = $3, proxy_url = $4, username = $5, client_traffic_policy = $6, enterprise_enabled = $7, disable_tunnels = $8, token = $9, openid_display_name = $10 WHERE id = $11;", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "8d99d5b737d20d54afb6e0708d7d1747a306bcd437b9adbad38b3627910a6185" +} diff --git a/src-tauri/.sqlx/query-8e5e1ade31ec88a0e75eb5d8aa86aa8f73e244f6d3128b255a367ad19f534721.json b/src-tauri/.sqlx/query-8e5e1ade31ec88a0e75eb5d8aa86aa8f73e244f6d3128b255a367ad19f534721.json deleted file mode 100644 index 44220f462..000000000 --- a/src-tauri/.sqlx/query-8e5e1ade31ec88a0e75eb5d8aa86aa8f73e244f6d3128b255a367ad19f534721.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "WITH prev_download AS (\n SELECT download\n FROM tunnel_stats\n WHERE tunnel_id = $1\n ORDER BY collected_at DESC\n LIMIT 1 OFFSET 1\n )\n SELECT ts.id \"id!: i64\",\n ts.tunnel_id,\n ts.upload \"upload!: i64\",\n ts.download \"download!: i64\",\n ts.last_handshake,\n ts.collected_at \"collected_at!: NaiveDateTime\",\n ts.listen_port \"listen_port!: u32\",\n ts.persistent_keepalive_interval \"persistent_keepalive_interval!: u16\"\n FROM tunnel_stats ts\n LEFT JOIN prev_download pd\n WHERE ts.tunnel_id = $1\n AND (pd.download IS NULL OR ts.download != pd.download)\n ORDER BY ts.collected_at DESC\n LIMIT 1", - "describe": { - "columns": [ - { - "name": "id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "tunnel_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "upload!: i64", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "download!: i64", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "last_handshake", - "ordinal": 4, - "type_info": "Integer" - }, - { - "name": "collected_at!: NaiveDateTime", - "ordinal": 5, - "type_info": "Datetime" - }, - { - "name": "listen_port!: u32", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "persistent_keepalive_interval!: u16", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "8e5e1ade31ec88a0e75eb5d8aa86aa8f73e244f6d3128b255a367ad19f534721" -} diff --git a/src-tauri/.sqlx/query-9137d3329ed718f211b5654af41b297c31706f5a5ad9ac400be116db7113a056.json b/src-tauri/.sqlx/query-9137d3329ed718f211b5654af41b297c31706f5a5ad9ac400be116db7113a056.json deleted file mode 100644 index 012a54b34..000000000 --- a/src-tauri/.sqlx/query-9137d3329ed718f211b5654af41b297c31706f5a5ad9ac400be116db7113a056.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\" FROM location WHERE instance_id = $1 AND service_location_mode <= $2 ORDER BY name ASC", - "describe": { - "columns": [ - { - "name": "id: _", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "instance_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "address", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "pubkey", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "endpoint", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "allowed_ips", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "dns", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "network_id", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "route_all_traffic", - "ordinal": 9, - "type_info": "Bool" - }, - { - "name": "keepalive_interval", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "location_mfa_mode: LocationMfaMode", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "service_location_mode: ServiceLocationMode", - "ordinal": 12, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false - ] - }, - "hash": "9137d3329ed718f211b5654af41b297c31706f5a5ad9ac400be116db7113a056" -} diff --git a/src-tauri/.sqlx/query-97a52a8bbf020b77afe5dc427efb66abfdc6b571d1631a4f77fbf4fa5cfbe7e7.json b/src-tauri/.sqlx/query-97a52a8bbf020b77afe5dc427efb66abfdc6b571d1631a4f77fbf4fa5cfbe7e7.json new file mode 100644 index 000000000..e0c2aeee7 --- /dev/null +++ b/src-tauri/.sqlx/query-97a52a8bbf020b77afe5dc427efb66abfdc6b571d1631a4f77fbf4fa5cfbe7e7.json @@ -0,0 +1,104 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE pubkey = $1", + "describe": { + "columns": [ + { + "name": "id: _", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "instance_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "pubkey", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "endpoint", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "allowed_ips", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "dns", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "network_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "route_all_traffic", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "keepalive_interval", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "location_mfa_mode: LocationMfaMode", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "service_location_mode: ServiceLocationMode", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "mfa_method: _", + "ordinal": 13, + "type_info": "Integer" + }, + { + "name": "posture_check_required", + "ordinal": 14, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "97a52a8bbf020b77afe5dc427efb66abfdc6b571d1631a4f77fbf4fa5cfbe7e7" +} diff --git a/src-tauri/.sqlx/query-996cf1bb9cab0f82963a519f0991a5a19ee3e5d2729c16b26c5c7ce9d73a5b5b.json b/src-tauri/.sqlx/query-996cf1bb9cab0f82963a519f0991a5a19ee3e5d2729c16b26c5c7ce9d73a5b5b.json new file mode 100644 index 000000000..dc9f97715 --- /dev/null +++ b/src-tauri/.sqlx/query-996cf1bb9cab0f82963a519f0991a5a19ee3e5d2729c16b26c5c7ce9d73a5b5b.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO tunnel_stats (tunnel_id, upload, download, upload_diff, download_diff, last_handshake, collected_at, listen_port, persistent_keepalive_interval) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id \"id!\"", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 9 + }, + "nullable": [ + true + ] + }, + "hash": "996cf1bb9cab0f82963a519f0991a5a19ee3e5d2729c16b26c5c7ce9d73a5b5b" +} diff --git a/src-tauri/.sqlx/query-a25979219918af2df8abca48a48d7fba459b79b74d462565088bf27d8e9fcd5d.json b/src-tauri/.sqlx/query-a25979219918af2df8abca48a48d7fba459b79b74d462565088bf27d8e9fcd5d.json new file mode 100644 index 000000000..48cdb545a --- /dev/null +++ b/src-tauri/.sqlx/query-a25979219918af2df8abca48a48d7fba459b79b74d462565088bf27d8e9fcd5d.json @@ -0,0 +1,104 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE id = $1", + "describe": { + "columns": [ + { + "name": "id: _", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "instance_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "pubkey", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "endpoint", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "allowed_ips", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "dns", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "network_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "route_all_traffic", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "keepalive_interval", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "location_mfa_mode: LocationMfaMode", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "service_location_mode: ServiceLocationMode", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "mfa_method: _", + "ordinal": 13, + "type_info": "Integer" + }, + { + "name": "posture_check_required", + "ordinal": 14, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "a25979219918af2df8abca48a48d7fba459b79b74d462565088bf27d8e9fcd5d" +} diff --git a/src-tauri/.sqlx/query-a694907453e48370dfc3f4de499d03dc47fa9fdd542af8c85764628f92133040.json b/src-tauri/.sqlx/query-a694907453e48370dfc3f4de499d03dc47fa9fdd542af8c85764628f92133040.json deleted file mode 100644 index e3f28c60e..000000000 --- a/src-tauri/.sqlx/query-a694907453e48370dfc3f4de499d03dc47fa9fdd542af8c85764628f92133040.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "WITH cte AS (SELECT id, location_id, COALESCE(upload - LAG(upload) OVER (PARTITION BY location_id ORDER BY collected_at), 0) upload, COALESCE(download - LAG(download) OVER (PARTITION BY location_id ORDER BY collected_at), 0) download, last_handshake, strftime($1, collected_at) collected_at, listen_port, persistent_keepalive_interval FROM location_stats ORDER BY collected_at LIMIT -1 OFFSET 1) SELECT id, location_id, SUM(MAX(upload, 0)) \"upload!: i64\", SUM(MAX(download, 0)) \"download!: i64\", last_handshake, collected_at \"collected_at!: NaiveDateTime\", listen_port \"listen_port!: u32\", persistent_keepalive_interval \"persistent_keepalive_interval?: u16\" FROM cte WHERE location_id = $2 AND collected_at >= $3 GROUP BY collected_at ORDER BY collected_at LIMIT $4", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "location_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "upload!: i64", - "ordinal": 2, - "type_info": "Null" - }, - { - "name": "download!: i64", - "ordinal": 3, - "type_info": "Null" - }, - { - "name": "last_handshake", - "ordinal": 4, - "type_info": "Integer" - }, - { - "name": "collected_at!: NaiveDateTime", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "listen_port!: u32", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "persistent_keepalive_interval?: u16", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 4 - }, - "nullable": [ - false, - false, - true, - true, - false, - true, - false, - true - ] - }, - "hash": "a694907453e48370dfc3f4de499d03dc47fa9fdd542af8c85764628f92133040" -} diff --git a/src-tauri/.sqlx/query-a87e237432cfd0bc54575c4b5f6ff1283fadfa9f50ac1de75fa283be52e970a1.json b/src-tauri/.sqlx/query-a87e237432cfd0bc54575c4b5f6ff1283fadfa9f50ac1de75fa283be52e970a1.json new file mode 100644 index 000000000..eee6229fe --- /dev/null +++ b/src-tauri/.sqlx/query-a87e237432cfd0bc54575c4b5f6ff1283fadfa9f50ac1de75fa283be52e970a1.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id!\", location_id,\n SUM(MAX(upload_diff, 0)) \"upload!: i64\", SUM(MAX(download_diff, 0)) \"download!: i64\", 0 \"upload_diff!: i64\", 0 \"download_diff!: i64\", last_handshake \"last_handshake!: i64\", strftime($1, collected_at) \"collected_at!: NaiveDateTime\", listen_port \"listen_port!: u32\", persistent_keepalive_interval \"persistent_keepalive_interval?: u16\" FROM location_stats WHERE location_id = $2 AND collected_at >= datetime(strftime($1, $3)) GROUP BY strftime($1, collected_at) ORDER BY collected_at LIMIT $4", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "location_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "upload!: i64", + "ordinal": 2, + "type_info": "Null" + }, + { + "name": "download!: i64", + "ordinal": 3, + "type_info": "Null" + }, + { + "name": "upload_diff!: i64", + "ordinal": 4, + "type_info": "Null" + }, + { + "name": "download_diff!: i64", + "ordinal": 5, + "type_info": "Null" + }, + { + "name": "last_handshake!: i64", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "collected_at!: NaiveDateTime", + "ordinal": 7, + "type_info": "Null" + }, + { + "name": "listen_port!: u32", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "persistent_keepalive_interval?: u16", + "ordinal": 9, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + true, + false, + null, + null, + null, + null, + false, + null, + false, + true + ] + }, + "hash": "a87e237432cfd0bc54575c4b5f6ff1283fadfa9f50ac1de75fa283be52e970a1" +} diff --git a/src-tauri/.sqlx/query-abded0999cc848a4baaad2e57a3247e9d7c7062bc43c84d3405a19c94006b8f8.json b/src-tauri/.sqlx/query-abded0999cc848a4baaad2e57a3247e9d7c7062bc43c84d3405a19c94006b8f8.json deleted file mode 100644 index 5a0b6593a..000000000 --- a/src-tauri/.sqlx/query-abded0999cc848a4baaad2e57a3247e9d7c7062bc43c84d3405a19c94006b8f8.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO location_stats (location_id, upload, download, last_handshake, collected_at, listen_port, persistent_keepalive_interval) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id \"id!\"", - "describe": { - "columns": [ - { - "name": "id!", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 7 - }, - "nullable": [ - true - ] - }, - "hash": "abded0999cc848a4baaad2e57a3247e9d7c7062bc43c84d3405a19c94006b8f8" -} diff --git a/src-tauri/.sqlx/query-ac0b93f9b64aa7f17824d4a55c714d8834ce46dacc032d2a900c36cd7c2b6a1e.json b/src-tauri/.sqlx/query-ac0b93f9b64aa7f17824d4a55c714d8834ce46dacc032d2a900c36cd7c2b6a1e.json new file mode 100644 index 000000000..b953e7983 --- /dev/null +++ b/src-tauri/.sqlx/query-ac0b93f9b64aa7f17824d4a55c714d8834ce46dacc032d2a900c36cd7c2b6a1e.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "WITH prev_download AS (\n SELECT download\n FROM tunnel_stats\n WHERE tunnel_id = $1\n ORDER BY collected_at DESC\n LIMIT 1 OFFSET 1\n )\n SELECT ts.id \"id!: i64\",\n ts.tunnel_id,\n ts.upload \"upload!: i64\",\n ts.download \"download!: i64\",\n ts.upload_diff,\n ts.download_diff,\n ts.last_handshake,\n ts.collected_at \"collected_at!: NaiveDateTime\",\n ts.listen_port \"listen_port!: u32\",\n ts.persistent_keepalive_interval \"persistent_keepalive_interval!: u16\"\n FROM tunnel_stats ts\n LEFT JOIN prev_download pd\n WHERE ts.tunnel_id = $1\n AND (pd.download IS NULL OR ts.download != pd.download)\n ORDER BY ts.collected_at DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tunnel_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "upload!: i64", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "download!: i64", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "upload_diff", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "download_diff", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "last_handshake", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "collected_at!: NaiveDateTime", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "listen_port!: u32", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "persistent_keepalive_interval!: u16", + "ordinal": 9, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "ac0b93f9b64aa7f17824d4a55c714d8834ce46dacc032d2a900c36cd7c2b6a1e" +} diff --git a/src-tauri/.sqlx/query-af70b9b18d8452a03d4d5624c2f3a11ab0d2e123989e97dfceb57e472523398c.json b/src-tauri/.sqlx/query-af70b9b18d8452a03d4d5624c2f3a11ab0d2e123989e97dfceb57e472523398c.json new file mode 100644 index 000000000..a0a75036b --- /dev/null +++ b/src-tauri/.sqlx/query-af70b9b18d8452a03d4d5624c2f3a11ab0d2e123989e97dfceb57e472523398c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE location SET route_all_traffic = 0 WHERE instance_id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "af70b9b18d8452a03d4d5624c2f3a11ab0d2e123989e97dfceb57e472523398c" +} diff --git a/src-tauri/.sqlx/query-b882379427740576d70c89eaeb815dede3c312162dcc73cea9c883289ba9fa8e.json b/src-tauri/.sqlx/query-b882379427740576d70c89eaeb815dede3c312162dcc73cea9c883289ba9fa8e.json deleted file mode 100644 index 5163c8ca3..000000000 --- a/src-tauri/.sqlx/query-b882379427740576d70c89eaeb815dede3c312162dcc73cea9c883289ba9fa8e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12 WHERE id = $13", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "b882379427740576d70c89eaeb815dede3c312162dcc73cea9c883289ba9fa8e" -} diff --git a/src-tauri/.sqlx/query-ba54bef9b71c2add858203b7be2c0e87d3a54d536ef96a923a6b949e16b9746e.json b/src-tauri/.sqlx/query-ba54bef9b71c2add858203b7be2c0e87d3a54d536ef96a923a6b949e16b9746e.json deleted file mode 100644 index a8ba1030a..000000000 --- a/src-tauri/.sqlx/query-ba54bef9b71c2add858203b7be2c0e87d3a54d536ef96a923a6b949e16b9746e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE location SET route_all_traffic = 0 WHERE instance_id = $1;", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "ba54bef9b71c2add858203b7be2c0e87d3a54d536ef96a923a6b949e16b9746e" -} diff --git a/src-tauri/.sqlx/query-c4a6b8e1c94eefc619c768ff4aac9aa248baf9553cea4f47ca9b1f6499bedaa3.json b/src-tauri/.sqlx/query-c4a6b8e1c94eefc619c768ff4aac9aa248baf9553cea4f47ca9b1f6499bedaa3.json deleted file mode 100644 index ec67166eb..000000000 --- a/src-tauri/.sqlx/query-c4a6b8e1c94eefc619c768ff4aac9aa248baf9553cea4f47ca9b1f6499bedaa3.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO instance (name, uuid, url, proxy_url, username, token, client_traffic_policy , enterprise_enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id;", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 8 - }, - "nullable": [ - false - ] - }, - "hash": "c4a6b8e1c94eefc619c768ff4aac9aa248baf9553cea4f47ca9b1f6499bedaa3" -} diff --git a/src-tauri/.sqlx/query-c598f6e45f80389d4ceaf24a1d2fc854f048ef3679e8d07964cd7acefd8709d0.json b/src-tauri/.sqlx/query-c598f6e45f80389d4ceaf24a1d2fc854f048ef3679e8d07964cd7acefd8709d0.json new file mode 100644 index 000000000..e511e531e --- /dev/null +++ b/src-tauri/.sqlx/query-c598f6e45f80389d4ceaf24a1d2fc854f048ef3679e8d07964cd7acefd8709d0.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name FROM instance WHERE id = $1;", + "describe": { + "columns": [ + { + "name": "id: _", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "uuid", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "proxy_url", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "username", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "token?", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "client_traffic_policy", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "enterprise_enabled", + "ordinal": 8, + "type_info": "Bool" + }, + { + "name": "disable_tunnels", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "openid_display_name", + "ordinal": 10, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "c598f6e45f80389d4ceaf24a1d2fc854f048ef3679e8d07964cd7acefd8709d0" +} diff --git a/src-tauri/.sqlx/query-c6a5e793cccc520039e28da8b4fb73e0c79c6a8d671c300ec2ea3eb0d58342b5.json b/src-tauri/.sqlx/query-c6a5e793cccc520039e28da8b4fb73e0c79c6a8d671c300ec2ea3eb0d58342b5.json new file mode 100644 index 000000000..2436bff93 --- /dev/null +++ b/src-tauri/.sqlx/query-c6a5e793cccc520039e28da8b4fb73e0c79c6a8d671c300ec2ea3eb0d58342b5.json @@ -0,0 +1,104 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE service_location_mode <= $1 ORDER BY name ASC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "instance_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "pubkey", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "endpoint", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "allowed_ips", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "dns", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "network_id", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "route_all_traffic", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "keepalive_interval", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "location_mfa_mode: LocationMfaMode", + "ordinal": 11, + "type_info": "Integer" + }, + { + "name": "service_location_mode: ServiceLocationMode", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "mfa_method: _", + "ordinal": 13, + "type_info": "Integer" + }, + { + "name": "posture_check_required", + "ordinal": 14, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "c6a5e793cccc520039e28da8b4fb73e0c79c6a8d671c300ec2ea3eb0d58342b5" +} diff --git a/src-tauri/.sqlx/query-d72c4c19cf9ed8247137d760a64c2c182ff2958180096fc0c85425fe6279138d.json b/src-tauri/.sqlx/query-d72c4c19cf9ed8247137d760a64c2c182ff2958180096fc0c85425fe6279138d.json new file mode 100644 index 000000000..28ce0735d --- /dev/null +++ b/src-tauri/.sqlx/query-d72c4c19cf9ed8247137d760a64c2c182ff2958180096fc0c85425fe6279138d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO instance (name, uuid, url, proxy_url, username, token, client_traffic_policy , enterprise_enabled, disable_tunnels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id;", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 9 + }, + "nullable": [ + false + ] + }, + "hash": "d72c4c19cf9ed8247137d760a64c2c182ff2958180096fc0c85425fe6279138d" +} diff --git a/src-tauri/.sqlx/query-d8d908979a8573ee2c32828fa562d1bf171b4a6e224cc238680e2e856811c62e.json b/src-tauri/.sqlx/query-d8d908979a8573ee2c32828fa562d1bf171b4a6e224cc238680e2e856811c62e.json deleted file mode 100644 index e023a3a77..000000000 --- a/src-tauri/.sqlx/query-d8d908979a8573ee2c32828fa562d1bf171b4a6e224cc238680e2e856811c62e.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id,route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\" FROM location WHERE service_location_mode <= $1 ORDER BY name ASC;", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "instance_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "name", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "address", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "pubkey", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "endpoint", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "allowed_ips", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "dns", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "network_id", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "route_all_traffic", - "ordinal": 9, - "type_info": "Bool" - }, - { - "name": "keepalive_interval", - "ordinal": 10, - "type_info": "Integer" - }, - { - "name": "location_mfa_mode: LocationMfaMode", - "ordinal": 11, - "type_info": "Integer" - }, - { - "name": "service_location_mode: ServiceLocationMode", - "ordinal": 12, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - false, - false - ] - }, - "hash": "d8d908979a8573ee2c32828fa562d1bf171b4a6e224cc238680e2e856811c62e" -} diff --git a/src-tauri/.sqlx/query-db08146110d6df2759b34328c8ac6f87350d379fce3541ea43ed3519b5757d72.json b/src-tauri/.sqlx/query-db08146110d6df2759b34328c8ac6f87350d379fce3541ea43ed3519b5757d72.json deleted file mode 100644 index 3bc260a50..000000000 --- a/src-tauri/.sqlx/query-db08146110d6df2759b34328c8ac6f87350d379fce3541ea43ed3519b5757d72.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "WITH cte AS (SELECT id, tunnel_id, COALESCE(upload - LAG(upload) OVER (PARTITION BY tunnel_id ORDER BY collected_at), 0) upload, COALESCE(download - LAG(download) OVER (PARTITION BY tunnel_id ORDER BY collected_at), 0) download, last_handshake, strftime($1, collected_at) collected_at, listen_port, persistent_keepalive_interval FROM tunnel_stats ORDER BY collected_at LIMIT -1 OFFSET 1) SELECT id, tunnel_id, SUM(MAX(upload, 0)) \"upload!: i64\", SUM(MAX(download, 0)) \"download!: i64\", last_handshake, collected_at \"collected_at!: NaiveDateTime\", listen_port \"listen_port!: u32\", persistent_keepalive_interval \"persistent_keepalive_interval!: u16\" FROM cte WHERE tunnel_id = $2 AND collected_at >= $3 GROUP BY collected_at ORDER BY collected_at", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "tunnel_id", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "upload!: i64", - "ordinal": 2, - "type_info": "Null" - }, - { - "name": "download!: i64", - "ordinal": 3, - "type_info": "Null" - }, - { - "name": "last_handshake", - "ordinal": 4, - "type_info": "Integer" - }, - { - "name": "collected_at!: NaiveDateTime", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "listen_port!: u32", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "persistent_keepalive_interval!: u16", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - false, - false, - true, - true, - false, - true, - false, - false - ] - }, - "hash": "db08146110d6df2759b34328c8ac6f87350d379fce3541ea43ed3519b5757d72" -} diff --git a/src-tauri/.sqlx/query-e16f46ba4c2365de31db15551084eddaabf35d813a54eced9d38c951965ce83e.json b/src-tauri/.sqlx/query-e16f46ba4c2365de31db15551084eddaabf35d813a54eced9d38c951965ce83e.json new file mode 100644 index 000000000..01577f676 --- /dev/null +++ b/src-tauri/.sqlx/query-e16f46ba4c2365de31db15551084eddaabf35d813a54eced9d38c951965ce83e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, mfa_method = $13, posture_check_required = $14 WHERE id = $15", + "describe": { + "columns": [], + "parameters": { + "Right": 15 + }, + "nullable": [] + }, + "hash": "e16f46ba4c2365de31db15551084eddaabf35d813a54eced9d38c951965ce83e" +} diff --git a/src-tauri/.sqlx/query-e27705d75d504385fbaea05c43eadecccdb12fcb43060dd383e7c9fd1516179e.json b/src-tauri/.sqlx/query-e27705d75d504385fbaea05c43eadecccdb12fcb43060dd383e7c9fd1516179e.json new file mode 100644 index 000000000..538701cfe --- /dev/null +++ b/src-tauri/.sqlx/query-e27705d75d504385fbaea05c43eadecccdb12fcb43060dd383e7c9fd1516179e.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, service_location_mode, mfa_method, posture_check_required) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id \"id!\"", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 14 + }, + "nullable": [ + true + ] + }, + "hash": "e27705d75d504385fbaea05c43eadecccdb12fcb43060dd383e7c9fd1516179e" +} diff --git a/src-tauri/.sqlx/query-ea39145f2cdc783bc78b32363cce32a87bd603debccaec23b160150766bdcd9f.json b/src-tauri/.sqlx/query-ea39145f2cdc783bc78b32363cce32a87bd603debccaec23b160150766bdcd9f.json deleted file mode 100644 index a05f49a06..000000000 --- a/src-tauri/.sqlx/query-ea39145f2cdc783bc78b32363cce32a87bd603debccaec23b160150766bdcd9f.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, service_location_mode) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id \"id!\"", - "describe": { - "columns": [ - { - "name": "id!", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 12 - }, - "nullable": [ - true - ] - }, - "hash": "ea39145f2cdc783bc78b32363cce32a87bd603debccaec23b160150766bdcd9f" -} diff --git a/src-tauri/.sqlx/query-eaac354b13bef778b251addee9a8b91fce670a426d535f10de9f308a7db95484.json b/src-tauri/.sqlx/query-eaac354b13bef778b251addee9a8b91fce670a426d535f10de9f308a7db95484.json new file mode 100644 index 000000000..e93482981 --- /dev/null +++ b/src-tauri/.sqlx/query-eaac354b13bef778b251addee9a8b91fce670a426d535f10de9f308a7db95484.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name FROM instance WHERE name = $1;", + "describe": { + "columns": [ + { + "name": "id: _", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "uuid", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "proxy_url", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "username", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "token?", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "client_traffic_policy", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "enterprise_enabled", + "ordinal": 8, + "type_info": "Bool" + }, + { + "name": "disable_tunnels", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "openid_display_name", + "ordinal": 10, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "eaac354b13bef778b251addee9a8b91fce670a426d535f10de9f308a7db95484" +} diff --git a/src-tauri/.sqlx/query-ed2bcb7a0bba8f3cee393b73367f38f262aa41551fc31a32bd91eefd22c4b870.json b/src-tauri/.sqlx/query-ed2bcb7a0bba8f3cee393b73367f38f262aa41551fc31a32bd91eefd22c4b870.json new file mode 100644 index 000000000..01c71e472 --- /dev/null +++ b/src-tauri/.sqlx/query-ed2bcb7a0bba8f3cee393b73367f38f262aa41551fc31a32bd91eefd22c4b870.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS (SELECT 1 FROM tunnel);", + "describe": { + "columns": [ + { + "name": "EXISTS (SELECT 1 FROM tunnel)", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "ed2bcb7a0bba8f3cee393b73367f38f262aa41551fc31a32bd91eefd22c4b870" +} diff --git a/src-tauri/.sqlx/query-fa461f6de14068995ec4ed0bddcc6837e5029ca20131c35901be3e898dbefb1e.json b/src-tauri/.sqlx/query-fa461f6de14068995ec4ed0bddcc6837e5029ca20131c35901be3e898dbefb1e.json new file mode 100644 index 000000000..734a9188d --- /dev/null +++ b/src-tauri/.sqlx/query-fa461f6de14068995ec4ed0bddcc6837e5029ca20131c35901be3e898dbefb1e.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT disable_tunnels as \"disable_tunnels!\" FROM instance", + "describe": { + "columns": [ + { + "name": "disable_tunnels!", + "ordinal": 0, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "fa461f6de14068995ec4ed0bddcc6837e5029ca20131c35901be3e898dbefb1e" +} diff --git a/src-tauri/.sqlx/query-fb2ca29fd032be5e176379bd4da7fbab71aaa4c42fe8d8cde418708077622408.json b/src-tauri/.sqlx/query-fb2ca29fd032be5e176379bd4da7fbab71aaa4c42fe8d8cde418708077622408.json new file mode 100644 index 000000000..b75001f10 --- /dev/null +++ b/src-tauri/.sqlx/query-fb2ca29fd032be5e176379bd4da7fbab71aaa4c42fe8d8cde418708077622408.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token, client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name FROM instance WHERE token IS NOT NULL ORDER BY name ASC;", + "describe": { + "columns": [ + { + "name": "id: _", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "uuid", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "proxy_url", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "username", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "token", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "client_traffic_policy", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "enterprise_enabled", + "ordinal": 8, + "type_info": "Bool" + }, + { + "name": "disable_tunnels", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "openid_display_name", + "ordinal": 10, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "fb2ca29fd032be5e176379bd4da7fbab71aaa4c42fe8d8cde418708077622408" +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6b2b88c85..70e3ed2a2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -14,28 +14,35 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] [[package]] -name = "ahash" -version = "0.7.8" +name = "aho-corasick" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", + "memchr", ] [[package]] -name = "aho-corasick" -version = "1.1.4" +name = "aligned" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" dependencies = [ - "memchr", + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", ] [[package]] @@ -46,9 +53,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -78,9 +85,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -121,7 +128,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -132,14 +139,20 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arboard" @@ -162,11 +175,31 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] [[package]] name = "ashpd" @@ -179,7 +212,7 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.8.6", + "rand 0.8.7", "serde", "serde_repr", "url", @@ -187,54 +220,22 @@ dependencies = [ ] [[package]] -name = "askama" -version = "0.14.0" +name = "assert-json-diff" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" dependencies = [ - "askama_derive", - "itoa", - "percent-encoding", "serde", "serde_json", ] -[[package]] -name = "askama_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 2.0.117", -] - -[[package]] -name = "askama_parser" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" -dependencies = [ - "memchr", - "serde", - "serde_derive", - "winnow 0.7.15", -] - [[package]] name = "async-broadcast" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -327,7 +328,7 @@ version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "event-listener-strategy", "pin-project-lite", ] @@ -356,7 +357,7 @@ dependencies = [ "async-task", "blocking", "cfg-if", - "event-listener 5.4.1", + "event-listener 5.4.2", "futures-lite", "rustix", ] @@ -369,7 +370,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -435,7 +436,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -446,13 +447,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -495,15 +496,58 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.20", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -511,14 +555,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -582,15 +627,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -606,6 +642,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -614,23 +656,20 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] [[package]] -name = "bitvec" -version = "1.0.1" +name = "bitstream-io" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" dependencies = [ - "funty", - "radium", - "tap", - "wyz", + "no_std_io2", ] [[package]] @@ -639,7 +678,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -651,6 +690,15 @@ 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.5.1" @@ -683,34 +731,35 @@ dependencies = [ ] [[package]] -name = "borsh" -version = "1.6.1" +name = "bon" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", + "bon-macros", + "rustversion", ] [[package]] -name = "borsh-derive" -version = "1.6.1" +name = "bon-macros" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ - "once_cell", - "proc-macro-crate 3.5.0", + "darling", + "ident_case", + "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "rustversion", + "syn 2.0.119", ] [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -719,59 +768,40 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] [[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "byte-unit" -version = "5.2.0" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "rust_decimal", - "schemars 1.2.1", - "serde", - "utf8-width", + "tinyvec", ] [[package]] -name = "bytecheck" -version = "0.6.12" +name = "built" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" [[package]] -name = "bytecheck_derive" -version = "0.6.12" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -787,9 +817,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -800,7 +830,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -821,9 +851,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -848,7 +878,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -863,9 +893,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -908,9 +938,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -920,7 +950,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -930,7 +971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -938,9 +979,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -956,16 +997,16 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -973,9 +1014,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -985,14 +1026,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1019,6 +1060,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1035,13 +1082,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "common" -version = "1.6.8" -dependencies = [ - "nix 0.31.2", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1057,6 +1097,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1077,17 +1123,11 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "percent-encoding", "time", @@ -1144,7 +1184,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1157,7 +1197,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -1171,6 +1211,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -1197,27 +1246,46 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -1237,20 +1305,12 @@ dependencies = [ ] [[package]] -name = "cssparser" -version = "0.29.6" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", + "hybrid-array", ] [[package]] @@ -1262,7 +1322,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -1273,19 +1333,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.117", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1293,9 +1359,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", "curve25519-dalek-derive", - "fiat-crypto", + "fiat-crypto 0.3.0", "rustc_version", "serde", "subtle", @@ -1310,7 +1390,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1327,38 +1407,14 @@ dependencies = [ "winreg 0.52.0", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "darling_core", + "darling_macro", ] [[package]] @@ -1371,30 +1427,25 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.20.11", + "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] -name = "darling_macro" -version = "0.23.0" +name = "data-encoding" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", -] +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "data-url" @@ -1403,31 +1454,105 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] -name = "defguard-client" -version = "1.6.8" +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 = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "defguard-cli" +version = "2.1.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "clap", + "defguard-client-common", + "defguard-client-config-sync", + "defguard-client-core", + "defguard-client-posture", + "defguard-client-proto", + "futures-util", + "http", + "image", + "owo-colors", + "qrcode", + "reqwest 0.13.4", + "secrecy", + "serde", + "serde_json", + "sha1 0.11.0", + "sqlx", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "tonic", + "tracing", + "tracing-subscriber", + "url", + "webbrowser", +] + +[[package]] +name = "defguard-client" +version = "2.1.0" dependencies = [ "anyhow", "async-stream", "base64 0.22.1", "block2 0.6.2", "chrono", - "clap", - "common", "dark-light", + "defguard-cli", + "defguard-client-common", + "defguard-client-config-sync", + "defguard-client-core", + "defguard-client-posture", + "defguard-client-proto", + "defguard-client-provisioning", + "defguard-client-service-locations", "defguard_wireguard_rs", "dirs-next", + "dispatch2", "futures-core", "hyper-util", "known-folders", "log", - "nix 0.31.2", + "nix", "objc2 0.6.4", + "objc2-app-kit", "objc2-foundation 0.3.2", "objc2-network-extension", + "objc2-system-extensions", "os_info", "prost", "regex", - "reqwest 0.13.2", + "reqwest 0.13.4", "rust-ini", "semver", "serde", @@ -1436,7 +1561,7 @@ dependencies = [ "sqlx", "struct-patch", "strum", - "swift-rs", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -1451,40 +1576,211 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-window-state", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", "tokio-util", "tonic", "tonic-prost", - "tonic-prost-build", "tower", "tracing", "tracing-appender", "tracing-subscriber", + "uuid", "vergen-git2", "webbrowser", "windows 0.62.2", "windows-acl", "windows-service", "windows-sys 0.61.2", - "x25519-dalek", + "wmi", + "x25519-dalek 3.0.0", +] + +[[package]] +name = "defguard-client-common" +version = "2.1.0" +dependencies = [ + "nix", + "vergen-git2", +] + +[[package]] +name = "defguard-client-config-sync" +version = "2.1.0" +dependencies = [ + "defguard-client-core", + "defguard-client-proto", + "defguard-client-service-locations", + "http", + "log", + "reqwest 0.13.4", + "semver", + "serde", + "serde_json", + "sqlx", + "tokio", + "tonic", +] + +[[package]] +name = "defguard-client-core" +version = "2.1.0" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "chrono", + "defguard-client-common", + "defguard-client-proto", + "defguard_wireguard_rs", + "dirs-next", + "futures-util", + "hex", + "hyper-util", + "log", + "nix", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-network-extension", + "os_info", + "prost", + "reqwest 0.13.4", + "rust-ini", + "semver", + "serde", + "serde_json", + "serde_with", + "sqlx", + "struct-patch", + "strum", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tonic", + "tower", + "tracing", + "windows-sys 0.61.2", + "wiremock", + "x25519-dalek 3.0.0", +] + +[[package]] +name = "defguard-client-posture" +version = "2.1.0" +dependencies = [ + "defguard-client-core", + "defguard-client-proto", + "log", + "reqwest 0.13.4", + "serde", + "serde_json", + "sysinfo", + "time", + "tokio", + "tonic", + "wiremock", + "wmi", +] + +[[package]] +name = "defguard-client-proto" +version = "2.1.0" +dependencies = [ + "defguard_wireguard_rs", + "prost", + "serde", + "serde_with", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "wmi", + "x25519-dalek 3.0.0", +] + +[[package]] +name = "defguard-client-provisioning" +version = "2.1.0" +dependencies = [ + "defguard-client-core", + "log", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "defguard-client-service" +version = "2.1.0" +dependencies = [ + "anyhow", + "async-stream", + "clap", + "defguard-client-common", + "defguard-client-posture", + "defguard-client-proto", + "defguard-client-service-locations", + "defguard_wireguard_rs", + "futures-core", + "log", + "nix", + "serde", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tracing", + "tracing-appender", + "tracing-subscriber", + "windows-core 0.62.2", + "windows-service", + "windows-sys 0.61.2", +] + +[[package]] +name = "defguard-client-service-locations" +version = "2.1.0" +dependencies = [ + "base64 0.22.1", + "defguard-client-common", + "defguard-client-core", + "defguard-client-posture", + "defguard-client-proto", + "defguard_wireguard_rs", + "futures-util", + "known-folders", + "log", + "prost", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "tokio", + "uuid", + "windows 0.62.2", + "windows-acl", + "windows-service", + "windows-sys 0.61.2", ] [[package]] name = "defguard-dg" -version = "1.6.8" +version = "2.1.0" dependencies = [ "clap", - "common", + "defguard-client-common", + "defguard-client-proto", "defguard_wireguard_rs", "dirs-next", "prost", - "reqwest 0.13.2", + "reqwest 0.13.4", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tonic", "tonic-prost", @@ -1495,9 +1791,9 @@ dependencies = [ [[package]] name = "defguard_boringtun" -version = "0.6.5" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b7c7f465dde186f958a0a0e4ae823af623451ba26817b8b366b9968286df7a1" +checksum = "6d920cd0791b2199308a3c8cc0d41b88d96e4c07d70e98b658a4ee72ad32ca7e" dependencies = [ "aead", "base64 0.22.1", @@ -1508,22 +1804,20 @@ dependencies = [ "ip_network", "ip_network_table", "libc", - "nix 0.31.2", + "nix", "parking_lot", "ring", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", - "uniffi", - "untrusted", - "x25519-dalek", + "x25519-dalek 2.0.1", ] [[package]] name = "defguard_wireguard_rs" -version = "0.9.5" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6805597cb34bef686b2c3f732ca1f88e6de676d92d43acd632ea5aae5169466" +checksum = "a09896853b7e5f2302c3e6c6786b3dc0433bc092cc9e3e647812d617d0c2a0ce" dependencies = [ "base64 0.22.1", "defguard_boringtun", @@ -1536,78 +1830,63 @@ dependencies = [ "netlink-packet-utils", "netlink-packet-wireguard", "netlink-sys", - "nix 0.31.2", "regex", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.62.2", "wireguard-nt", - "x25519-dalek", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "x25519-dalek 3.0.0", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "powerfmt", - "serde_core", + "bitflags 1.3.2", + "defmt-macros", ] [[package]] -name = "derive_builder" -version = "0.20.2" +name = "defmt-macros" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ - "derive_builder_macro", + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "derive_builder_core" -version = "0.20.2" +name = "defmt-parser" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", + "thiserror 2.0.20", ] [[package]] -name = "derive_builder_macro" -version = "0.20.2" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "derive_builder_core", - "syn 2.0.117", + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "derive_more" -version = "0.99.20" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", + "serde_core", ] [[package]] @@ -1628,7 +1907,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1637,12 +1916,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[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 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1671,7 +1961,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1691,7 +1981,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1699,13 +1989,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1728,7 +2018,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1756,12 +2046,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", - "cssparser 0.36.0", + "cssparser", "foldhash 0.2.0", - "html5ever 0.38.0", + "html5ever", "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", + "selectors", + "tendril", ] [[package]] @@ -1800,6 +2090,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -1814,23 +2119,23 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" dependencies = [ "serde", ] [[package]] name = "embed-resource" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -1874,7 +2179,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1887,6 +2192,26 @@ dependencies = [ "regex", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1911,14 +2236,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "etcetera" @@ -1939,11 +2264,10 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1954,35 +2278,38 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "pin-project-lite", ] [[package]] -name = "fastrand" -version = "2.4.1" +name = "exr" +version = "1.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] [[package]] -name = "fax" -version = "0.2.6" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "fax_derive" -version = "0.2.0" +name = "fax" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -2008,6 +2335,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -2020,9 +2353,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixedbitset" @@ -2090,13 +2423,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -2120,15 +2453,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs-err" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" -dependencies = [ - "autocfg", -] - [[package]] name = "fs_extra" version = "1.3.0" @@ -2136,26 +2460,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futf" -version = "0.1.5" +name = "futures" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ - "mac", - "new_debug_unreachable", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2163,15 +2486,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2191,9 +2514,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2210,33 +2533,34 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2247,15 +2571,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gdk" version = "0.18.2" @@ -2375,17 +2690,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -2395,7 +2699,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -2406,24 +2710,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", ] [[package]] @@ -2460,15 +2773,14 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", - "url", ] [[package]] @@ -2477,7 +2789,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -2505,7 +2817,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2520,9 +2832,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gloo-timers" @@ -2547,17 +2859,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "goblin" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" -dependencies = [ - "log", - "plain", - "scroll", -] - [[package]] name = "gtk" version = "0.18.2" @@ -2607,14 +2908,14 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "h2" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -2645,9 +2946,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] [[package]] name = "hashbrown" @@ -2668,9 +2966,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -2720,7 +3018,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2732,18 +3030,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - [[package]] name = "html5ever" version = "0.38.0" @@ -2751,14 +3037,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever 0.38.0", + "markup5ever", ] [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2766,9 +3052,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2776,9 +3062,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -2799,11 +3085,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -2927,9 +3222,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2941,9 +3236,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2954,9 +3249,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2968,16 +3263,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2988,15 +3284,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -3007,12 +3303,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -3032,9 +3322,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -3048,12 +3338,38 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "1.9.3" @@ -3072,7 +3388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3095,6 +3411,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ip_network" version = "0.4.1" @@ -3119,27 +3446,28 @@ checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d" [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] -name = "iri-string" -version = "0.7.12" +name = "is-docker" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" dependencies = [ - "memchr", - "serde", + "once_cell", ] [[package]] -name = "is-docker" -version = "0.2.0" +name = "is-terminal" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "once_cell", + "hermit-abi", + "libc", + "windows-sys 0.52.0", ] [[package]] @@ -3152,6 +3480,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3196,6 +3530,59 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[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" @@ -3224,7 +3611,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -3239,7 +3626,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3267,28 +3654,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3320,7 +3706,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] @@ -3331,19 +3717,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.14.0", - "selectors 0.24.0", + "windows-sys 0.59.0", ] [[package]] @@ -3365,10 +3739,10 @@ dependencies = [ ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "lebe" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libappindicator" @@ -3396,15 +3770,34 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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 = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] [[package]] name = "libgit2-sys" -version = "0.18.3+1.9.2" +version = "0.18.7+1.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" dependencies = [ "cc", "libc", @@ -3440,14 +3833,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.9.2", ] [[package]] @@ -3463,9 +3856,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -3481,9 +3874,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -3502,50 +3895,41 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" dependencies = [ "serde_core", "value-bag", ] [[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "mac" -version = "0.1.1" +name = "loop9" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] [[package]] -name = "mac-notification-sys" -version = "0.6.12" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" -dependencies = [ - "cc", - "objc2 0.6.4", - "objc2-foundation 0.3.2", - "time", -] +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "markup5ever" -version = "0.14.1" +name = "mac-notification-sys" +version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" dependencies = [ + "cc", "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "time", + "uuid", ] [[package]] @@ -3555,21 +3939,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril 0.5.0", + "tendril", "web_atoms", ] -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "matchers" version = "0.2.0" @@ -3579,18 +3952,22 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "matchit" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -3598,14 +3975,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -3622,12 +3999,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3640,12 +4011,12 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -3661,9 +4032,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -3674,9 +4045,9 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.60.2", ] @@ -3709,7 +4080,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3735,9 +4106,9 @@ dependencies = [ [[package]] name = "netlink-packet-core" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" dependencies = [ "paste", ] @@ -3753,11 +4124,11 @@ dependencies = [ [[package]] name = "netlink-packet-route" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "log", "netlink-packet-core", @@ -3771,15 +4142,16 @@ checksum = "3176f18d11a1ae46053e59ec89d46ba318ae1343615bd3f8c908bfc84edae35c" dependencies = [ "byteorder", "pastey", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "netlink-packet-wireguard" -version = "0.3.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037892b0e01ce41f30398a47be2051e712a2cf1eed9cb7e5e6a92b05c423255b" +checksum = "81b0e03593f61a7684836d73fdfef3dddae3f2dbc81896159a14bfafdb013567" dependencies = [ + "bitflags 2.13.1", "libc", "log", "netlink-packet-core", @@ -3805,23 +4177,11 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -3829,19 +4189,12 @@ dependencies = [ ] [[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "nom" -version = "7.1.3" +name = "no_std_io2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ "memchr", - "minimal-lexical", ] [[package]] @@ -3853,11 +4206,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify-rust" -version = "4.16.0" +version = "4.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" dependencies = [ "futures-lite", "log", @@ -3867,13 +4226,32 @@ dependencies = [ "zbus", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", ] [[package]] @@ -3887,33 +4265,64 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", "num-integer", "num-traits", ] @@ -3928,6 +4337,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3947,7 +4366,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3991,12 +4410,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", + "libc", "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation 0.3.2", + "objc2-quartz-core", ] [[package]] @@ -4005,7 +4431,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4016,6 +4442,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4026,7 +4453,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -4037,7 +4464,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -4070,10 +4497,23 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", + "objc2-io-surface", ] [[package]] @@ -4097,7 +4537,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4109,20 +4549,30 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "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.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -4147,7 +4597,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4159,18 +4609,29 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] +[[package]] +name = "objc2-system-extensions" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00ca10e1fd778a1dd13d579afb37168422ef4bbb36b6723179c2228f6b8372c" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-cloud-kit", @@ -4201,7 +4662,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-app-kit", @@ -4229,23 +4690,22 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.4" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4261,7 +4721,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4272,18 +4732,18 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -4320,13 +4780,13 @@ dependencies = [ [[package]] name = "os_info" -version = "3.14.0" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" dependencies = [ "android_system_properties", "log", - "nix 0.30.1", + "nix", "objc2 0.6.4", "objc2-foundation 0.3.2", "objc2-ui-kit", @@ -4341,7 +4801,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", ] [[package]] @@ -4410,12 +4880,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4444,180 +4908,46 @@ dependencies = [ [[package]] name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.6", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_macros" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.117", + "phf_macros", + "phf_shared", + "serde", ] [[package]] -name = "phf_shared" -version = "0.8.0" +name = "phf_codegen" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "siphasher 0.3.11", + "phf_generator", + "phf_shared", ] [[package]] -name = "phf_shared" -version = "0.10.0" +name = "phf_generator" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "siphasher 0.3.11", + "fastrand", + "phf_shared", ] [[package]] -name = "phf_shared" -version = "0.11.3" +name = "phf_macros" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "siphasher 1.0.2", + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4626,27 +4956,27 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4695,9 +5025,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" @@ -4707,13 +5037,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.39.2", + "quick-xml", "serde", "time", ] @@ -4737,7 +5067,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -4764,16 +5094,31 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -4806,7 +5151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4835,7 +5180,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -4863,25 +5208,38 @@ dependencies = [ ] [[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "profiling" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ - "unicode-ident", + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", ] [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -4889,9 +5247,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck 0.5.0", "itertools", @@ -4904,28 +5262,28 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.117", + "syn 2.0.119", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -4936,26 +5294,6 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "publicsuffix" version = "2.3.0" @@ -4968,59 +5306,91 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "memchr", "unicase", ] [[package]] name = "pulldown-cmark-to-cmark" -version = "22.0.0" +version = "22.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" dependencies = [ "pulldown-cmark", ] +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] -name = "quick-error" -version = "2.0.1" +name = "qoi" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] [[package]] -name = "quick-xml" -version = "0.37.5" +name = "qrcode" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" dependencies = [ - "memchr", + "image", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" -version = "0.39.2" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -5030,7 +5400,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -5038,21 +5408,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -5060,23 +5431,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -5093,31 +5464,11 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -5126,22 +5477,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] [[package]] -name = "rand_chacha" -version = "0.2.2" +name = "rand" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -5164,15 +5516,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -5192,21 +5535,77 @@ dependencies = [ ] [[package]] -name = "rand_hc" -version = "0.2.0" +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.5.1", + "rand_core 0.10.1", ] [[package]] -name = "rand_pcg" -version = "0.2.1" +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.20", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "rand_core 0.5.1", + "bitflags 2.13.1", ] [[package]] @@ -5215,22 +5614,48 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "f1c93da5bb2c5d4e6c0ef7abeead62c89169a0a4882bfb83ac892f2423aea2fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -5252,34 +5677,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -5289,9 +5714,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5300,18 +5725,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "rend" -version = "0.4.2" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -5361,9 +5777,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -5431,6 +5847,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -5445,43 +5867,14 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rsa" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -5504,28 +5897,11 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rust_decimal" -version = "1.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.6", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -5542,18 +5918,18 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -5567,9 +5943,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5579,9 +5955,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -5589,13 +5965,13 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.21.1", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -5605,7 +5981,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5616,9 +5992,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -5628,9 +6004,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -5685,9 +6061,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -5704,7 +6080,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5714,38 +6090,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "scroll" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" -dependencies = [ - "scroll_derive", -] - -[[package]] -name = "scroll_derive" -version = "0.12.1" +name = "secrecy" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "zeroize", ] -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5762,40 +6121,22 @@ dependencies = [ "libc", ] -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - [[package]] name = "selectors" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.1", - "cssparser 0.36.0", - "derive_more 2.1.1", + "bitflags 2.13.1", + "cssparser", + "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", + "phf", + "phf_codegen", "precomputed-hash", "rustc-hash", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] @@ -5811,9 +6152,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -5833,22 +6174,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -5859,14 +6200,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -5877,13 +6218,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -5918,17 +6259,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -5937,14 +6280,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ - "darling 0.23.0", + "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5966,37 +6309,38 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "servo_arc" -version = "0.2.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ - "nodrop", "stable_deref_trait", ] [[package]] -name = "servo_arc" -version = "0.4.3" +name = "sha1" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ - "stable_deref_trait", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -6006,8 +6350,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -6021,9 +6365,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -6041,43 +6385,46 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", ] [[package]] -name = "simdutf8" -version = "0.1.5" +name = "simd_helpers" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] [[package]] -name = "siphasher" -version = "0.3.11" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -6087,27 +6434,21 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] -[[package]] -name = "smawk" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" - [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6160,9 +6501,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -6202,7 +6543,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener 5.4.1", + "event-listener 5.4.2", "futures-core", "futures-intrusive", "futures-io", @@ -6218,7 +6559,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -6236,7 +6577,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6259,7 +6600,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.117", + "syn 2.0.119", "tokio", "url", ] @@ -6272,12 +6613,12 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -6294,15 +6635,15 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.6", + "rand 0.8.7", "rsa", "serde", - "sha1", + "sha1 0.10.7", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -6316,7 +6657,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "chrono", "crc", @@ -6334,14 +6675,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.6", + "rand 0.8.7", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -6367,7 +6708,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", "uuid", @@ -6379,25 +6720,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - [[package]] name = "string_cache" version = "0.9.0" @@ -6406,30 +6728,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -6453,22 +6763,22 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "struct-patch" -version = "0.10.5" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d4caaaccd69c9b56c5f5b33d4dca462464d3275230e4d2d3739ba6d4bf5bcb" +checksum = "9c33b1be720fec2481ed6f1d2b95fefa464500cc55ce0ba5938aba77718da5f2" dependencies = [ "struct-patch-derive", ] [[package]] name = "struct-patch-derive" -version = "0.10.5" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1671c6f0992b1b4cb4f5f8ea4a58f9a5f7f895a7638ef9690633dcec0aa67944" +checksum = "d4bfb88f9c30c62693f33814de38c84fd833c7a5b92238655d158deb3ac62d9f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6489,7 +6799,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6498,6 +6808,25 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -6520,6 +6849,16 @@ 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", @@ -6528,9 +6867,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -6554,7 +6893,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6566,13 +6905,27 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6602,15 +6955,16 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.8" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", + "dbus", "dispatch2", "dlopen2", "dpi", @@ -6621,13 +6975,14 @@ dependencies = [ "libc", "log", "ndk", - "ndk-context", "ndk-sys", "objc2 0.6.4", "objc2-app-kit", "objc2-foundation 0.3.2", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", @@ -6640,21 +6995,15 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "target-lexicon" version = "0.12.16" @@ -6663,9 +7012,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -6692,7 +7041,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.2", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -6703,7 +7052,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -6715,9 +7064,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.6" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -6731,15 +7080,14 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -6753,9 +7101,9 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.117", + "syn 2.0.119", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "uuid", @@ -6764,23 +7112,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.5.4" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -6789,7 +7137,6 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -6805,14 +7152,14 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "tauri-plugin-deep-link" -version = "2.4.7" +version = "2.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" dependencies = [ "dunce", "plist", @@ -6822,7 +7169,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", "windows-registry 0.5.3", @@ -6831,9 +7178,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" dependencies = [ "log", "raw-window-handle", @@ -6843,15 +7190,15 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.5.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ "anyhow", "dunce", @@ -6866,16 +7213,16 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-http" -version = "2.5.8" +version = "2.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d" +checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067" dependencies = [ "bytes", "cookie_store", @@ -6889,7 +7236,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "url", "urlpattern", @@ -6897,12 +7244,11 @@ dependencies = [ [[package]] name = "tauri-plugin-log" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" dependencies = [ "android_logger", - "byte-unit", "fern", "log", "objc2 0.6.4", @@ -6913,7 +7259,7 @@ dependencies = [ "swift-rs", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] @@ -6925,22 +7271,22 @@ checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" dependencies = [ "log", "notify-rust", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", ] [[package]] name = "tauri-plugin-opener" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", @@ -6952,7 +7298,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "windows 0.61.3", "zbus", @@ -6973,7 +7319,7 @@ dependencies = [ "sys-locale", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -6988,15 +7334,16 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.0" +version = "2.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" dependencies = [ "serde", "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.18", + "thiserror 2.0.20", + "tokio", "tracing", "windows-sys 0.60.2", "zbus", @@ -7008,20 +7355,20 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "log", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -7035,7 +7382,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -7044,9 +7391,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -7070,24 +7417,24 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.3" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", - "html5ever 0.29.1", "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf", + "plist", "proc-macro2", "quote", "regex", @@ -7098,8 +7445,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -7114,17 +7461,16 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] name = "tauri-winrt-notification" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "quick-xml 0.37.5", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-version", ] @@ -7136,40 +7482,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", + "windows-sys 0.52.0", ] [[package]] name = "tendril" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ "new_debug_unreachable", - "utf-8", -] - -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "smawk", ] [[package]] @@ -7183,11 +7508,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -7198,25 +7523,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -7237,12 +7562,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -7254,15 +7578,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -7279,9 +7603,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -7289,9 +7613,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -7304,9 +7628,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -7321,13 +7645,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -7352,24 +7676,39 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -7403,9 +7742,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -7413,7 +7752,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.4", ] [[package]] @@ -7469,36 +7808,36 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tonic" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", @@ -7528,21 +7867,21 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "tonic-prost" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", "prost", @@ -7551,16 +7890,16 @@ dependencies = [ [[package]] name = "tonic-prost-build" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" dependencies = [ "prettyplease", "proc-macro2", "prost-build", "prost-types", "quote", - "syn 2.0.117", + "syn 2.0.119", "tempfile", "tonic-build", ] @@ -7586,20 +7925,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -7634,7 +7973,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -7647,7 +7986,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7704,9 +8043,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs", @@ -7718,9 +8057,9 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.60.2", ] @@ -7731,7 +8070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", - "nom 8.0.0", + "nom", "petgraph", ] @@ -7741,6 +8080,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.10.2", + "sha1 0.11.0", + "thiserror 2.0.20", +] + [[package]] name = "typeid" version = "1.0.3" @@ -7749,9 +8105,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -7761,7 +8117,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7788,200 +8144,61 @@ checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" [[package]] name = "unic-ucd-ident" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[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.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "uniffi" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" -dependencies = [ - "anyhow", - "camino", - "cargo_metadata", - "clap", - "uniffi_bindgen", - "uniffi_build", - "uniffi_core", - "uniffi_macros", - "uniffi_pipeline", -] - -[[package]] -name = "uniffi_bindgen" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" -dependencies = [ - "anyhow", - "askama", - "camino", - "cargo_metadata", - "fs-err", - "glob", - "goblin", - "heck 0.5.0", - "indexmap 2.14.0", - "once_cell", - "serde", - "tempfile", - "textwrap", - "toml 0.9.12+spec-1.1.0", - "uniffi_internal_macros", - "uniffi_meta", - "uniffi_pipeline", - "uniffi_udl", +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", ] [[package]] -name = "uniffi_build" -version = "0.31.1" +name = "unic-ucd-version" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c39413c43b955e4aa8a4e2b34bbd1b6b5ff6bd85532b52f9eb92fbe88c14458" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" dependencies = [ - "anyhow", - "camino", - "uniffi_bindgen", + "unic-common", ] [[package]] -name = "uniffi_core" -version = "0.31.1" +name = "unicase" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" -dependencies = [ - "anyhow", - "bytes", - "once_cell", - "static_assertions", -] +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] -name = "uniffi_internal_macros" -version = "0.31.1" +name = "unicode-bidi" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] -name = "uniffi_macros" -version = "0.31.1" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" -dependencies = [ - "camino", - "fs-err", - "once_cell", - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "toml 0.9.12+spec-1.1.0", - "uniffi_meta", -] +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "uniffi_meta" -version = "0.31.1" +name = "unicode-normalization" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ - "anyhow", - "siphasher 1.0.2", - "uniffi_internal_macros", - "uniffi_pipeline", + "tinyvec", ] [[package]] -name = "uniffi_pipeline" -version = "0.31.1" +name = "unicode-properties" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "tempfile", - "uniffi_internal_macros", -] +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] -name = "uniffi_udl" -version = "0.31.1" +name = "unicode-segmentation" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" -dependencies = [ - "anyhow", - "textwrap", - "uniffi_meta", - "weedle2", -] +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "universal-hash" @@ -7989,7 +8206,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -8024,18 +8241,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -8050,16 +8255,27 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -8068,9 +8284,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" [[package]] name = "vcpkg" @@ -8080,12 +8296,12 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vergen" -version = "9.1.0" +version = "10.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +checksum = "3fd02b50246a773d6b8f48cf8e4e623a2773fd31cbe697bf10289abbb17e9e87" dependencies = [ "anyhow", - "derive_builder", + "bon", "rustversion", "time", "vergen-lib", @@ -8093,12 +8309,12 @@ dependencies = [ [[package]] name = "vergen-git2" -version = "9.1.0" +version = "10.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d51ab55ddf1188c8d679f349775362b0fa9e90bd7a4ac69838b2a087623f0d57" +checksum = "ab0a5448bcbc376d2420f9780884bc4bcbd5fa73158c78e0942b0dade0599148" dependencies = [ "anyhow", - "derive_builder", + "bon", "git2", "rustversion", "time", @@ -8108,12 +8324,12 @@ dependencies = [ [[package]] name = "vergen-lib" -version = "9.1.0" +version = "10.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +checksum = "7d6a4e23de0fd3f8a940650a4d83eca980e9ef8f108fe6ddeb5313b0313523d3" dependencies = [ "anyhow", - "derive_builder", + "bon", "rustversion", ] @@ -8168,12 +8384,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -8182,20 +8392,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -8206,9 +8407,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -8219,9 +8420,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -8229,9 +8430,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8239,48 +8440,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -8294,23 +8473,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -8321,11 +8488,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "rustix", "wayland-backend", "wayland-scanner", @@ -8333,11 +8500,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -8349,7 +8516,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -8358,12 +8525,12 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.39.2", + "quick-xml", "quote", ] @@ -8378,9 +8545,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -8398,27 +8565,27 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "core-foundation 0.10.1", "jni 0.22.4", "log", "ndk-context", "objc2 0.6.4", + "objc2-app-kit", "objc2-foundation 0.3.2", "url", "web-sys", @@ -8470,18 +8637,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -8508,7 +8675,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8517,20 +8684,11 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-core 0.61.2", ] -[[package]] -name = "weedle2" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "weezl" version = "0.1.12" @@ -8581,7 +8739,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -8716,7 +8874,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8727,7 +8885,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8804,13 +8962,13 @@ dependencies = [ [[package]] name = "windows-service" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "193cae8e647981c35bc947fdd57ba7928b1fa0d4a79305f6dd2dc55221ac35ac" +checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "widestring 1.2.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9169,15 +9327,12 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -9208,7 +9363,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22b4dbcc6c93786cf22e420ef96e8976bfb92a455070282302b74de5848191f4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "getrandom 0.2.17", "ipnet", "libloading 0.8.9", @@ -9219,98 +9374,33 @@ dependencies = [ ] [[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" +name = "wiremock" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", "log", + "once_cell", + "regex", "serde", - "serde_derive", "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "tokio", + "url", ] [[package]] -name = "wit-parser" -version = "0.244.0" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wl-clipboard-rs" @@ -9322,7 +9412,7 @@ dependencies = [ "log", "os_pipe", "rustix", - "thiserror 2.0.18", + "thiserror 2.0.20", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -9330,17 +9420,31 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "futures", + "log", + "serde", + "thiserror 2.0.20", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.54.4" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2 0.6.2", @@ -9369,7 +9473,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", @@ -9380,15 +9484,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" @@ -9433,17 +9528,34 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", +] + +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "getrandom 0.4.3", + "rand_core 0.10.1", "serde", "zeroize", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -9458,15 +9570,15 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zbus" -version = "5.15.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -9478,7 +9590,7 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener 5.4.1", + "event-listener 5.4.2", "futures-core", "futures-lite", "hex", @@ -9491,7 +9603,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.2", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -9499,14 +9611,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.15.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -9514,40 +9626,49 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow 1.0.2", + "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 = "zerocopy" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -9560,35 +9681,21 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -9597,9 +9704,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -9608,26 +9715,35 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] [[package]] name = "zune-jpeg" @@ -9640,41 +9756,42 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", "url", - "winnow 1.0.2", + "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.3.1" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", - "winnow 1.0.2", + "syn 3.0.3", + "winnow 1.0.4", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9cb9ddba0..1abfd744f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,15 +1,22 @@ [workspace] -members = ["cli", "common"] -default-members = [".", "cli"] +members = ["cli", "client-cli", "common", "client-proto", "core", "daemon", "enterprise/posture", "enterprise/provisioning", "enterprise/config-sync", "enterprise/service-locations"] +default-members = ["cli", "client-cli", "daemon", "."] [workspace.dependencies] +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["cargo", "derive", "env"] } -defguard_wireguard_rs = "0.9" +defguard_wireguard_rs = "0.11" dirs-next = "2.0" +log = { version = "0.4", features = ["serde"] } prost = "0.14" reqwest = { version = "0.13", features = ["cookies", "json"] } +secrecy = "0.10" +semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_with = "3.11" +sqlx = { version = "0.8", features = ["chrono", "runtime-tokio", "sqlite", "uuid", "macros"] } thiserror = "2.0" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } tonic = { version = "0.14", default-features = false, features = [ @@ -24,14 +31,31 @@ tonic-prost = "0.14" tonic-prost-build = "0.14" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +webbrowser = "1.0" +tempfile = "3" +futures-util = "0.3" +http = "1" +image = "0.25" +qrcode = { version = "0.14", features = ["image"] } +tokio-tungstenite = { version = "0.30", features = ["native-tls"] } +tokio-util = "0.7" +url = "2" +uuid = { version = "1", features = ["v4"] } +vergen-git2 = { version = "10.0", features = ["build"] } +wiremock = "0.6" +x25519-dalek = { version = "3.0", features = [ + "getrandom", + "serde", + "static_secrets", +] } [workspace.package] authors = ["Defguard"] edition = "2021" homepage = "https://github.com/DefGuard/client" license-file = "../LICENSE.md" -rust-version = "1.87" -version = "1.6.8" +rust-version = "1.95" +version = "2.1.0" [package] name = "defguard-client" @@ -49,43 +73,45 @@ version.workspace = true [[bin]] name = "defguard-client" -[[bin]] -name = "defguard-service" -required-features = ["service"] - [build-dependencies] tauri-build = { version = "2", features = [] } -tonic-prost-build.workspace = true -vergen-git2 = { version = "9.1", features = ["build"] } +vergen-git2.workspace = true + +[workspace.lints.clippy] +uninlined_format_args = "deny" +manual_let_else = "warn" +redundant_closure = "warn" [dependencies] anyhow = "1.0" -base64 = "0.22" -clap.workspace = true -chrono = { version = "0.4", features = ["serde"] } -common = { path = "common" } +base64.workspace = true +chrono.workspace = true +defguard-cli.path = "client-cli" +defguard-client-proto.path = "client-proto" +defguard-client-core.path = "core" +defguard-client-posture.path = "enterprise/posture" +defguard-client-config-sync.path = "enterprise/config-sync" +defguard-client-service-locations.path = "enterprise/service-locations" +defguard-client-provisioning.path = "enterprise/provisioning" +defguard-client-common.path = "common" dark-light = "2.0" defguard_wireguard_rs = { workspace = true, features = ["check_dependencies"] } dirs-next.workspace = true hyper-util = "0.1" -log = { version = "0.4", features = ["serde"] } +log.workspace = true +os_info = { version = "3.14", default-features = false } prost.workspace = true regex = "1.12" reqwest.workspace = true rust-ini = "0.21" -semver = "1.0" +semver.workspace = true serde.workspace = true serde_json.workspace = true -serde_with = "3.11" -sqlx = { version = "0.8", features = [ - "chrono", - "sqlite", - "runtime-tokio", - "uuid", - "macros", -] } -struct-patch = "0.10" +serde_with.workspace = true +sqlx.workspace = true +struct-patch = "0.12" strum = { version = "0.28", features = ["derive"] } +sysinfo = { version = "0.39", default-features = false, features = ["apple-app-store", "system"] } tauri = { version = "2", features = [ "native-tls-vendored", "image-png", @@ -109,27 +135,23 @@ time = { version = "0.3", features = ["formatting", "macros"] } tokio.workspace = true tokio-util = "0.7" tonic.workspace = true +uuid.workspace = true tonic-prost.workspace = true tower = "0.5" tracing.workspace = true tracing-appender = "0.2" tracing-subscriber.workspace = true -webbrowser = "1.0" -x25519-dalek = { version = "2", features = [ - "getrandom", - "serde", - "static_secrets", -] } -os_info = "3.12" +webbrowser.workspace = true +x25519-dalek.workspace = true [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" +dispatch2 = { version = "0.3", optional = true } objc2 = "0.6" +objc2-app-kit = "0.3" objc2-foundation = "0.3" objc2-network-extension = "0.3" - -[target.'cfg(target_os = "macos")'.build-dependencies] -swift-rs = { version = "1.0", features = ["build"] } +objc2-system-extensions = { version = "0.3", optional = true } [target.'cfg(unix)'.dependencies] nix = { version = "0.31", features = ["user", "fs"] } @@ -143,6 +165,9 @@ windows = { version = "0.62", features = [ "Win32", "Win32_System", "Win32_System_RemoteDesktop", + "Win32_Graphics_Gdi", + "Win32_UI_HiDpi", + "Win32_Foundation", ] } windows-acl = "0.3" windows-service = "0.8" @@ -164,13 +189,14 @@ windows-sys = { version = "0.61", features = [ # Network address change notifications (NotifyAddrChange) "Win32_NetworkManagement_IpHelper", ] } +wmi = {version = "0.18", default-features = false} [features] # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. # If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes. # DO NOT REMOVE!! custom-protocol = ["tauri/custom-protocol"] -service = [] +macos_installer = ["dep:dispatch2", "dep:objc2-system-extensions"] [dev-dependencies] tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/Defguard_Client_Mac_App_Store.provisionprofile b/src-tauri/Defguard_Client_Mac_App_Store.provisionprofile deleted file mode 100644 index 7eaff6cd7..000000000 Binary files a/src-tauri/Defguard_Client_Mac_App_Store.provisionprofile and /dev/null differ diff --git a/src-tauri/Defguard_VPNExtension_Mac_App_Store.provisionprofile b/src-tauri/Defguard_VPNExtension_Mac_App_Store.provisionprofile deleted file mode 100644 index c762e5212..000000000 Binary files a/src-tauri/Defguard_VPNExtension_Mac_App_Store.provisionprofile and /dev/null differ diff --git a/src-tauri/Installer.entitlements b/src-tauri/Installer.entitlements new file mode 100644 index 000000000..44b8ff5ca --- /dev/null +++ b/src-tauri/Installer.entitlements @@ -0,0 +1,26 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider-systemextension + + com.apple.developer.system-extension.install + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.application-identifier + 82GZ7KN29J.net.defguard + com.apple.developer.team-identifier + 82GZ7KN29J + com.apple.security.application-groups + + group.net.defguard + + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 91455f0e1..8c57c088d 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,28 +1,24 @@ -use vergen_git2::{Emitter, Git2Builder}; +use vergen_git2::{Emitter, Git2}; fn main() -> Result<(), Box> { + println!("cargo:rerun-if-env-changed=DEFGUARD_CLIENT_BUILD_VERSION"); + + println!("cargo:rerun-if-env-changed=DEFGUARD_CLIENT_DEV"); + println!("cargo::rustc-check-cfg=cfg(defguard_client_dev)"); + if std::env::var("DEFGUARD_CLIENT_DEV").is_ok() { + println!("cargo::rustc-cfg=defguard_client_dev"); + } + // set VERGEN_GIT_SHA env variable based on git commit hash - let git2 = Git2Builder::default().branch(true).sha(true).build()?; + let git2 = Git2::builder().branch(true).sha(true).build(); Emitter::default().add_instructions(&git2)?.emit()?; - tonic_prost_build::configure() - // Enable optional fields. - .protoc_arg("--experimental_allow_proto3_optional") - // Make sure empty DNS is deserialized correctly as `None`. - .type_attribute(".DeviceConfig", "#[serde_as]") - .field_attribute( - ".DeviceConfig.dns", - "#[serde_as(deserialize_as = \"NoneAsEmptyString\")]", - ) - // Make all messages serde-serializable. - .type_attribute(".", "#[derive(serde::Serialize,serde::Deserialize)]") - .compile_protos( - &["proto/client/client.proto", "proto/core/proxy.proto"], - &["proto/client", "proto/core"], - )?; + if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "macos" + && std::env::var("CARGO_FEATURE_MACOS_INSTALLER").is_ok() + { + println!("cargo:rustc-link-lib=framework=SystemExtensions"); + } tauri_build::build(); - - println!("cargo:rerun-if-changed=proto"); Ok(()) } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d8b4de1ba..a7ec2bf51 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,8 +3,13 @@ "identifier": "main-capability", "description": "Capability for the main window", "local": true, - "windows": ["main"], + "remote": { + "urls": ["http://localhost:5072/*"] + }, + "windows": ["compact-view", "full-view", "welcome"], "permissions": [ + "core:webview:allow-create-webview", + "core:webview:allow-create-webview-window", "core:default", "core:window:allow-create", "core:window:allow-center", @@ -38,6 +43,7 @@ "core:window:allow-set-cursor-position", "core:window:allow-set-ignore-cursor-events", "core:window:allow-start-dragging", + "core:window:allow-toggle-maximize", "core:webview:allow-print", "deep-link:default", "fs:default", @@ -49,6 +55,7 @@ "dialog:default", "clipboard-manager:allow-write-text", "process:allow-exit", + "allow-app-commands", { "identifier": "http:default", "allow": [ diff --git a/src-tauri/cli/Cargo.toml b/src-tauri/cli/Cargo.toml index f38c6cf9c..95abcb3a6 100644 --- a/src-tauri/cli/Cargo.toml +++ b/src-tauri/cli/Cargo.toml @@ -12,7 +12,8 @@ tonic-prost-build.workspace = true [dependencies] clap.workspace = true -common = { path = "../common" } +common = { package = "defguard-client-common", path = "../common" } +defguard-client-proto = { path = "../client-proto" } defguard_wireguard_rs = { workspace = true, features = ["check_dependencies"] } dirs-next.workspace = true prost.workspace = true diff --git a/src-tauri/cli/build.rs b/src-tauri/cli/build.rs index fb4ca05e1..a25227de8 100644 --- a/src-tauri/cli/build.rs +++ b/src-tauri/cli/build.rs @@ -10,7 +10,7 @@ fn main() -> Result<(), Box> { ) // Make all messages serde-serializable. .type_attribute(".", "#[derive(serde::Deserialize,serde::Serialize)]") - .compile_protos(&["../proto/core/proxy.proto"], &["../proto/core"])?; + .compile_protos(&["../proto/v1/core/proxy.proto"], &["../proto"])?; Ok(()) } diff --git a/src-tauri/cli/src/bin/dg.rs b/src-tauri/cli/src/bin/dg.rs index 13c20b7e8..7e60116a2 100644 --- a/src-tauri/cli/src/bin/dg.rs +++ b/src-tauri/cli/src/bin/dg.rs @@ -11,6 +11,7 @@ use std::{ use clap::{builder::FalseyValueParser, command, value_parser, Arg, Command}; use common::{dns_borrow, find_free_tcp_port, get_interface_name}; +use defguard_client_proto::conversions::normalize_allowed_ips; #[cfg(not(target_os = "macos"))] use defguard_wireguard_rs::Kernel; #[cfg(target_os = "macos")] @@ -19,6 +20,10 @@ use defguard_wireguard_rs::{ error::WireguardInterfaceError, key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, WGApi, WireguardInterfaceApi, }; +use proto::defguard::client_types::{ + Device, DeviceConfig, DeviceConfigResponse, EnrollmentStartRequest, EnrollmentStartResponse, + InstanceInfo, InstanceInfoRequest, InstanceInfoResponse, NewDevice, +}; use reqwest::{Client, StatusCode, Url}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -31,15 +36,37 @@ use tracing::{debug, error, info, level_filters::LevelFilter, trace, warn}; use tracing_subscriber::EnvFilter; mod proto { - include!(concat!(env!("OUT_DIR"), "/defguard.proxy.rs")); + pub mod defguard { + pub mod client_types { + include!(concat!(env!("OUT_DIR"), "/defguard.client_types.rs")); + } + + #[allow(dead_code)] + pub mod enterprise { + pub mod posture { + pub mod v2 { + include!(concat!( + env!("OUT_DIR"), + "/defguard.enterprise.posture.v2.rs" + )); + } + } + } + + pub mod proxy { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/defguard.proxy.v1.rs")); + } + } + } } #[derive(Clone, Default, Deserialize, Serialize)] struct CliConfig { private_key: Key, - device: proto::Device, - device_config: proto::DeviceConfig, - instance_info: proto::InstanceInfo, + device: Device, + device_config: DeviceConfig, + instance_info: InstanceInfo, // polling token used for further client-core communication token: Option, } @@ -217,7 +244,7 @@ async fn connect(config: CliConfig, ifname: String, trigger: Arc) -> Res .collect::>(); debug!("Parsed assigned IPs: {addresses:?}"); - let config = InterfaceConfiguration { + let mut config = InterfaceConfiguration { name: config.instance_info.name.clone(), prvkey: config.private_key.to_string(), addresses, @@ -226,6 +253,7 @@ async fn connect(config: CliConfig, ifname: String, trigger: Arc) -> Res mtu: None, fwmark: None, }; + normalize_allowed_ips(&mut config); let configure_interface_result = wgapi.configure_interface(&config); configure_interface_result.expect("Failed to configure WireGuard interface"); @@ -283,11 +311,11 @@ async fn enroll(base_url: &Url, token: String) -> Result { url.set_path("/api/v1/enrollment/start"); let result = client .post(url) - .json(&proto::EnrollmentStartRequest { token }) + .json(&EnrollmentStartRequest { token }) .send() .await?; - let response: proto::EnrollmentStartResponse = if result.status() == StatusCode::OK { + let response: EnrollmentStartResponse = if result.status() == StatusCode::OK { let result = result.json().await?; debug!( "Enrollment start request has been successfully sent to Defguard Proxy. Received a \ @@ -314,7 +342,7 @@ async fn enroll(base_url: &Url, token: String) -> Result { url.set_path("/api/v1/enrollment/create_device"); let result = client .post(url) - .json(&proto::NewDevice { + .json(&NewDevice { // The name is ignored by the server as it's set by the user before the enrollment. name: String::new(), pubkey: pubkey.to_string(), @@ -323,7 +351,7 @@ async fn enroll(base_url: &Url, token: String) -> Result { .send() .await?; - let response: proto::DeviceConfigResponse = if result.status() == StatusCode::OK { + let response: DeviceConfigResponse = if result.status() == StatusCode::OK { let result = result.json().await?; debug!( "The device public key has been successfully sent to Defguard Proxy. The device should \ @@ -367,19 +395,15 @@ const INTERVAL_SECONDS: Duration = Duration::from_secs(30); const HTTP_REQ_TIMEOUT: Duration = Duration::from_secs(5); /// Fetch configuration from Defguard proxy. -async fn fetch_config( - client: &Client, - url: Url, - token: String, -) -> Result { +async fn fetch_config(client: &Client, url: Url, token: String) -> Result { let result = client .post(url.clone()) - .json(&proto::InstanceInfoRequest { token }) + .json(&InstanceInfoRequest { token }) .timeout(HTTP_REQ_TIMEOUT) .send() .await?; - let instance_response: proto::InstanceInfoResponse = if result.status() == StatusCode::OK { + let instance_response: InstanceInfoResponse = if result.status() == StatusCode::OK { result.json().await? } else if result.status() == StatusCode::PAYMENT_REQUIRED { return Err(CliError::EnterpriseDisabled); @@ -489,6 +513,7 @@ async fn wait_for_hangup() { hangup.recv().await; } } + /// Dummy version of the above function for non-UNIX systems. #[cfg(not(unix))] async fn wait_for_hangup() { @@ -536,12 +561,14 @@ async fn main() { .value_name("URL") .value_parser(value_parser!(Url)); + // Handle --version / -V before clap parsing. + common::check_version_flag("dg"); + let matches = command!() .arg(config_opt) .arg(debug_opt) .arg(verbose_opt) .arg_required_else_help(false) - .propagate_version(true) .subcommand_required(false) .subcommand( Command::new("enroll") diff --git a/src-tauri/client-cli/Cargo.toml b/src-tauri/client-cli/Cargo.toml new file mode 100644 index 000000000..52a0e0c2a --- /dev/null +++ b/src-tauri/client-cli/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "defguard-cli" +description = "Command-line client for Defguard VPN - connect, disconnect, status" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license-file = "../../LICENSE.md" +rust-version.workspace = true +version.workspace = true + +[dependencies] +chrono.workspace = true +clap = { workspace = true, features = ["cargo", "derive", "env"] } +owo-colors = { version = "4", features = ["supports-colors"] } + +common = { package = "defguard-client-common", path = "../common" } +defguard_core = { package = "defguard-client-core", path = "../core" } +defguard_client_config_sync = { package = "defguard-client-config-sync", path = "../enterprise/config-sync" } +defguard_client_posture = { package = "defguard-client-posture", path = "../enterprise/posture" } +defguard_client_proto = { package = "defguard-client-proto", path = "../client-proto" } +base64.workspace = true +reqwest.workspace = true +secrecy.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +tracing-subscriber = { workspace = true, features = ["env-filter"] } +url.workspace = true +webbrowser.workspace = true +http.workspace = true +qrcode.workspace = true +image.workspace = true +tokio-tungstenite.workspace = true +futures-util.workspace = true + +[dev-dependencies] +sha1 = "0.11" +tempfile.workspace = true +tokio-stream = "0.1" +tonic.workspace = true diff --git a/src-tauri/client-cli/assets/logo-color.ansi b/src-tauri/client-cli/assets/logo-color.ansi new file mode 100644 index 000000000..554ca7dad --- /dev/null +++ b/src-tauri/client-cli/assets/logo-color.ansi @@ -0,0 +1,9 @@ + ▋▌  + ▂▀▂▃▆▃▋▌ ▅ ▅▂▅ ▊▖ +▕▅ ▆▖▌ ▁▁▁▁▕▊ ▁▁▁ ▁▏▁▁ ▁▁▁▅▅ ▁ ▁ ▁▁▁ ▁▁▁▁ ▁▁▁ ▊▋ +▕ ▃▅▆▀▂ ▘▀▅▅▅▚▊ ▀▗▆▆▅▚▖ ▅▎▅▅ ▗▗▆▆▆▚ ▊▋ ▎▎ ▗▗▆▆▅▝ ▊▕▅▅▅ ▆▀▅▅▅▗▋ +▏▗▆ ▆▖▆▖ ▊▊ ▊▊ ▊▕▀▀▀▀▘▋ ▏ ▋▋ ▎▎ ▊▋ ▎▎ ▀▀▀▀▘▎ ▊▕ ▊▋ ▋▋ +▕▆▀▆▅▃▅▌▌ ▊▊ ▊▊ ▏ ▗▖ ▏ ▗▗▀▀▅▆ ▊▋ ▎▎ ▏▘ ▍▎ ▊▕ ▊▋ ▌▋ +▕▁ ▌▌ ▝▂▆▅▂▅▂ ▝▃▆▀▂▀ ▂ ▃▃▀▀▀▀▆ ▀▂▅▆▀▂ ▝▂▀▀▂▂▎ ▊▂ ▀▂▅▅▂▅▘ +▆▀▂▅▀▆▃▖▌ ▖▃▂▂▂▂▚  + ▆ ▗▚▘ ▔▔▔▔▔  diff --git a/src-tauri/client-cli/assets/logo-mono.txt b/src-tauri/client-cli/assets/logo-mono.txt new file mode 100644 index 000000000..6dd8630a6 --- /dev/null +++ b/src-tauri/client-cli/assets/logo-mono.txt @@ -0,0 +1,7 @@ + ▁▂▁ ▐ +▄▆▀▔▀▆▟ ▕▉ ▕█▀▘ ▁▂▁ ▕▉ +▊▁▂▄▂▁▝ ▁▅▆▀▀▅▉ ▗▅▀▀▆▃▕▕█▆ ▕▅▀▀▜▌▔▗▖ ▆ ▗▆▀▀▆▖ ▗▆▘▘ ▅▇▀▀▅▉ +██▔▔▔█▆ ▕▉ ▕▉ █▙▄▄▄█ ▕█ ▝▉▁▁▟▊ ▐▌ █▎▔▄▅▅▅▉ ▐▉ ▐▉ ▐▉ +▊▔▀▀▀▔▐ ▕█▂▁▁▟▉ ▀▙▁▁▂▅ ▕█ ▕█▃▃▃▂▁▐▙▁▁▃█▎▐▋▁▁▟▉ ▐▉ ▝█▂▁▁▟▉ +▀▅▃▁▃▅█ ▔▀▀▀▔▔ ▔▔▀▔▔ ▕▔ ▟▍▔▔▔▐▌ ▔▀▀▔▔ ▔▔▀▀▔▔ ▔▔ ▔▀▀▔▔▔ + ▔▔▀▔▃█ ▔▀▀▀▀▔▔ diff --git a/src-tauri/client-cli/src/brand.rs b/src-tauri/client-cli/src/brand.rs new file mode 100644 index 000000000..586e831f4 --- /dev/null +++ b/src-tauri/client-cli/src/brand.rs @@ -0,0 +1,69 @@ +//! Defguard CLI brand banner -- logo + copyright + version line. +//! +//! Shown when invoked with no arguments or with `--help`. +//! Suppressed for `--version` (which must stay grep-friendly). +//! +//! The logo is emitted on non-Windows platforms only. Its art uses +//! fine-grained Unicode block glyphs (eighths/quadrant blocks) that many +//! Windows console fonts can't render, so Windows shows just the +//! copyright + version line. +//! +//! Two assets (non-Windows): +//! - assets/logo-color.ansi -- ANSI block-character art, +//! used when stdout is an interactive TTY +//! - assets/logo-mono.txt -- plain ASCII fallback (no ANSI), +//! used when output is piped/redirected or NO_COLOR is set +//! +//! Both assets are embedded at compile time via `include_str!`. + +#[cfg(not(windows))] +use owo_colors::{OwoColorize, Stream}; + +#[cfg(not(windows))] +const LOGO_COLOR: &str = include_str!("../assets/logo-color.ansi"); +#[cfg(not(windows))] +const LOGO_MONO: &str = include_str!("../assets/logo-mono.txt"); + +const COPYRIGHT: &str = "Copyright (C) 2026 Defguard Sp. z o.o."; + +/// Print logo + copyright + project name/version to stdout. Picks the +/// colored logo variant on an interactive TTY and the mono fallback +/// otherwise (so `defguard-client --help | cat` stays clean ASCII). +#[cfg(not(windows))] +pub fn print_banner() { + // owo-colors' supports-colors detection drives the choice: if + // stdout supports color, emit the ANSI variant; otherwise mono. + // We do not feed the logo through if_supports_color directly -- + // it carries its own ANSI escapes -- we just gate which string + // we emit. NO_COLOR / CLICOLOR_FORCE propagate via the + // supports_color() helper. + let use_color = "x" + .if_supports_color(Stream::Stdout, |s| s.red()) + .to_string() + != "x"; + + let logo = if use_color { LOGO_COLOR } else { LOGO_MONO }; + println!("{logo}"); + + let project = common::version_string("defguard-client"); + if use_color { + println!(" {}", project.bright_yellow().bold()); + println!(" {}", COPYRIGHT.dimmed()); + } else { + println!(" {project}"); + println!(" {COPYRIGHT}"); + } + println!(); +} + +/// Print copyright + project name/version to stdout. The logo is skipped +/// on Windows: its art relies on Unicode block glyphs that many Windows +/// console fonts can't render, and the console may not interpret ANSI. +#[cfg(windows)] +pub fn print_banner() { + let project = common::version_string("defguard-client"); + println!(); + println!(" {project}"); + println!(" {COPYRIGHT}"); + println!(); +} diff --git a/src-tauri/client-cli/src/cli.rs b/src-tauri/client-cli/src/cli.rs new file mode 100644 index 000000000..7ac1dc664 --- /dev/null +++ b/src-tauri/client-cli/src/cli.rs @@ -0,0 +1,165 @@ +use clap::{Parser, Subcommand}; + +/// Command-line client for the Defguard VPN. +/// +/// Shares the same database as the desktop client. +#[derive(Parser)] +#[command(name = "defguard-client", version, about)] +pub struct Cli { + /// Output machine-readable JSON instead of human-readable tables. + #[arg(long, global = true)] + pub json: bool, + + /// Increase log verbosity (staged: -v INFO, -vv DEBUG, -vvv TRACE). + /// Diagnostics go to stderr. Honors DG_LOG / RUST_LOG env. + #[arg(short = 'v', long, action = clap::ArgAction::Count, global = true)] + pub verbose: u8, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// List all configured instances, locations, and tunnels. + List, + + /// Show currently-active VPN connections (live state from the daemon). + #[command(alias = "s")] + Status, + + /// Connect to a location or tunnel. + #[command(alias = "c")] + Connect { + /// Location or tunnel name. If omitted, connects to the sole configured + /// location (error if ambiguous). + name: Option, + + /// Connect to a tunnel instead of a location. + #[arg(long)] + tunnel: bool, + + /// Target by id (fast path, skips name resolution). + #[arg(long)] + id: Option, + + /// Instance name qualifier when the same location name exists in multiple + /// instances. + #[arg(long)] + instance: Option, + + /// MFA authentication code (TOTP / email). + #[arg(long)] + code: Option, + + /// Shell command that prints the MFA code to stdout. Receives + /// DG_INSTANCE and DG_LOCATION in its environment. + #[arg(long)] + code_command: Option, + + /// Override the persisted MFA method. + #[arg(long)] + mfa_method: Option, + + /// Save the mobile-approve MFA QR code as a PNG image to this path. + /// Required when stderr is not a terminal. + #[arg(long)] + qr_file: Option, + + /// Override route-all-traffic for this connection only. + #[arg(long, overrides_with = "predefined_traffic")] + all_traffic: bool, + + /// Do not route all traffic (overrides location default). + #[arg(long, overrides_with = "all_traffic")] + predefined_traffic: bool, + }, + + /// Disconnect from a location or tunnel. + #[command(alias = "d")] + Disconnect { + /// Location or tunnel name. If omitted, disconnects the sole active + /// connection (error if ambiguous). + name: Option, + + /// Disconnect a tunnel instead of a location. + #[arg(long)] + tunnel: bool, + + /// Target by id. + #[arg(long)] + id: Option, + + /// Instance name qualifier. + #[arg(long)] + instance: Option, + + /// Disconnect all active connections. + #[arg(long)] + all: bool, + }, + + /// Manage locations (view settings, set MFA method, routing). + #[command(subcommand, alias = "l")] + Location(LocationCommand), + + /// Manage instances. + #[command(subcommand, alias = "i")] + Instance(InstanceCommand), + + /// Manage imported WireGuard tunnels. + #[command(subcommand, alias = "t")] + Tunnel(TunnelCommand), +} + +#[derive(Subcommand)] +pub enum LocationCommand { + /// List all locations. + List, + + /// Show details for a location. + Show { + name: String, + + #[arg(long)] + instance: Option, + }, + + /// Persist a per-location preference. + Set { + name: String, + + #[arg(long)] + instance: Option, + + /// Override the MFA method (totp, email, oidc, mobile). + #[arg(long)] + mfa_method: Option, + + /// Always route all traffic through this location. + #[arg(long, overrides_with = "predefined_traffic")] + route_all_traffic: bool, + + /// Never route all traffic through this location. + #[arg(long, overrides_with = "route_all_traffic")] + predefined_traffic: bool, + }, +} + +#[derive(Subcommand)] +pub enum InstanceCommand { + /// List all enrolled instances. + List, + + /// Show details for an instance. + Show { name: String }, +} + +#[derive(Subcommand)] +pub enum TunnelCommand { + /// List all imported tunnels. + List, + + /// Show details for a tunnel. + Show { name: String }, +} diff --git a/src-tauri/client-cli/src/commands/connect.rs b/src-tauri/client-cli/src/commands/connect.rs new file mode 100644 index 000000000..beac5fd40 --- /dev/null +++ b/src-tauri/client-cli/src/commands/connect.rs @@ -0,0 +1,272 @@ +use std::io::{stderr, stdin, IsTerminal}; + +use defguard_client_posture::{authorize_posture_session, get_posture_data}; +use defguard_client_proto::defguard::client_types::MfaMethod; +use defguard_core::{ + connection::{active_state::active_state, bring_up, ConnectionTarget}, + database::models::{instance::Instance, Id}, + ConnectionType, +}; +use secrecy::ExposeSecret; +use serde_json::{json, Value}; +use tracing::info; + +use crate::{ + mfa, + mfa_code::CodeSource, + output::CommandOutput, + resolve::{resolve_connect_target, ResolvedTarget, TargetSpec}, + state::{CliError, State}, +}; + +#[allow(clippy::too_many_arguments)] +pub async fn handle( + state: &State, + name: Option<&str>, + tunnel: bool, + id: Option, + instance: Option<&str>, + code: Option<&str>, + code_command: Option<&str>, + mfa_method: Option<&str>, + qr_file: Option<&str>, + all_traffic: bool, + predefined_traffic: bool, + json: bool, +) -> Result { + // Per-call routing override: --all-traffic = true, --predefined-traffic = false, + // neither = None (use the location/tunnel default). + let routing_override: Option = if all_traffic { + Some(true) + } else if predefined_traffic { + Some(false) + } else { + None + }; + + let spec = TargetSpec { + name: name.map(String::from), + tunnel, + id, + instance: instance.map(String::from), + }; + + let target = resolve_connect_target(&spec, &state.pool).await?; + + if matches!(&target, ResolvedTarget::Tunnel(_)) { + Instance::ensure_tunnels_enabled(&state.pool).await?; + } + + // Idempotency: if the target is already connected, report and exit 0. + let (target_id, target_connection_type, target_name) = match &target { + ResolvedTarget::Location(loc) => (loc.id, ConnectionType::Location, loc.name.as_str()), + ResolvedTarget::Tunnel(tun) => (tun.id, ConnectionType::Tunnel, tun.name.as_str()), + }; + let active: Vec<(Id, ConnectionType)> = active_state(&state.pool) + .await? + .iter() + .map(|c| (c.target_id, c.connection_type)) + .collect(); + if active.contains(&(target_id, target_connection_type)) { + return Ok(ConnectResult::AlreadyConnected { + name: target_name.to_string(), + }); + } + + let (target_name, psk, mtu) = match &target { + ResolvedTarget::Location(location) => { + if location.mfa_enabled() { + // Resolve the effective MFA method. + let method = mfa::resolve_method(location, mfa_method)?; + + // Reject flags that are incompatible with the resolved method. + mfa::validate_mfa_flags(method, &location.name, code, code_command, qr_file)?; + + let instance = Instance::find_by_id(&state.pool, location.instance_id) + .await + .map_err(|e| CliError::Other(format!("Failed to load instance: {e}")))? + .ok_or_else(|| { + CliError::Other(format!("Instance {} not found", location.instance_id)) + })?; + + // When posture is also required, collect posture data and pass it + // into the MFA start request so the server can validate both together. + let posture_data = if location.posture_check_required { + Some( + get_posture_data() + .await + .map_err(|e| CliError::Other(e.to_string()))?, + ) + } else { + None + }; + + let psk = if method == MfaMethod::Oidc { + mfa::authorize_oidc(location, &instance, posture_data, &state.pool, json) + .await? + } else if method == MfaMethod::MobileApprove { + // Fail-fast: if neither stderr is a TTY nor --qr-file is set, + // the user cannot scan the QR. Do not call /start. + if !stderr().is_terminal() && qr_file.is_none() { + return Err(CliError::InvalidInput( + "No QR display available (stderr is not a TTY). \ + Use --qr-file to save the QR as a PNG image." + .into(), + )); + } + mfa::authorize_mobile_approve( + location, + &instance, + posture_data, + qr_file, + &state.pool, + json, + ) + .await? + } else { + // Determine the MFA code source from CLI flags. + let code_source = code + .map(|c| CodeSource::Literal(c.to_string())) + .or_else(|| code_command.map(|cmd| CodeSource::Command(cmd.to_string()))); + + let source = if let Some(code_source) = code_source { + code_source + } else if stdin().is_terminal() { + CodeSource::Interactive + } else { + return Err(CliError::MfaInputRequired(format!( + "Location '{}' requires MFA but no --code, --code-command, or TTY is available.", + location.name + ))); + }; + + mfa::authorize( + location, + &source, + &instance, + method, + posture_data, + &state.pool, + ) + .await? + }; + ( + location.name.clone(), + Some(psk.expose_secret().to_string()), + state.app_config.mtu(), + ) + } else if location.posture_check_required { + // Posture only (no MFA). + let psk = authorize_posture_session(location) + .await + .map_err(|e| CliError::Other(e.to_string()))?; + (location.name.clone(), psk, state.app_config.mtu()) + } else { + (location.name.clone(), None, state.app_config.mtu()) + } + } + ResolvedTarget::Tunnel(tun) => ( + tun.name.clone(), + tun.preshared_key.clone(), + state.app_config.mtu(), + ), + }; + + info!("Connecting to {target_name}..."); + let conn_target = match target { + ResolvedTarget::Location(loc) => ConnectionTarget::Location(loc), + ResolvedTarget::Tunnel(tun) => ConnectionTarget::Tunnel(tun), + }; + + bring_up(conn_target, psk, mtu, &state.pool, routing_override).await?; + + Ok(ConnectResult::Connected { name: target_name }) +} + +pub enum ConnectResult { + /// A new connection was established. + Connected { name: String }, + /// The target was already connected (idempotent). + AlreadyConnected { name: String }, +} + +impl CommandOutput for ConnectResult { + fn human(&self) -> String { + match self { + ConnectResult::Connected { name } => format!("Connected to {name}"), + ConnectResult::AlreadyConnected { name } => { + format!("Already connected to {name}") + } + } + } + + fn json(&self) -> Value { + match self { + ConnectResult::Connected { name } => json!({ + "connected": name, + }), + ConnectResult::AlreadyConnected { name } => json!({ + "connected": name, + "already": true, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_connected_human() { + let result = ConnectResult::Connected { + name: "office".to_string(), + }; + assert_eq!(result.human(), "Connected to office"); + } + + #[test] + fn test_already_connected_human() { + let result = ConnectResult::AlreadyConnected { + name: "office".to_string(), + }; + assert_eq!(result.human(), "Already connected to office"); + } + + #[test] + fn test_connected_json() { + let result = ConnectResult::Connected { + name: "office".to_string(), + }; + let json = result.json(); + assert_eq!(json["connected"], "office"); + assert!(json["already"].is_null()); + } + + #[test] + fn test_already_connected_json() { + let result = ConnectResult::AlreadyConnected { + name: "office".to_string(), + }; + let json = result.json(); + assert_eq!(json["connected"], "office"); + assert_eq!(json["already"], true); + } + + #[test] + fn test_json_no_message_field() { + let result = ConnectResult::Connected { + name: "office".to_string(), + }; + let json = result.json(); + assert!(json["message"].is_null()); + } + + #[test] + fn test_exit_code_zero() { + let result = ConnectResult::Connected { + name: "office".to_string(), + }; + assert_eq!(result.exit_code(), 0); + } +} diff --git a/src-tauri/client-cli/src/commands/disconnect.rs b/src-tauri/client-cli/src/commands/disconnect.rs new file mode 100644 index 000000000..d323caccd --- /dev/null +++ b/src-tauri/client-cli/src/commands/disconnect.rs @@ -0,0 +1,293 @@ +use defguard_core::{ + connection::{active_state::active_state, tear_down}, + ConnectionType, +}; +use serde_json::{json, Value}; +use tracing::{error, info}; + +use crate::{ + output::CommandOutput, + resolve::{resolve_disconnect_target, ResolvedTarget, TargetSpec}, + state::{CliError, State}, +}; + +pub async fn handle( + state: &State, + name: Option<&str>, + tunnel: bool, + id: Option, + instance: Option<&str>, + all: bool, +) -> Result { + if all { + // Disconnect all currently-active connections. + let active = active_state(&state.pool).await?; + + if active.is_empty() { + return Ok(DisconnectResult::NoneActive); + } + + let mut disconnected = Vec::with_capacity(active.len()); + let mut errors = Vec::new(); + + for connection in &active { + let name = connection.name.clone(); + info!( + "Disconnecting {name} on interface {}...", + connection.interface_name + ); + let result = tear_down(connection).await; + match result { + Ok(()) => { + info!("Disconnected {name} ({})", connection.interface_name); + disconnected.push(name); + } + Err(e) => { + let msg = format!("Failed to disconnect {name}: {e}"); + error!("{msg}"); + errors.push(msg); + } + } + } + + Ok(DisconnectResult::All { + disconnected, + errors, + }) + } else { + // No-arg disconnect: if exactly one connection is active, disconnect it. + if name.is_none() && !tunnel && id.is_none() && instance.is_none() { + let active = active_state(&state.pool).await?; + + match active.len() { + 0 => { + return Ok(DisconnectResult::NoneActive); + } + 1 => { + let connection = &active[0]; + let ifname = connection.interface_name.clone(); + let name = connection.name.clone(); + info!("Disconnecting sole active connection {name} on interface {ifname}..."); + + tear_down(connection).await?; + + return Ok(DisconnectResult::Single { + name, + interface: ifname, + }); + } + _ => { + let names = active.iter().map(|c| c.name.as_str()).collect::>(); + return Err(CliError::Usage(format!( + "Multiple active connections ({}). Specify which to disconnect, --all to \ + disconnect all.", + names.join(", ") + ))); + } + } + } + + let spec = TargetSpec { + name: name.map(String::from), + tunnel, + id, + instance: instance.map(String::from), + }; + + let target = resolve_disconnect_target(&spec, &state.pool).await?; + + let (target_id, target_connection_type, target_name) = match &target { + ResolvedTarget::Location(loc) => (loc.id, ConnectionType::Location, loc.name.clone()), + ResolvedTarget::Tunnel(tun) => (tun.id, ConnectionType::Tunnel, tun.name.clone()), + }; + + // Look up the actual interface name from active_state. + let active = active_state(&state.pool).await?; + + let connection = active + .iter() + .find(|c| c.connection_type == target_connection_type && c.target_id == target_id) + .ok_or_else(|| { + CliError::NotFound(format!("'{target_name}' is not currently connected")) + })?; + + let ifname = connection.interface_name.clone(); + + info!("Disconnecting {target_name} on interface {ifname}..."); + + tear_down(connection).await?; + + Ok(DisconnectResult::Single { + name: target_name, + interface: ifname, + }) + } +} + +pub enum DisconnectResult { + Single { + name: String, + interface: String, + }, + All { + disconnected: Vec, + errors: Vec, + }, + NoneActive, +} + +impl CommandOutput for DisconnectResult { + fn human(&self) -> String { + match self { + DisconnectResult::Single { name, interface } => { + format!("Disconnected from {name} ({interface})") + } + DisconnectResult::All { + disconnected, + errors, + } => { + let mut parts = Vec::new(); + if !disconnected.is_empty() { + parts.push(format!("disconnected: {}", disconnected.join(", "))); + } + if !errors.is_empty() { + parts.push(format!("errors: {}", errors.join(", "))); + } + if parts.is_empty() { + "No active connections.".to_string() + } else { + parts.join("\n") + } + } + DisconnectResult::NoneActive => "No active connections.".to_string(), + } + } + + fn json(&self) -> Value { + match self { + DisconnectResult::Single { name, interface } => json!({ + "disconnected": name, + "interface": interface, + }), + DisconnectResult::All { + disconnected, + errors, + } => json!({ + "disconnected": disconnected, + "errors": errors, + }), + DisconnectResult::NoneActive => json!({}), + } + } + + fn exit_code(&self) -> u8 { + match self { + DisconnectResult::All { errors, .. } if !errors.is_empty() => 1, + _ => 0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_single_human() { + let result = DisconnectResult::Single { + name: "office".to_string(), + interface: "wg0".to_string(), + }; + assert_eq!(result.human(), "Disconnected from office (wg0)"); + } + + #[test] + fn test_none_active_human() { + let result = DisconnectResult::NoneActive; + assert_eq!(result.human(), "No active connections."); + } + + #[test] + fn test_all_success_human() { + let result = DisconnectResult::All { + disconnected: vec!["office".to_string(), "home".to_string()], + errors: Vec::new(), + }; + assert_eq!(result.human(), "disconnected: office, home"); + } + + #[test] + fn test_all_with_errors_human() { + let result = DisconnectResult::All { + disconnected: vec!["office".to_string()], + errors: vec!["Failed to disconnect home: timeout".to_string()], + }; + let s = result.human(); + assert!(s.contains("disconnected: office")); + assert!(s.contains("errors: Failed to disconnect home: timeout")); + } + + #[test] + fn test_single_json() { + let result = DisconnectResult::Single { + name: "office".to_string(), + interface: "wg0".to_string(), + }; + let json = result.json(); + assert_eq!(json["disconnected"], "office"); + assert_eq!(json["interface"], "wg0"); + assert!(json["message"].is_null()); + } + + #[test] + fn test_all_json() { + let result = DisconnectResult::All { + disconnected: vec!["office".to_string()], + errors: vec!["err".to_string()], + }; + let json = result.json(); + assert_eq!(json["disconnected"].as_array().unwrap().len(), 1); + assert_eq!(json["errors"].as_array().unwrap().len(), 1); + assert!(json["message"].is_null()); + } + + #[test] + fn test_none_active_json() { + let result = DisconnectResult::NoneActive; + let json = result.json(); + assert_eq!(json, serde_json::json!({})); + } + + #[test] + fn test_exit_code_zero_on_success() { + assert_eq!( + DisconnectResult::Single { + name: "x".to_string(), + interface: "y".to_string(), + } + .exit_code(), + 0 + ); + assert_eq!( + DisconnectResult::All { + disconnected: vec!["x".to_string()], + errors: Vec::new(), + } + .exit_code(), + 0 + ); + assert_eq!(DisconnectResult::NoneActive.exit_code(), 0); + } + + #[test] + fn test_exit_code_one_on_partial_failure() { + assert_eq!( + DisconnectResult::All { + disconnected: vec!["x".to_string()], + errors: vec!["e".to_string()], + } + .exit_code(), + 1 + ); + } +} diff --git a/src-tauri/client-cli/src/commands/instance.rs b/src-tauri/client-cli/src/commands/instance.rs new file mode 100644 index 000000000..7abe6c048 --- /dev/null +++ b/src-tauri/client-cli/src/commands/instance.rs @@ -0,0 +1,198 @@ +use defguard_core::database::models::{instance::Instance, Id}; +use serde_json::{json, Value}; + +use crate::{ + output::CommandOutput, + state::{CliError, State}, +}; + +const MIN_NAME_COL_WIDTH: usize = 4; +const MIN_URL_COL_WIDTH: usize = 3; +const MIN_USER_COL_WIDTH: usize = 8; + +pub async fn handle_list(state: &State) -> Result { + let instances = Instance::all(&state.pool).await?; + Ok(InstanceListResult { instances }) +} + +pub async fn handle_show(state: &State, name: &str) -> Result { + let instance = Instance::find_by_name(&state.pool, name) + .await? + .ok_or_else(|| CliError::NotFound(format!("Instance '{name}' not found")))?; + Ok(InstanceShowResult { instance }) +} + +pub struct InstanceListResult { + pub instances: Vec>, +} + +impl CommandOutput for InstanceListResult { + fn human(&self) -> String { + if self.instances.is_empty() { + "No instances configured. Use the desktop app to enroll first.".to_string() + } else { + format_instance_list_table(&self.instances) + } + } + + fn json(&self) -> Value { + let instances = self + .instances + .iter() + .map(|inst| { + json!({ + "name": inst.name, + "url": inst.url, + "username": inst.username, + "traffic_policy": format!("{:?}", inst.client_traffic_policy), + }) + }) + .collect::>(); + json!({ "instances": instances }) + } +} + +fn format_instance_list_table(instances: &[Instance]) -> String { + let name_col_width = instances + .iter() + .map(|i| i.name.len()) + .max() + .unwrap_or(MIN_NAME_COL_WIDTH) + .max(MIN_NAME_COL_WIDTH); + let url_col_width = instances + .iter() + .map(|i| i.url.len()) + .max() + .unwrap_or(MIN_URL_COL_WIDTH) + .max(MIN_URL_COL_WIDTH); + let user_col_width = instances + .iter() + .map(|i| i.username.len()) + .max() + .unwrap_or(MIN_USER_COL_WIDTH) + .max(MIN_USER_COL_WIDTH); + + let mut lines = vec![format!( + " {:, +} + +impl CommandOutput for InstanceShowResult { + fn human(&self) -> String { + let mut lines = Vec::new(); + lines.push(format!("Name: {}", self.instance.name)); + lines.push(format!("UUID: {}", self.instance.uuid)); + lines.push(format!("URL: {}", self.instance.url)); + lines.push(format!("Proxy URL: {}", self.instance.proxy_url)); + lines.push(format!("Username: {}", self.instance.username)); + lines.push(format!( + "Traffic policy: {:?}", + self.instance.client_traffic_policy + )); + if let Some(ref display_name) = self.instance.openid_display_name { + lines.push(format!("OIDC display: {display_name}")); + } + lines.join("\n") + } + + fn json(&self) -> Value { + json!({ + "name": self.instance.name, + "uuid": self.instance.uuid, + "url": self.instance.url, + "proxy_url": self.instance.proxy_url, + "username": self.instance.username, + "traffic_policy": format!("{:?}", self.instance.client_traffic_policy), + "openid_display_name": self.instance.openid_display_name, + }) + } +} + +#[cfg(test)] +mod tests { + use defguard_core::database::models::instance::ClientTrafficPolicy; + + use super::*; + + fn make_instance(name: &str) -> Instance { + Instance { + id: 1, + name: name.to_string(), + uuid: "uuid-1".to_string(), + url: "https://vpn.example.com".to_string(), + proxy_url: "https://proxy.example.com".to_string(), + username: "admin".to_string(), + token: None, + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: false, + disable_tunnels: false, + openid_display_name: None, + } + } + + #[test] + fn test_list_human_empty() { + let result = InstanceListResult { + instances: Vec::new(), + }; + assert_eq!( + result.human(), + "No instances configured. Use the desktop app to enroll first." + ); + } + + #[test] + fn test_list_human_with_data() { + let result = InstanceListResult { + instances: vec![make_instance("acme")], + }; + let s = result.human(); + assert!(s.contains("acme")); + assert!(s.contains("https://vpn.example.com")); + assert!(s.contains("admin")); + } + + #[test] + fn test_show_human() { + let result = InstanceShowResult { + instance: make_instance("acme"), + }; + let s = result.human(); + assert!(s.contains("Name: acme")); + assert!(s.contains("URL: https://vpn.example.com")); + assert!(s.contains("Username: admin")); + } + + #[test] + fn test_exit_code_zero() { + assert_eq!( + InstanceListResult { + instances: Vec::new() + } + .exit_code(), + 0 + ); + assert_eq!( + InstanceShowResult { + instance: make_instance("x"), + } + .exit_code(), + 0 + ); + } +} diff --git a/src-tauri/client-cli/src/commands/list.rs b/src-tauri/client-cli/src/commands/list.rs new file mode 100644 index 000000000..9228ebf9f --- /dev/null +++ b/src-tauri/client-cli/src/commands/list.rs @@ -0,0 +1,366 @@ +use std::collections::HashMap; + +use defguard_core::database::models::{ + instance::{ClientTrafficPolicy, Instance}, + location::Location, + tunnel::Tunnel, + Id, +}; +use serde_json::{json, Value}; + +use crate::{ + commands::location::mfa_label, + output::{CommandOutput, InstanceEntry, LocationEntry, TunnelEntry}, + state::{CliError, State}, +}; + +const MIN_LOCATION_NAME_COL_WIDTH: usize = 8; +const MIN_ENDPOINT_COL_WIDTH: usize = 8; +const MIN_TUNNEL_NAME_COL_WIDTH: usize = 4; + +pub(crate) async fn handle(state: &State) -> Result { + let instances = Instance::all(&state.pool).await?; + let locations = Location::all(&state.pool, false).await?; + let tunnels = if Instance::tunnels_disabled(&state.pool).await? { + Vec::new() + } else { + Tunnel::all(&state.pool).await? + }; + Ok(ListResult { + instances, + locations, + tunnels, + }) +} + +pub struct ListResult { + pub instances: Vec>, + pub locations: Vec>, + pub tunnels: Vec>, +} + +impl CommandOutput for ListResult { + fn human(&self) -> String { + if self.instances.is_empty() { + "No instances enrolled. Use the desktop app to get started.".to_string() + } else { + format_list_table(&self.instances, &self.locations, &self.tunnels) + } + } + + fn json(&self) -> Value { + let instances_by_id = self + .instances + .iter() + .map(|i| (i.id, i)) + .collect::>(); + + let instances = self + .instances + .iter() + .map(|i| InstanceEntry { + id: i.id, + name: i.name.clone(), + url: i.url.clone(), + }) + .collect::>(); + + let locations = self + .locations + .iter() + .map(|l| { + let instance = instances_by_id.get(&l.instance_id); + let route_all_traffic = match instance + .map_or(&ClientTrafficPolicy::None, |i| &i.client_traffic_policy) + { + ClientTrafficPolicy::None => l.route_all_traffic, + ClientTrafficPolicy::DisableAllTraffic => false, + ClientTrafficPolicy::ForceAllTraffic => true, + }; + LocationEntry { + id: l.id, + name: l.name.clone(), + instance: instance.map(|i| i.name.clone()), + address: l.address.clone(), + endpoint: l.endpoint.clone(), + mfa_enabled: Some(l.mfa_enabled()), + mfa_method: Some(mfa_label(l.mfa_method).to_string()), + route_all_traffic: Some(route_all_traffic), + } + }) + .collect::>(); + + let tunnels = self + .tunnels + .iter() + .map(|t| TunnelEntry { + id: t.id, + name: t.name.clone(), + address: t.address.clone(), + endpoint: t.endpoint.clone(), + }) + .collect::>(); + + json!({ + "instances": instances, + "locations": locations, + "tunnels": tunnels, + }) + } +} + +fn format_list_table( + instances: &[Instance], + locations: &[Location], + tunnels: &[Tunnel], +) -> String { + let mut instance_locations: HashMap>> = HashMap::new(); + for location in locations { + instance_locations + .entry(location.instance_id) + .or_default() + .push(location); + } + + let location_name_col_width = locations + .iter() + .map(|l| l.name.len()) + .max() + .unwrap_or(MIN_LOCATION_NAME_COL_WIDTH) + .max(MIN_LOCATION_NAME_COL_WIDTH); + let endpoint_col_width = locations + .iter() + .map(|l| l.endpoint.len()) + .max() + .unwrap_or(MIN_ENDPOINT_COL_WIDTH) + .max(MIN_ENDPOINT_COL_WIDTH); + + let mut lines = Vec::new(); + + for instance in instances { + lines.push(format!("\n{} ({})", instance.name, instance.url)); + if let Some(locations) = instance_locations.get(&instance.id) { + lines.push(format!( + " {:>4} {:3} {:<11}", + "ID", "LOCATION", "ADDRESS", "ENDPOINT", "MFA", "Routing" + )); + for location in locations { + let mfa = if location.mfa_enabled() { "yes" } else { "no" }; + let route_all_traffic = match instance.client_traffic_policy { + ClientTrafficPolicy::None => location.route_all_traffic, + ClientTrafficPolicy::DisableAllTraffic => false, + ClientTrafficPolicy::ForceAllTraffic => true, + }; + + let route_label = if route_all_traffic { + "All-traffic" + } else { + "Predefined" + }; + + lines.push(format!( + " {:>4} {:3} {route_label:<11}", + location.id, location.name, location.address, location.endpoint + )); + } + } else { + lines.push(" (no locations)".to_string()); + } + } + + if !tunnels.is_empty() { + let tunnel_name_col_width = tunnels + .iter() + .map(|t| t.name.len()) + .max() + .unwrap_or(MIN_TUNNEL_NAME_COL_WIDTH) + .max(location_name_col_width); + let tunnel_endpoint_col_width = tunnels + .iter() + .map(|t| t.endpoint.len()) + .max() + .unwrap_or(MIN_ENDPOINT_COL_WIDTH) + .max(endpoint_col_width); + + lines.push("\nTunnels".to_string()); + lines.push(format!( + " {:>4} {:4} {: Instance { + Instance { + id, + name: name.to_string(), + uuid: format!("uuid-{id}"), + url: url.to_string(), + proxy_url: String::new(), + username: "user".to_string(), + token: None, + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: false, + disable_tunnels: false, + openid_display_name: None, + } + } + + fn make_location(id: Id, instance_id: Id, name: &str, endpoint: &str) -> Location { + Location { + id, + instance_id, + network_id: 1, + name: name.to_string(), + address: "10.0.0.0/24".to_string(), + pubkey: "pk".to_string(), + endpoint: endpoint.to_string(), + allowed_ips: "0.0.0.0/0".to_string(), + dns: None, + route_all_traffic: false, + keepalive_interval: 25, + location_mfa_mode: LocationMfaMode::Disabled, + service_location_mode: ServiceLocationMode::Disabled, + mfa_method: None, + posture_check_required: false, + } + } + + fn make_tunnel(id: Id, name: &str, endpoint: &str) -> Tunnel { + Tunnel { + id, + name: name.to_string(), + pubkey: "pk".to_string(), + prvkey: "prvk".to_string(), + address: "10.1.0.0/24".to_string(), + server_pubkey: "spk".to_string(), + preshared_key: None, + allowed_ips: Some("0.0.0.0/0".to_string()), + endpoint: endpoint.to_string(), + dns: None, + persistent_keep_alive: 25, + route_all_traffic: false, + pre_up: None, + post_up: None, + pre_down: None, + post_down: None, + } + } + + #[test] + fn test_human_empty() { + let result = ListResult { + instances: Vec::new(), + locations: Vec::new(), + tunnels: Vec::new(), + }; + let s = result.human(); + assert!(s.contains("No instances enrolled")); + } + + #[test] + fn test_human_with_data() { + let inst = make_instance(1, "acme", "https://acme.example"); + let loc = make_location(10, 1, "office", "1.2.3.4:51820"); + let tun = make_tunnel(20, "datacenter", "5.6.7.8:51820"); + + let result = ListResult { + instances: vec![inst], + locations: vec![loc], + tunnels: vec![tun], + }; + let s = result.human(); + assert!(s.contains("ID")); + assert!(s.contains("acme")); + assert!(s.contains("office")); + assert!(s.contains("datacenter")); + assert!(s.contains("Tunnels")); + } + + #[test] + fn test_json_empty() { + let result = ListResult { + instances: Vec::new(), + locations: Vec::new(), + tunnels: Vec::new(), + }; + let json = result.json(); + assert_eq!(json["instances"].as_array().unwrap().len(), 0); + assert_eq!(json["locations"].as_array().unwrap().len(), 0); + assert_eq!(json["tunnels"].as_array().unwrap().len(), 0); + } + + #[test] + fn test_json_with_data() { + let inst = make_instance(1, "acme", "https://acme.example"); + let loc = make_location(10, 1, "office", "1.2.3.4:51820"); + let loc2 = make_location(11, 1, "home", "9.9.9.9:51820"); + let tun = make_tunnel(20, "datacenter", "5.6.7.8:51820"); + + let result = ListResult { + instances: vec![inst], + locations: vec![loc, loc2], + tunnels: vec![tun], + }; + let json = result.json(); + + let instances = json["instances"].as_array().unwrap(); + assert_eq!(instances.len(), 1); + assert_eq!(instances[0]["id"], 1); + assert_eq!(instances[0]["name"], "acme"); + assert_eq!(instances[0]["url"], "https://acme.example"); + + let locations = json["locations"].as_array().unwrap(); + assert_eq!(locations.len(), 2); + assert_eq!(locations[0]["id"], 10); + assert_eq!(locations[0]["name"], "office"); + assert_eq!(locations[0]["instance"], "acme"); + assert_eq!(locations[1]["name"], "home"); + + let tunnels = json["tunnels"].as_array().unwrap(); + assert_eq!(tunnels.len(), 1); + assert_eq!(tunnels[0]["id"], 20); + assert_eq!(tunnels[0]["name"], "datacenter"); + assert_eq!(tunnels[0]["endpoint"], "5.6.7.8:51820"); + } + + #[test] + fn test_json_no_message_field() { + let result = ListResult { + instances: Vec::new(), + locations: Vec::new(), + tunnels: Vec::new(), + }; + let json = result.json(); + assert!(json["message"].is_null()); + } + + #[test] + fn test_exit_code_zero() { + let result = ListResult { + instances: Vec::new(), + locations: Vec::new(), + tunnels: Vec::new(), + }; + assert_eq!(result.exit_code(), 0); + } +} diff --git a/src-tauri/client-cli/src/commands/location.rs b/src-tauri/client-cli/src/commands/location.rs new file mode 100644 index 000000000..139ac4dc7 --- /dev/null +++ b/src-tauri/client-cli/src/commands/location.rs @@ -0,0 +1,627 @@ +use std::collections::HashMap; + +use defguard_core::database::models::{ + instance::{ClientTrafficPolicy, Instance}, + location::{Location, LocationMfaMethod}, + Id, +}; +use serde_json::{json, Value}; + +use crate::{ + output::{CommandOutput, LocationEntry}, + resolve::{self, ResolvedTarget, TargetSpec}, + state::{CliError, State}, +}; + +const MIN_NAME_COL_WIDTH: usize = 8; +const MIN_ENDPOINT_COL_WIDTH: usize = 8; +const MIN_INST_COL_WIDTH: usize = 8; + +pub(crate) async fn handle_list(state: &State) -> Result { + let locations = Location::all(&state.pool, false).await?; + + let instance_details = Instance::all(&state.pool) + .await? + .into_iter() + .map(|instance| { + ( + instance.id, + InstanceDetails { + name: instance.name, + client_traffic_policy: instance.client_traffic_policy, + }, + ) + }) + .collect::>(); + + Ok(LocationListResult { + locations, + instance_details, + }) +} + +pub async fn handle_set( + state: &State, + name: &str, + instance: Option<&str>, + mfa_method: Option<&str>, + route_all_traffic: Option, + predefined_traffic: bool, +) -> Result { + let spec = TargetSpec { + name: Some(name.to_string()), + tunnel: false, + id: None, + instance: instance.map(String::from), + }; + + let target = resolve::resolve_connect_target(&spec, &state.pool).await?; + let location_id = match &target { + ResolvedTarget::Location(loc) => loc.id, + ResolvedTarget::Tunnel(_) => { + return Err(CliError::NotFound(format!("Location '{name}' not found"))); + } + }; + + let mut changed = Vec::new(); + + if let Some(method_str) = mfa_method { + let method = parse_mfa_method(method_str)?; + Location::set_mfa_method(&state.pool, location_id, method).await?; + changed.push(format!("MFA method → {method_str}")); + } + + if let Some(true) = route_all_traffic { + Location::update_routing(&state.pool, location_id, true).await?; + changed.push("route-all-traffic → on".to_string()); + } else if predefined_traffic { + Location::update_routing(&state.pool, location_id, false).await?; + changed.push("route-all-traffic → off".to_string()); + } + + Ok(LocationSetResult { + name: name.to_string(), + changes: changed, + }) +} + +pub async fn handle_show( + state: &State, + name: &str, + instance: Option<&str>, +) -> Result { + let spec = TargetSpec { + name: Some(name.to_string()), + tunnel: false, + id: None, + instance: instance.map(String::from), + }; + + let target = resolve::resolve_connect_target(&spec, &state.pool).await?; + let ResolvedTarget::Location(location) = &target else { + return Err(CliError::NotFound(format!("Location '{name}' not found"))); + }; + let client_traffic_policy = Instance::find_by_id(&state.pool, location.instance_id) + .await? + .map_or(ClientTrafficPolicy::None, |instance| { + instance.client_traffic_policy + }); + + Ok(LocationShowResult { + name: location.name.clone(), + address: location.address.clone(), + endpoint: location.endpoint.clone(), + pubkey: location.pubkey.clone(), + allowed_ips: location.allowed_ips.clone(), + dns: location.dns.clone(), + mfa_method: mfa_label(location.mfa_method).to_string(), + route_all_traffic: match client_traffic_policy { + ClientTrafficPolicy::None => location.route_all_traffic, + ClientTrafficPolicy::DisableAllTraffic => false, + ClientTrafficPolicy::ForceAllTraffic => true, + }, + keepalive_interval: location.keepalive_interval, + }) +} + +fn parse_mfa_method(raw: &str) -> Result { + match raw.to_lowercase().as_str() { + "totp" => Ok(LocationMfaMethod::Totp), + "email" => Ok(LocationMfaMethod::Email), + "oidc" => Ok(LocationMfaMethod::Oidc), + "biometric" => Ok(LocationMfaMethod::Biometric), + "mobile" | "mobile_approve" => Ok(LocationMfaMethod::MobileApprove), + _ => Err(CliError::Usage(format!( + "Invalid MFA method '{raw}'. Valid: totp, email, oidc, biometric, mobile." + ))), + } +} + +pub(crate) fn mfa_label(method: Option) -> &'static str { + match method { + Some(method) => method.as_str(), + None => "none", + } +} + +pub(crate) struct InstanceDetails { + pub name: String, + pub client_traffic_policy: ClientTrafficPolicy, +} + +pub struct LocationListResult { + pub locations: Vec>, + pub instance_details: HashMap, +} + +impl CommandOutput for LocationListResult { + fn human(&self) -> String { + if self.locations.is_empty() { + "No locations configured. Use the desktop app to enroll an instance first.".to_string() + } else { + format_location_list_table(&self.locations, &self.instance_details) + } + } + + fn json(&self) -> Value { + let locations = self + .locations + .iter() + .map(|l| { + let details = self.instance_details.get(&l.instance_id); + let route_all_traffic = match details + .map_or(&ClientTrafficPolicy::None, |details| { + &details.client_traffic_policy + }) { + ClientTrafficPolicy::None => l.route_all_traffic, + ClientTrafficPolicy::DisableAllTraffic => false, + ClientTrafficPolicy::ForceAllTraffic => true, + }; + LocationEntry { + id: l.id, + name: l.name.clone(), + instance: details.map(|details| details.name.clone()), + address: l.address.clone(), + endpoint: l.endpoint.clone(), + mfa_enabled: None, + mfa_method: Some(mfa_label(l.mfa_method).to_string()), + route_all_traffic: Some(route_all_traffic), + } + }) + .collect::>(); + json!({ "locations": locations }) + } +} + +fn format_location_list_table( + locations: &[Location], + instance_details: &HashMap, +) -> String { + let name_col_width = locations + .iter() + .map(|l| l.name.len()) + .max() + .unwrap_or(MIN_NAME_COL_WIDTH) + .max(MIN_NAME_COL_WIDTH); + let endpoint_col_width = locations + .iter() + .map(|l| l.endpoint.len()) + .max() + .unwrap_or(MIN_ENDPOINT_COL_WIDTH) + .max(MIN_ENDPOINT_COL_WIDTH); + let inst_col_width = locations + .iter() + .filter_map(|l| { + instance_details + .get(&l.instance_id) + .map(|details| details.name.len()) + }) + .max() + .unwrap_or(MIN_INST_COL_WIDTH) + .max(MIN_INST_COL_WIDTH); + + let mut lines = vec![format!( + " {:>4} {:3} {:<11}", + "ID", "LOCATION", "ADDRESS", "ENDPOINT", "INSTANCE", "MFA", "Routing" + )]; + for location in locations { + let details = instance_details.get(&location.instance_id); + + let instance_name = details.map_or("?", |instance| instance.name.as_str()); + let instance_traffic_policy = details.map_or(&ClientTrafficPolicy::None, |instance| { + &instance.client_traffic_policy + }); + + let route_all_traffic = match instance_traffic_policy { + ClientTrafficPolicy::None => location.route_all_traffic, + ClientTrafficPolicy::DisableAllTraffic => false, + ClientTrafficPolicy::ForceAllTraffic => true, + }; + + let route_label = if route_all_traffic { + "All-traffic" + } else { + "Predefined" + }; + + lines.push(format!( + " {:>4} {:3} {:>11}", + location.id, + location.name, + location.address, + location.endpoint, + instance_name, + mfa_label(location.mfa_method), + route_label + )); + } + lines.join("\n") +} + +pub struct LocationShowResult { + pub name: String, + pub address: String, + pub endpoint: String, + pub pubkey: String, + pub allowed_ips: String, + pub dns: Option, + pub mfa_method: String, + pub route_all_traffic: bool, + pub keepalive_interval: i64, +} + +impl CommandOutput for LocationShowResult { + fn human(&self) -> String { + let mut lines = Vec::new(); + lines.push(format!("Name: {}", self.name)); + lines.push(format!("Address: {}", self.address)); + lines.push(format!("Endpoint: {}", self.endpoint)); + lines.push(format!("Pubkey: {}", self.pubkey)); + lines.push(format!("Allowed IPs: {}", self.allowed_ips)); + if let Some(dns) = &self.dns { + lines.push(format!("DNS: {dns}")); + } + lines.push(format!("MFA method: {}", self.mfa_method)); + lines.push(format!("Route all traffic: {}", self.route_all_traffic)); + lines.push(format!("Keepalive: {}s", self.keepalive_interval)); + lines.join("\n") + } + + fn json(&self) -> Value { + let mut json = json!({ + "name": self.name, + "address": self.address, + "endpoint": self.endpoint, + "pubkey": self.pubkey, + "allowed_ips": self.allowed_ips, + "mfa_method": self.mfa_method, + "route_all_traffic": self.route_all_traffic, + "keepalive_interval": self.keepalive_interval, + }); + if let Some(dns) = &self.dns { + json["dns"] = json!(dns); + } + json + } +} + +pub struct LocationSetResult { + pub name: String, + pub changes: Vec, +} + +impl CommandOutput for LocationSetResult { + fn human(&self) -> String { + if self.changes.is_empty() { + format!("No changes for location '{}'.", self.name) + } else { + format!( + "Updated location '{}': {}", + self.name, + self.changes.join(", ") + ) + } + } + + fn json(&self) -> Value { + json!({ + "location": self.name, + "changes": self.changes, + }) + } +} + +#[cfg(test)] +mod tests { + use defguard_core::database::models::location::{LocationMfaMode, ServiceLocationMode}; + + use super::*; + + fn make_location( + id: Id, + instance_id: Id, + name: &str, + endpoint: &str, + mfa: bool, + ) -> Location { + Location { + id, + instance_id, + network_id: 1, + name: name.to_string(), + address: "10.0.0.0/24".to_string(), + pubkey: "pk".to_string(), + endpoint: endpoint.to_string(), + allowed_ips: "0.0.0.0/0".to_string(), + dns: None, + route_all_traffic: false, + keepalive_interval: 25, + location_mfa_mode: if mfa { + LocationMfaMode::Internal + } else { + LocationMfaMode::Disabled + }, + service_location_mode: ServiceLocationMode::Disabled, + mfa_method: None, + posture_check_required: false, + } + } + + fn make_instance_details( + name: &str, + client_traffic_policy: ClientTrafficPolicy, + ) -> InstanceDetails { + InstanceDetails { + name: name.to_string(), + client_traffic_policy, + } + } + + #[test] + fn test_list_human_empty() { + let result = LocationListResult { + locations: Vec::new(), + instance_details: HashMap::new(), + }; + assert_eq!( + result.human(), + "No locations configured. Use the desktop app to enroll an instance first." + ); + } + + #[test] + fn test_list_human_with_data() { + let loc = make_location(1, 10, "office", "1.2.3.4:51820", false); + let mut instance_details = HashMap::new(); + instance_details.insert(10, make_instance_details("acme", ClientTrafficPolicy::None)); + let result = LocationListResult { + locations: vec![loc], + instance_details, + }; + let s = result.human(); + assert!(s.contains("ID")); + assert!(s.contains("office")); + assert!(s.contains("acme")); + assert!(s.contains("1.2.3.4:51820")); + } + + fn routing_column( + route_all_traffic: bool, + client_traffic_policy: ClientTrafficPolicy, + ) -> String { + let mut location = make_location(1, 10, "office", "1.2.3.4:51820", false); + location.route_all_traffic = route_all_traffic; + let mut instance_details = HashMap::new(); + instance_details.insert(10, make_instance_details("acme", client_traffic_policy)); + LocationListResult { + locations: vec![location], + instance_details, + } + .human() + } + + #[test] + fn test_list_human_force_all_traffic_overrides_location() { + let table = routing_column(false, ClientTrafficPolicy::ForceAllTraffic); + assert!(table.contains("All-traffic")); + assert!(!table.contains("Predefined")); + } + + #[test] + fn test_list_human_disable_all_traffic_overrides_location() { + let table = routing_column(true, ClientTrafficPolicy::DisableAllTraffic); + assert!(table.contains("Predefined")); + assert!(!table.contains("All-traffic")); + } + + #[test] + fn test_list_human_no_policy_keeps_location_setting() { + assert!(routing_column(true, ClientTrafficPolicy::None).contains("All-traffic")); + assert!(routing_column(false, ClientTrafficPolicy::None).contains("Predefined")); + } + + #[test] + fn test_list_json_empty() { + let result = LocationListResult { + locations: Vec::new(), + instance_details: HashMap::new(), + }; + let json = result.json(); + assert_eq!(json["locations"].as_array().unwrap().len(), 0); + assert!(json["message"].is_null()); + } + + #[test] + fn test_list_json_with_data() { + let loc = make_location(1, 10, "office", "1.2.3.4:51820", false); + let mut instance_details = HashMap::new(); + instance_details.insert(10, make_instance_details("acme", ClientTrafficPolicy::None)); + let result = LocationListResult { + locations: vec![loc], + instance_details, + }; + let json = result.json(); + let locations = json["locations"].as_array().unwrap(); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0]["id"], 1); + assert_eq!(locations[0]["name"], "office"); + assert_eq!(locations[0]["instance"], "acme"); + } + + #[test] + fn test_show_human() { + let result = LocationShowResult { + name: "office".to_string(), + address: "10.0.0.0/24".to_string(), + endpoint: "1.2.3.4:51820".to_string(), + pubkey: "pk".to_string(), + allowed_ips: "0.0.0.0/0".to_string(), + dns: Some("8.8.8.8".to_string()), + mfa_method: "totp".to_string(), + route_all_traffic: false, + keepalive_interval: 25, + }; + let s = result.human(); + assert!(s.contains("Name: office")); + assert!(s.contains("Address: 10.0.0.0/24")); + assert!(s.contains("DNS: 8.8.8.8")); + assert!(s.contains("MFA method: totp")); + } + + #[test] + fn test_show_human_without_dns() { + let result = LocationShowResult { + name: "office".to_string(), + address: "10.0.0.0/24".to_string(), + endpoint: "1.2.3.4:51820".to_string(), + pubkey: "pk".to_string(), + allowed_ips: "0.0.0.0/0".to_string(), + dns: None, + mfa_method: "none".to_string(), + route_all_traffic: true, + keepalive_interval: 30, + }; + let s = result.human(); + assert!(!s.contains("DNS")); + assert!(s.contains("Route all traffic: true")); + } + + #[test] + fn test_show_json() { + let result = LocationShowResult { + name: "office".to_string(), + address: "10.0.0.0/24".to_string(), + endpoint: "1.2.3.4:51820".to_string(), + pubkey: "pk".to_string(), + allowed_ips: "0.0.0.0/0".to_string(), + dns: Some("8.8.8.8".to_string()), + mfa_method: "totp".to_string(), + route_all_traffic: false, + keepalive_interval: 25, + }; + let json = result.json(); + assert_eq!(json["name"], "office"); + assert_eq!(json["dns"], "8.8.8.8"); + assert_eq!(json["mfa_method"], "totp"); + assert!(json["message"].is_null()); + } + + #[test] + fn test_show_json_without_dns() { + let result = LocationShowResult { + name: "office".to_string(), + address: "10.0.0.0/24".to_string(), + endpoint: "1.2.3.4:51820".to_string(), + pubkey: "pk".to_string(), + allowed_ips: "0.0.0.0/0".to_string(), + dns: None, + mfa_method: "none".to_string(), + route_all_traffic: true, + keepalive_interval: 30, + }; + let json = result.json(); + assert!(json["dns"].is_null()); + } + + #[test] + fn test_exit_code_zero() { + assert_eq!( + LocationListResult { + locations: Vec::new(), + instance_details: HashMap::new(), + } + .exit_code(), + 0 + ); + assert_eq!( + LocationShowResult { + name: "x".to_string(), + address: "a".to_string(), + endpoint: "e".to_string(), + pubkey: "p".to_string(), + allowed_ips: "0.0.0.0/0".to_string(), + dns: None, + mfa_method: "n".to_string(), + route_all_traffic: false, + keepalive_interval: 25, + } + .exit_code(), + 0 + ); + assert_eq!( + LocationSetResult { + name: "x".to_string(), + changes: Vec::new(), + } + .exit_code(), + 0 + ); + } + + #[test] + fn test_set_human_no_changes() { + let result = LocationSetResult { + name: "office".to_string(), + changes: Vec::new(), + }; + assert_eq!(result.human(), "No changes for location 'office'."); + } + + #[test] + fn test_set_human_with_changes() { + let result = LocationSetResult { + name: "office".to_string(), + changes: vec![ + "MFA method → totp".to_string(), + "route-all-traffic → on".to_string(), + ], + }; + let s = result.human(); + assert!(s.contains("Updated location 'office'")); + assert!(s.contains("MFA method → totp")); + assert!(s.contains("route-all-traffic → on")); + } + + #[test] + fn test_set_json() { + let result = LocationSetResult { + name: "office".to_string(), + changes: vec!["MFA method → totp".to_string()], + }; + let json = result.json(); + assert_eq!(json["location"], "office"); + assert_eq!(json["changes"].as_array().unwrap().len(), 1); + } + + #[test] + fn test_set_json_empty_changes() { + let result = LocationSetResult { + name: "office".to_string(), + changes: Vec::new(), + }; + let json = result.json(); + assert_eq!(json["location"], "office"); + assert_eq!(json["changes"].as_array().unwrap().len(), 0); + assert!(json["message"].is_null()); + } +} diff --git a/src-tauri/client-cli/src/commands/mod.rs b/src-tauri/client-cli/src/commands/mod.rs new file mode 100644 index 000000000..13cf2293f --- /dev/null +++ b/src-tauri/client-cli/src/commands/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod connect; +pub(crate) mod disconnect; +pub(crate) mod instance; +pub(crate) mod list; +pub(crate) mod location; +pub(crate) mod status; +pub(crate) mod tunnel; diff --git a/src-tauri/client-cli/src/commands/status.rs b/src-tauri/client-cli/src/commands/status.rs new file mode 100644 index 000000000..ee493fe1f --- /dev/null +++ b/src-tauri/client-cli/src/commands/status.rs @@ -0,0 +1,245 @@ +use defguard_core::connection::active_state::{active_state, ActiveConnectionInfo}; +use serde_json::{json, Value}; + +use crate::{ + output::{ActiveEntry, CommandOutput}, + state::{CliError, State}, +}; + +const MIN_NAME_COL_WIDTH: usize = 4; +const MIN_IFACE_COL_WIDTH: usize = 9; + +pub(crate) async fn handle(state: &State) -> Result { + let connections = active_state(&state.pool).await?; + Ok(StatusResult { connections }) +} + +pub struct StatusResult { + pub connections: Vec, +} + +impl CommandOutput for StatusResult { + fn human(&self) -> String { + if self.connections.is_empty() { + "No active connections.".to_string() + } else { + format_status_table(&self.connections) + } + } + + fn json(&self) -> Value { + let active = self + .connections + .iter() + .map(|c| ActiveEntry { + connection_type: c.connection_type.to_string(), + name: c.name.clone(), + interface: c.interface_name.clone(), + listen_port: c.stats.as_ref().map(|s| s.listen_port), + tx_bytes: c.stats.as_ref().map(|s| s.tx_bytes), + rx_bytes: c.stats.as_ref().map(|s| s.rx_bytes), + last_handshake_secs: c.stats.as_ref().and_then(|s| s.last_handshake), + }) + .collect::>(); + json!({ "active": active }) + } +} + +/// Build a human-readable status table string. +fn format_status_table(connections: &[ActiveConnectionInfo]) -> String { + let name_col_width = connections + .iter() + .map(|c| c.name.len()) + .max() + .unwrap_or(MIN_NAME_COL_WIDTH) + .max(MIN_NAME_COL_WIDTH); + let iface_col_width = connections + .iter() + .map(|c| c.interface_name.len()) + .max() + .unwrap_or(MIN_IFACE_COL_WIDTH) + .max(MIN_IFACE_COL_WIDTH); + + let mut lines = vec!["\nActive Connections".to_string()]; + lines.push(format!( + " {: String { + const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit_idx = 0; + while value >= 1024.0 && unit_idx < UNITS.len() - 1 { + value /= 1024.0; + unit_idx += 1; + } + if unit_idx == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit_idx]) + } +} + +fn format_handshake(secs: u64) -> String { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + let then = UNIX_EPOCH + Duration::from_secs(secs); + let now = SystemTime::now(); + let Ok(elapsed) = now.duration_since(then) else { + return "now".to_string(); + }; + + let secs = elapsed.as_secs(); + if secs < 60 { + format!("{secs}s ago") + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } +} + +#[cfg(test)] +mod tests { + use defguard_core::{connection::active_state::InterfaceStats, ConnectionType}; + + use super::*; + + fn make_conn( + name: &str, + iface: &str, + stats: Option, + conn_type: ConnectionType, + ) -> ActiveConnectionInfo { + ActiveConnectionInfo { + connection_type: conn_type, + target_id: 1, + name: name.to_string(), + interface_name: iface.to_string(), + stats, + } + } + + #[test] + fn test_human_empty() { + let result = StatusResult { + connections: Vec::new(), + }; + let s = result.human(); + assert!(s.contains("No active connections")); + } + + #[test] + fn test_human_with_connections() { + let result = StatusResult { + connections: vec![make_conn( + "office", + "wg0", + Some(InterfaceStats { + listen_port: 51820, + tx_bytes: 1024, + rx_bytes: 2048, + last_handshake: Some(1_700_000_000), + }), + ConnectionType::Location, + )], + }; + let s = result.human(); + if !cfg!(target_os = "macos") { + assert!(s.contains("office")); + assert!(s.contains("wg0")); + assert!(s.contains("1.0 KiB")); + assert!(s.contains("2.0 KiB")); + } + } + + #[test] + fn test_json_empty() { + let result = StatusResult { + connections: Vec::new(), + }; + let json = result.json(); + assert_eq!(json["active"].as_array().unwrap().len(), 0); + } + + #[test] + fn test_json_with_connections() { + let result = StatusResult { + connections: vec![ + make_conn( + "office", + "wg0", + Some(InterfaceStats { + listen_port: 51820, + tx_bytes: 1024, + rx_bytes: 2048, + last_handshake: Some(1_700_000_000), + }), + ConnectionType::Location, + ), + make_conn("data-center", "wg1", None, ConnectionType::Tunnel), + ], + }; + let json = result.json(); + let active = json["active"].as_array().unwrap(); + assert_eq!(active.len(), 2); + + assert_eq!(active[0]["name"], "office"); + assert_eq!(active[0]["connection_type"], "location"); + assert_eq!(active[0]["interface"], "wg0"); + assert_eq!(active[0]["listen_port"], 51820); + assert_eq!(active[0]["tx_bytes"], 1024); + assert_eq!(active[0]["rx_bytes"], 2048); + assert_eq!(active[0]["last_handshake_secs"], 1_700_000_000); + + assert_eq!(active[1]["name"], "data-center"); + assert_eq!(active[1]["connection_type"], "tunnel"); + assert_eq!(active[1]["interface"], "wg1"); + assert!(active[1]["listen_port"].is_null()); + } + + #[test] + fn test_json_no_message_field() { + let result = StatusResult { + connections: Vec::new(), + }; + let json = result.json(); + assert!(json["message"].is_null()); + } + + #[test] + fn test_exit_code_zero() { + let result = StatusResult { + connections: Vec::new(), + }; + assert_eq!(result.exit_code(), 0); + } +} diff --git a/src-tauri/client-cli/src/commands/tunnel.rs b/src-tauri/client-cli/src/commands/tunnel.rs new file mode 100644 index 000000000..dc09f9444 --- /dev/null +++ b/src-tauri/client-cli/src/commands/tunnel.rs @@ -0,0 +1,262 @@ +use defguard_core::database::models::{instance::Instance, tunnel::Tunnel, Id}; +use serde_json::{json, Value}; + +use crate::{ + output::CommandOutput, + state::{CliError, State}, +}; + +const MIN_NAME_COL_WIDTH: usize = 4; +const MIN_ADDR_COL_WIDTH: usize = 7; +const MIN_ENDPOINT_COL_WIDTH: usize = 8; + +pub async fn handle_list(state: &State) -> Result { + Instance::ensure_tunnels_enabled(&state.pool).await?; + let tunnels = Tunnel::all(&state.pool).await?; + Ok(TunnelListResult { tunnels }) +} + +pub async fn handle_show(state: &State, name: &str) -> Result { + Instance::ensure_tunnels_enabled(&state.pool).await?; + let tunnels = Tunnel::find_by_name(&state.pool, name).await?; + let tunnel = match tunnels.len() { + 0 => { + return Err(CliError::NotFound(format!("Tunnel '{name}' not found"))); + } + 1 => tunnels + .into_iter() + .next() + .expect("exactly one tunnel expected after length check"), + _ => { + return Err(CliError::NotFound(format!( + "Multiple tunnels named '{name}'" + ))); + } + }; + Ok(TunnelShowResult { tunnel }) +} + +pub struct TunnelListResult { + pub tunnels: Vec>, +} + +impl CommandOutput for TunnelListResult { + fn human(&self) -> String { + if self.tunnels.is_empty() { + "No tunnels configured. Import tunnels via the desktop app.".to_string() + } else { + format_tunnel_list_table(&self.tunnels) + } + } + + fn json(&self) -> Value { + let tunnels = self + .tunnels + .iter() + .map(|t| { + json!({ + "id": t.id, + "name": t.name, + "address": t.address, + "endpoint": t.endpoint, + "route_all_traffic": t.route_all_traffic, + }) + }) + .collect::>(); + json!({ "tunnels": tunnels }) + } +} + +fn format_tunnel_list_table(tunnels: &[Tunnel]) -> String { + let name_col_width = tunnels + .iter() + .map(|t| t.name.len()) + .max() + .unwrap_or(MIN_NAME_COL_WIDTH) + .max(MIN_NAME_COL_WIDTH); + let addr_col_width = tunnels + .iter() + .map(|t| t.address.len()) + .max() + .unwrap_or(MIN_ADDR_COL_WIDTH) + .max(MIN_ADDR_COL_WIDTH); + let endpoint_col_width = tunnels + .iter() + .map(|t| t.endpoint.len()) + .max() + .unwrap_or(MIN_ENDPOINT_COL_WIDTH) + .max(MIN_ENDPOINT_COL_WIDTH); + + let mut lines = vec![format!( + " {:>4} {:11}", + "ID", "NAME", "ADDRESS", "ENDPOINT", "Routing" + )]; + for tunnel in tunnels { + lines.push(format!( + " {:>4} {:11}", + tunnel.id, + tunnel.name, + tunnel.address, + tunnel.endpoint, + if tunnel.route_all_traffic { + "All-traffic" + } else { + "Predefined" + } + )); + } + lines.join("\n") +} + +pub struct TunnelShowResult { + pub tunnel: Tunnel, +} + +impl CommandOutput for TunnelShowResult { + fn human(&self) -> String { + let mut lines = Vec::new(); + lines.push(format!("Name: {}", self.tunnel.name)); + lines.push(format!("Address: {}", self.tunnel.address)); + lines.push(format!("Endpoint: {}", self.tunnel.endpoint)); + lines.push(format!("Pubkey: {}", self.tunnel.pubkey)); + lines.push(format!( + "Server pubkey: {}", + self.tunnel.server_pubkey + )); + if let Some(ref allowed) = self.tunnel.allowed_ips { + lines.push(format!("Allowed IPs: {allowed}")); + } + if let Some(ref dns) = self.tunnel.dns { + lines.push(format!("DNS: {dns}")); + } + lines.push(format!( + "Route all traffic: {}", + self.tunnel.route_all_traffic + )); + lines.push(format!( + "Persistent keepalive: {}", + self.tunnel.persistent_keep_alive + )); + if let Some(ref pre_up) = self.tunnel.pre_up { + lines.push(format!("Pre-up: {pre_up}")); + } + if let Some(ref post_up) = self.tunnel.post_up { + lines.push(format!("Post-up: {post_up}")); + } + if let Some(ref pre_down) = self.tunnel.pre_down { + lines.push(format!("Pre-down: {pre_down}")); + } + if let Some(ref post_down) = self.tunnel.post_down { + lines.push(format!("Post-down: {post_down}")); + } + lines.join("\n") + } + + fn json(&self) -> Value { + json!({ + "name": self.tunnel.name, + "address": self.tunnel.address, + "endpoint": self.tunnel.endpoint, + "pubkey": self.tunnel.pubkey, + "server_pubkey": self.tunnel.server_pubkey, + "allowed_ips": self.tunnel.allowed_ips, + "dns": self.tunnel.dns, + "route_all_traffic": self.tunnel.route_all_traffic, + "persistent_keep_alive": self.tunnel.persistent_keep_alive, + "pre_up": self.tunnel.pre_up, + "post_up": self.tunnel.post_up, + "pre_down": self.tunnel.pre_down, + "post_down": self.tunnel.post_down, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_tunnel(name: &str) -> Tunnel { + Tunnel { + id: 1, + name: name.to_string(), + pubkey: "pk".to_string(), + prvkey: "sk".to_string(), + address: "10.0.0.0/24".to_string(), + server_pubkey: "spk".to_string(), + preshared_key: None, + allowed_ips: Some("0.0.0.0/0".to_string()), + endpoint: "1.2.3.4:51820".to_string(), + dns: Some("8.8.8.8".to_string()), + persistent_keep_alive: 25, + route_all_traffic: false, + pre_up: None, + post_up: None, + pre_down: None, + post_down: None, + } + } + + #[test] + fn test_list_human_empty() { + let result = TunnelListResult { + tunnels: Vec::new(), + }; + assert_eq!( + result.human(), + "No tunnels configured. Import tunnels via the desktop app." + ); + } + + #[test] + fn test_list_human_with_data() { + let result = TunnelListResult { + tunnels: vec![make_tunnel("gateway")], + }; + let s = result.human(); + assert!(s.contains("ID")); + assert!(s.contains("gateway")); + assert!(s.contains("10.0.0.0/24")); + assert!(s.contains("1.2.3.4:51820")); + } + + #[test] + fn test_show_human() { + let result = TunnelShowResult { + tunnel: make_tunnel("gateway"), + }; + let s = result.human(); + assert!(s.contains("Name: gateway")); + assert!(s.contains("Address: 10.0.0.0/24")); + assert!(s.contains("Endpoint: 1.2.3.4:51820")); + assert!(s.contains("DNS: 8.8.8.8")); + } + + #[test] + fn test_show_human_no_dns() { + let mut tun = make_tunnel("gateway"); + tun.dns = None; + tun.allowed_ips = None; + let result = TunnelShowResult { tunnel: tun }; + let s = result.human(); + assert!(!s.contains("DNS")); + assert!(!s.contains("Allowed IPs")); + } + + #[test] + fn test_exit_code_zero() { + assert_eq!( + TunnelListResult { + tunnels: Vec::new() + } + .exit_code(), + 0 + ); + assert_eq!( + TunnelShowResult { + tunnel: make_tunnel("x"), + } + .exit_code(), + 0 + ); + } +} diff --git a/src-tauri/client-cli/src/exit.rs b/src-tauri/client-cli/src/exit.rs new file mode 100644 index 000000000..c4ecd4493 --- /dev/null +++ b/src-tauri/client-cli/src/exit.rs @@ -0,0 +1,61 @@ +use crate::state::CliError; + +/// Map a `CliError` variant to the corresponding process exit code. +/// +/// | Code | Meaning | +/// |------|--------------------| +/// | 0 | ok | +/// | 1 | database, other | +/// | 2 | usage | +/// | 3 | not-found | +/// | 4 | daemon-unavailable | +/// | 5 | mfa-failed | +/// | 6 | not-enrolled | +/// | 7 | invalid-input | +/// | 8 | cancelled | +pub fn exit_code_for(err: &CliError) -> u8 { + match err { + CliError::Usage(_) => 2, + CliError::NotFound(_) => 3, + CliError::DaemonUnavailable(_) => 4, + CliError::MfaFailed(_) | CliError::MfaInputRequired(_) => 5, + CliError::NotEnrolled(_) => 6, + CliError::InvalidInput(_) => 7, + CliError::Cancelled(_) => 8, + CliError::Database(_) | CliError::Other(_) => 1, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exit_code_mapping() { + let cases: &[(&CliError, u8)] = &[ + (&CliError::Usage("bad flag".into()), 2), + (&CliError::NotFound("no such location".into()), 3), + (&CliError::DaemonUnavailable("daemon down".into()), 4), + (&CliError::MfaFailed("wrong code".into()), 5), + (&CliError::MfaInputRequired("no TTY".into()), 5), + (&CliError::NotEnrolled("no instances".into()), 6), + ( + &CliError::InvalidInput("route_all_traffic enforced".into()), + 7, + ), + (&CliError::Cancelled("user cancelled".into()), 8), + ( + &CliError::Database(sqlx::Error::Protocol("bad schema".into())), + 1, + ), + (&CliError::Other("something broke".into()), 1), + ]; + for (err, expected) in cases { + assert_eq!( + exit_code_for(err), + *expected, + "{err:?} should map to {expected}", + ); + } + } +} diff --git a/src-tauri/client-cli/src/lib.rs b/src-tauri/client-cli/src/lib.rs new file mode 100644 index 000000000..8325349dc --- /dev/null +++ b/src-tauri/client-cli/src/lib.rs @@ -0,0 +1,155 @@ +use std::{env, process::ExitCode}; + +use clap::Parser; + +mod brand; +mod cli; +mod commands; +mod exit; +mod logging; +mod mfa; +mod mfa_code; +mod mfa_qr; +mod monitor; +mod output; +mod polling; +mod resolve; +mod state; +#[cfg(all(test, target_os = "linux"))] +mod tests_daemon; + +use cli::{Cli, InstanceCommand, LocationCommand, TunnelCommand}; + +use crate::{ + cli::Commands, + commands::{connect, disconnect, instance, list, location, status, tunnel}, + state::State, +}; + +pub async fn cli_main() -> ExitCode { + // Brand banner: shown before clap's --help, and when invoked with + // zero arguments. NOT shown for --version (must stay grep-friendly). + show_banner_if_appropriate(); + + let cli = Cli::parse(); + + // Init logging to stderr so stdout stays data-only. + logging::init(cli.verbose); + + // Resolve state (DB pool, migrations, app config). + let state = match State::init().await { + Ok(s) => s, + Err(err) => { + let code = exit::exit_code_for(&err); + output::emit_error(&err, cli.json); + return ExitCode::from(code); + } + }; + + polling::poll_config(&state).await; + monitor::tear_down_stale_connections(&state).await; + + // Dispatch command. + match cli.command { + Commands::List => output::finish(list::handle(&state).await, cli.json), + Commands::Status => output::finish(status::handle(&state).await, cli.json), + Commands::Connect { + name, + tunnel, + id, + instance, + code, + code_command, + mfa_method, + qr_file, + all_traffic, + predefined_traffic, + } => output::finish( + connect::handle( + &state, + name.as_deref(), + tunnel, + id, + instance.as_deref(), + code.as_deref(), + code_command.as_deref(), + mfa_method.as_deref(), + qr_file.as_deref(), + all_traffic, + predefined_traffic, + cli.json, + ) + .await, + cli.json, + ), + Commands::Disconnect { + name, + tunnel, + id, + instance, + all, + } => output::finish( + disconnect::handle( + &state, + name.as_deref(), + tunnel, + id, + instance.as_deref(), + all, + ) + .await, + cli.json, + ), + Commands::Location(sub) => match sub { + LocationCommand::List => output::finish(location::handle_list(&state).await, cli.json), + LocationCommand::Set { + name, + instance, + mfa_method, + route_all_traffic, + predefined_traffic, + } => output::finish( + location::handle_set( + &state, + &name, + instance.as_deref(), + mfa_method.as_deref(), + if route_all_traffic { Some(true) } else { None }, + predefined_traffic, + ) + .await, + cli.json, + ), + LocationCommand::Show { name, instance } => output::finish( + location::handle_show(&state, &name, instance.as_deref()).await, + cli.json, + ), + }, + Commands::Instance(sub) => match sub { + InstanceCommand::List => output::finish(instance::handle_list(&state).await, cli.json), + InstanceCommand::Show { name } => { + output::finish(instance::handle_show(&state, &name).await, cli.json) + } + }, + Commands::Tunnel(sub) => match sub { + TunnelCommand::List => output::finish(tunnel::handle_list(&state).await, cli.json), + TunnelCommand::Show { name } => { + output::finish(tunnel::handle_show(&state, &name).await, cli.json) + } + }, + } +} + +/// Show the brand banner (logo + copyright + project version) on the two surfaces that need +/// branding: `defguard-client` with no args (clap prints help; we banner first), and +/// `defguard-client --help` / `-h`. Don't check for `--version` / `-V` – that is handled in +/// `check_version_flag`, which should be called before calling this function. +fn show_banner_if_appropriate() { + // Skip argv[0]. If user supplied any subcommand or flag other + // than --help / -h, do not print the banner. + let no_args = env::args().count() <= 1; + let asked_help = env::args().skip(1).any(|a| a == "--help" || a == "-h"); + if no_args || asked_help { + brand::print_banner(); + } +} diff --git a/src-tauri/client-cli/src/logging.rs b/src-tauri/client-cli/src/logging.rs new file mode 100644 index 000000000..1742b3b88 --- /dev/null +++ b/src-tauri/client-cli/src/logging.rs @@ -0,0 +1,35 @@ +//! CLI logging — three-stream model: stdout = data only, stderr = diagnostics. +//! +//! Installs a `tracing_subscriber` that writes to **stderr**. The `tracing-log` +//! feature on `tracing-subscriber` bridges `core`'s `log::*` output so library +//! diagnostics never pollute stdout. +//! +//! Default level: WARN (quiet). Staged with `-v`/`-vv`/`-vvv`. `DG_LOG` or +//! `RUST_LOG` in the environment take precedence. + +use tracing_subscriber::EnvFilter; + +/// Initialise the logging subscriber. +/// +/// * `verbosity` — 0 = WARN (quiet), 1 = INFO, 2 = DEBUG, 3+ = TRACE. +/// * If `DG_LOG` or `RUST_LOG` is set in the environment, it takes precedence. +pub fn init(verbosity: u8) { + let default_directive = match verbosity { + 0 => "warn", + 1 => "info", + 2 => "debug", + _ => "trace", + }; + + let filter = if let Ok(env) = std::env::var("DG_LOG").or_else(|_| std::env::var("RUST_LOG")) { + EnvFilter::new(env) + } else { + EnvFilter::new(default_directive) + }; + + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter(filter) + .with_target(false) + .init(); +} diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs new file mode 100644 index 000000000..f61ddb0f7 --- /dev/null +++ b/src-tauri/client-cli/src/mfa.rs @@ -0,0 +1,518 @@ +//! Connect-time VPN MFA thin wrapper over `defguard_core::mfa`. +//! +//! Supports TOTP, email, OIDC, and mobile-approve methods. +//! +//! CLI-specific code (method resolution from flags, browser-open, QR +//! rendering, TTY prompting) stays here; all HTTP, WebSocket, and poll +//! logic delegates to `defguard_core::mfa`. + +use defguard_client_proto::defguard::{ + client_types::MfaMethod, enterprise::posture::v2::DevicePostureData, +}; +use defguard_core::{ + database::{ + models::{ + instance::Instance, + location::{infer_mfa_method, Location, LocationMfaMethod, LocationMfaMode}, + wireguard_keys::WireguardKeys, + Id, + }, + DbPool, + }, + mfa, + proto::client_types::{ClientMfaFinishRequest, ClientMfaStartRequest}, +}; +use secrecy::{ExposeSecret, SecretString}; +use tracing::{debug, info, warn}; +use url::Url; + +use crate::{ + mfa_code::{obtain_code, CodeSource, MfaContext}, + mfa_qr, + state::CliError, +}; + +/// Convert a `defguard_core::mfa::MfaError` into a [`CliError`]. +fn into_cli(err: mfa::MfaError) -> CliError { + let msg = err.to_string(); + match err { + mfa::MfaError::NetworkError { .. } + | mfa::MfaError::ProxyError { .. } + | mfa::MfaError::Other { .. } => CliError::Other(msg), + mfa::MfaError::MfaRejected { .. } + | mfa::MfaError::PostureRejected { .. } + | mfa::MfaError::Timeout => CliError::MfaFailed(msg), + mfa::MfaError::Cancelled => CliError::Cancelled(msg), + } +} + +/// Resolve the effective MFA method for a location. +/// +/// When `method_override` is `Some`, parses it into [`MfaMethod`]; otherwise +/// delegates to [`infer_method`] which respects the location's +/// [`LocationMfaMode`]. +/// +/// Rejects `--mfa-method oidc` on Internal-mode locations. +pub(crate) fn resolve_method( + location: &Location, + method_override: Option<&str>, +) -> Result { + let method = if let Some(raw) = method_override { + let method = parse_method(raw)?; + // OIDC override on an Internal-mode location will be rejected by the + // server. Fail early to give the user a clear error before I/O. + if method == MfaMethod::Oidc && location.location_mfa_mode == LocationMfaMode::Internal { + return Err(CliError::InvalidInput( + "--mfa-method oidc is only valid for locations that use external (OIDC) MFA." + .into(), + )); + } + method + } else { + infer_method(location) + }; + + Ok(method) +} + +/// Validate CLI flags against the resolved MFA method. +/// +/// * `--code` / `--code-command` are incompatible with OIDC and mobile-approve +/// (neither method accepts textual codes). +/// * `--qr-file` is only valid for mobile-approve MFA. +pub(crate) fn validate_mfa_flags( + method: MfaMethod, + location_name: &str, + code: Option<&str>, + code_command: Option<&str>, + qr_file: Option<&str>, +) -> Result<(), CliError> { + if matches!(method, MfaMethod::Oidc | MfaMethod::MobileApprove) + && (code.is_some() || code_command.is_some()) + { + return Err(CliError::InvalidInput(format!( + "location '{location_name}' cannot use --code / --code-command with {method:?} MFA", + ))); + } + + if method != MfaMethod::MobileApprove && qr_file.is_some() { + return Err(CliError::InvalidInput( + "--qr-file is only valid with mobile-approve MFA".into(), + )); + } + + Ok(()) +} + +/// Run the VPN MFA handshake for a location (TOTP or email). +/// +/// The HTTP calls are handled by `defguard_core::mfa`; this function +/// handles CLI-specific code sourcing (TTY / --code / --code-command). +pub(crate) async fn authorize( + location: &Location, + source: &CodeSource, + instance: &Instance, + method: MfaMethod, + posture_data: Option, + pool: &DbPool, +) -> Result { + // Reject methods not yet supported by the CLI before doing any I/O. + // OIDC/MobileApprove are not "unsupported" - they have dedicated code + // paths (authorize_oidc / authorize_mobile_approve). This catch-all is a + // defense-in-depth barrier that emits a clear error if they land here. + match method { + MfaMethod::Biometric => { + return Err(CliError::MfaFailed(format!( + "MFA method {method:?} is not supported by the CLI. Use the mobile client." + ))); + } + MfaMethod::MobileApprove => { + return Err(CliError::Other( + "Internal error: MobileApprove MFA must use authorize_mobile_approve, not authorize" + .into(), + )); + } + MfaMethod::Oidc => { + return Err(CliError::Other( + "Internal error: OIDC MFA must use authorize_oidc, not authorize".into(), + )); + } + _ => {} + } + + let wireguard_keys = WireguardKeys::find_by_instance_id(pool, instance.id) + .await + .map_err(|e| CliError::Other(e.to_string()))? + .ok_or_else(|| { + CliError::Other(format!( + "WireGuard keys not found for instance {}", + instance.name + )) + })?; + + let proxy_url = Url::parse(&instance.proxy_url) + .map_err(|e| CliError::Other(format!("Invalid proxy URL: {e}")))?; + check_proxy_scheme(&proxy_url); + + debug!("Starting MFA session for location {}", location.name); + let request = ClientMfaStartRequest { + location_id: location.network_id, + pubkey: wireguard_keys.pubkey, + method: method as i32, + posture_data, + }; + let info = mfa::mfa_start(proxy_url.clone(), request) + .await + .map_err(into_cli)?; + + let ctx = MfaContext { + instance: instance.name.clone(), + location: location.name.clone(), + }; + let code = obtain_code(source, &ctx)?; + + let finish_req = ClientMfaFinishRequest { + token: info.token, + code: Some(code.expose_secret().to_string()), + auth_pub_key: None, + }; + let psk = mfa::mfa_finish_code(proxy_url, finish_req) + .await + .map_err(into_cli)?; + + info!("MFA session completed, preshared key obtained"); + Ok(SecretString::from(psk.preshared_key)) +} + +/// Run the OIDC MFA flow for an external-IdP location. +/// +/// Opens the system browser and delegates the HTTP poll to +/// `defguard_core::mfa::poll_openid_mfa`. +/// +/// When `json_mode` is true, progress messages on stderr are suppressed so +/// that `--json` output consumers only see the final result/error. +pub(crate) async fn authorize_oidc( + location: &Location, + instance: &Instance, + posture_data: Option, + pool: &DbPool, + json_mode: bool, +) -> Result { + let wireguard_keys = WireguardKeys::find_by_instance_id(pool, instance.id) + .await + .map_err(|e| CliError::Other(e.to_string()))? + .ok_or_else(|| { + CliError::Other(format!( + "WireGuard keys not found for instance {}", + instance.name + )) + })?; + + let proxy_url = Url::parse(&instance.proxy_url) + .map_err(|e| CliError::Other(format!("Invalid proxy URL: {e}")))?; + check_proxy_scheme(&proxy_url); + + debug!("Starting OIDC MFA session for location {}", location.name); + let request = ClientMfaStartRequest { + location_id: location.network_id, + pubkey: wireguard_keys.pubkey, + method: MfaMethod::Oidc as i32, + posture_data, + }; + let info = mfa::mfa_start(proxy_url.clone(), request) + .await + .map_err(into_cli)?; + + let mut browser_url = proxy_url + .join("openid/mfa") + .map_err(|e| CliError::Other(format!("Failed to build OIDC MFA URL: {e}")))?; + browser_url + .query_pairs_mut() + .append_pair("token", &info.token); + + if !json_mode { + eprintln!("Open this URL to authenticate:"); + eprintln!(" {browser_url}"); + eprintln!("Waiting for authentication... (Ctrl-C to cancel)"); + } + open_url(browser_url.as_ref(), json_mode); + + let cancel = tokio_util::sync::CancellationToken::new(); + let cancel_clone = cancel.clone(); + let ctrlc_handle = tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + cancel_clone.cancel(); + }); + + let result = mfa::poll_openid_mfa(proxy_url, info.token, cancel).await; + ctrlc_handle.abort(); + + let psk = result.map_err(into_cli)?; + info!("OIDC MFA session completed, preshared key obtained"); + Ok(SecretString::from(psk.preshared_key)) +} + +/// Run the mobile-approve MFA flow. +/// +/// Displays a QR code (terminal and/or `--qr-file` PNG) and delegates the +/// WebSocket connection to `defguard_core::mfa::connect_mobile_approve`. +/// +/// When `json_mode` is true, progress messages on stderr are suppressed so +/// that `--json` output consumers only see the final result/error. +pub(crate) async fn authorize_mobile_approve( + location: &Location, + instance: &Instance, + posture_data: Option, + qr_file: Option<&str>, + pool: &DbPool, + json_mode: bool, +) -> Result { + let wireguard_keys = WireguardKeys::find_by_instance_id(pool, instance.id) + .await + .map_err(|e| CliError::Other(e.to_string()))? + .ok_or_else(|| { + CliError::Other(format!( + "WireGuard keys not found for instance {}", + instance.name + )) + })?; + + let proxy_url = Url::parse(&instance.proxy_url) + .map_err(|e| CliError::Other(format!("Invalid proxy URL: {e}")))?; + check_proxy_scheme(&proxy_url); + + debug!( + "Starting mobile-approve MFA session for location {}", + location.name + ); + let request = ClientMfaStartRequest { + location_id: location.network_id, + pubkey: wireguard_keys.pubkey, + method: MfaMethod::MobileApprove as i32, + posture_data, + }; + let info = mfa::mfa_start(proxy_url.clone(), request) + .await + .map_err(into_cli)?; + + let challenge = info.challenge.ok_or_else(|| { + CliError::Other("Proxy did not return a challenge for mobile-approve MFA".into()) + })?; + + let payload = mfa_qr::build_qr_payload(&info.token, &challenge, &instance.uuid); + mfa_qr::render_qr(&payload, qr_file, json_mode)?; + if !json_mode { + eprintln!("Waiting for mobile approval... (Ctrl-C to cancel)"); + } + + let ws_url = mfa::derive_ws_url(&proxy_url, &info.token).map_err(into_cli)?; + + let cancel = tokio_util::sync::CancellationToken::new(); + let cancel_clone = cancel.clone(); + let ctrlc_handle = tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + cancel_clone.cancel(); + }); + + let result = mfa::connect_mobile_approve(&ws_url, cancel).await; + ctrlc_handle.abort(); + + let psk = result.map_err(into_cli)?; + info!("Mobile-approve MFA completed, preshared key obtained"); + Ok(SecretString::from(psk.preshared_key)) +} + +/// Parse a `--mfa-method` flag string into the proto [`MfaMethod`] enum. +fn parse_method(raw: &str) -> Result { + match raw.to_lowercase().as_str() { + "totp" => Ok(MfaMethod::Totp), + "email" => Ok(MfaMethod::Email), + "oidc" => Ok(MfaMethod::Oidc), + "biometric" => Ok(MfaMethod::Biometric), + "mobile" | "mobile_approve" => Ok(MfaMethod::MobileApprove), + _ => Err(CliError::Usage(format!( + "Invalid --mfa-method '{raw}'. Valid: totp, email, oidc, biometric, mobile." + ))), + } +} + +/// Determine the MFA method to use for a location. +/// +/// Delegates to the core's [`infer_mfa_method`] so that [`LocationMfaMode`] +/// is respected - an External-mode location always uses OIDC, while an +/// Internal-mode location respects the stored preference (defaulting to TOTP). +fn infer_method(location: &Location) -> MfaMethod { + let method = infer_mfa_method(location.location_mfa_mode, location.mfa_method); + match method { + Some(LocationMfaMethod::Totp) => MfaMethod::Totp, + Some(LocationMfaMethod::Email) => MfaMethod::Email, + Some(LocationMfaMethod::Oidc) => MfaMethod::Oidc, + Some(LocationMfaMethod::Biometric) => MfaMethod::Biometric, + Some(LocationMfaMethod::MobileApprove) => MfaMethod::MobileApprove, + None => { + // infer_mfa_method only returns None for Disabled mode, but this is + // only called when MFA is enabled. Default to TOTP as a safe fallback. + MfaMethod::Totp + } + } +} + +/// Warn if the proxy is not using HTTPS. +/// +/// The one-time MFA code and the returned preshared key are sensitive and +/// would travel in cleartext over plain HTTP. +fn check_proxy_scheme(proxy_base: &Url) { + if proxy_base.scheme() != "https" { + warn!( + "Proxy URL '{}' is not HTTPS; secrets will be sent in cleartext.", + proxy_base.as_str() + ); + } +} + +/// Open a URL in the system browser. +/// +/// Production: calls [`webbrowser::open`]; prints a hint to stderr on failure. +/// When `json_mode` is true, the fallback message includes the URL itself since +/// it wasn't already printed above. +/// Tests: no-op (never spawn a browser). +#[cfg(not(test))] +fn open_url(url: &str, json_mode: bool) { + if webbrowser::open(url).is_err() { + if json_mode { + eprintln!("Could not open browser. Open this URL manually: {url}"); + } else { + eprintln!("Could not open browser. Open the URL above manually."); + } + } +} + +#[cfg(test)] +fn open_url(_url: &str, _json_mode: bool) { + // no-op: tests must not spawn a browser +} + +#[cfg(test)] +mod tests { + use defguard_core::database::models::location::ServiceLocationMode; + + use super::*; + + fn location(name: &str, mode: LocationMfaMode) -> Location { + Location { + id: 1, + instance_id: 1, + network_id: 1, + name: name.into(), + address: "10.0.0.0/24".into(), + pubkey: "pk".into(), + endpoint: "1.2.3.4:51820".into(), + allowed_ips: "0.0.0.0/0".into(), + dns: None, + route_all_traffic: false, + keepalive_interval: 25, + location_mfa_mode: mode, + service_location_mode: ServiceLocationMode::Disabled, + mfa_method: None, + posture_check_required: false, + } + } + + #[test] + fn test_oidc_location_resolves_to_oidc() { + let l = location("office", LocationMfaMode::External); + let method = resolve_method(&l, None).unwrap(); + assert_eq!(method, MfaMethod::Oidc); + } + + #[test] + fn test_internal_location_resolves_to_totp() { + let l = location("office", LocationMfaMode::Internal); + let method = resolve_method(&l, None).unwrap(); + assert_eq!(method, MfaMethod::Totp); + } + + #[test] + fn test_validate_flags_oidc_rejects_code() { + let err = + validate_mfa_flags(MfaMethod::Oidc, "office", Some("123456"), None, None).unwrap_err(); + assert!(matches!(err, CliError::InvalidInput(_))); + assert!(err.to_string().contains("--code")); + } + + #[test] + fn test_validate_flags_oidc_rejects_code_command() { + let err = validate_mfa_flags(MfaMethod::Oidc, "office", None, Some("pass otp"), None) + .unwrap_err(); + assert!(matches!(err, CliError::InvalidInput(_))); + assert!(err.to_string().contains("--code")); + } + + #[test] + fn test_validate_flags_mobile_approve_rejects_code() { + let err = validate_mfa_flags( + MfaMethod::MobileApprove, + "office", + Some("123456"), + None, + None, + ) + .unwrap_err(); + assert!(matches!(err, CliError::InvalidInput(_))); + assert!(err.to_string().contains("--code")); + } + + #[test] + fn test_validate_flags_mobile_approve_rejects_code_command() { + let err = validate_mfa_flags( + MfaMethod::MobileApprove, + "office", + None, + Some("pass otp"), + None, + ) + .unwrap_err(); + assert!(matches!(err, CliError::InvalidInput(_))); + assert!(err.to_string().contains("--code")); + } + + #[test] + fn test_validate_flags_qr_file_only_for_mobile_approve() { + let err = + validate_mfa_flags(MfaMethod::Totp, "office", None, None, Some("qr.png")).unwrap_err(); + assert!(matches!(err, CliError::InvalidInput(_))); + assert!(err.to_string().contains("qr-file")); + } + + #[test] + fn test_validate_flags_qr_file_ok_for_mobile_approve() { + validate_mfa_flags( + MfaMethod::MobileApprove, + "office", + None, + None, + Some("qr.png"), + ) + .unwrap(); + } + + #[test] + fn test_validate_flags_pass_through_totp() { + validate_mfa_flags(MfaMethod::Totp, "office", Some("123456"), None, None).unwrap(); + } + + #[test] + fn test_no_code_with_oidc_passes() { + let l = location("office", LocationMfaMode::External); + let method = resolve_method(&l, None).unwrap(); + assert_eq!(method, MfaMethod::Oidc); + } + + #[test] + fn test_mfa_method_oidc_on_internal_rejected() { + let l = location("office", LocationMfaMode::Internal); + let err = resolve_method(&l, Some("oidc")).unwrap_err(); + assert!(matches!(err, CliError::InvalidInput(_))); + assert!(err.to_string().contains("oidc")); + } +} diff --git a/src-tauri/client-cli/src/mfa_code.rs b/src-tauri/client-cli/src/mfa_code.rs new file mode 100644 index 000000000..928f7d9cd --- /dev/null +++ b/src-tauri/client-cli/src/mfa_code.rs @@ -0,0 +1,182 @@ +//! Resolve the MFA proof across three input sources. +//! +//! Priority order: `--code` > `--code-command` > interactive TTY prompt. +//! Non-TTY + no code or command → `MfaInputRequired` error. +//! +//! The returned value is wrapped in [`secrecy::SecretString`] so it never +//! appears in logs, debug output, or error messages. + +use std::{ + fmt, + io::{stderr, stdin, IsTerminal, Write}, + process::Command, +}; + +use secrecy::SecretString; +use tracing::debug; + +use crate::state::CliError; + +/// Describes where to source the MFA code from. +/// +/// Manual [`Debug`] impl redacts the `Literal` variant so `--code ` +/// never leaks into logs or error output. +#[derive(Clone)] +pub enum CodeSource { + /// Literal value from `--code <6-digit>`. + Literal(String), + /// Shell command whose stdout yields the code (`--code-command`). + Command(String), + /// Read interactively from the terminal. + Interactive, +} + +impl fmt::Debug for CodeSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Literal(_) => f.debug_tuple("Literal").field(&"").finish(), + Self::Command(cmd) => f.debug_tuple("Command").field(cmd).finish(), + Self::Interactive => f.debug_tuple("Interactive").finish(), + } + } +} + +/// Context passed to `--code-command` via environment variables. +pub struct MfaContext { + /// `DG_INSTANCE` - the instance name. + pub instance: String, + /// `DG_LOCATION` - the location name. + pub location: String, +} + +/// Obtain a TOTP/email code from the configured source. +pub fn obtain_code(source: &CodeSource, ctx: &MfaContext) -> Result { + match source { + CodeSource::Literal(code) => { + debug!("Using --code value"); + Ok(SecretString::from(code.trim())) + } + CodeSource::Command(cmd) => { + debug!("Running --code-command"); + let output = Command::new("sh") + .arg("-c") + .arg(cmd) + .env("DG_INSTANCE", &ctx.instance) + .env("DG_LOCATION", &ctx.location) + .output() + .map_err(|e| CliError::MfaFailed(format!("Failed to run code command: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(CliError::MfaFailed(format!( + "Code command exited with {}: {}", + output.status, + stderr.trim() + ))); + } + + let code = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if code.is_empty() { + return Err(CliError::MfaFailed( + "Code command produced no output".into(), + )); + } + Ok(SecretString::from(code.as_str())) + } + CodeSource::Interactive => { + if !stdin().is_terminal() { + return Err(CliError::MfaInputRequired( + "No TTY available for interactive MFA code entry. Provide --code or --code-command." + .into(), + )); + } + + // N.B. stderr - stdout is reserved for data. + eprint!("Enter MFA code for {}: ", ctx.location); + stderr().flush().ok(); + + let mut code = String::new(); + stdin() + .read_line(&mut code) + .map_err(|e| CliError::MfaFailed(format!("Failed to read code: {e}")))?; + + Ok(SecretString::from(code.trim())) + } + } +} + +#[cfg(test)] +mod tests { + use secrecy::ExposeSecret; + + use super::*; + + fn ctx() -> MfaContext { + MfaContext { + instance: "test-inst".into(), + location: "test-loc".into(), + } + } + + #[test] + fn test_literal_code_returns_trimmed_secret() { + let source = CodeSource::Literal(" 123456 ".into()); + let secret = obtain_code(&source, &ctx()).unwrap(); + assert_eq!(secret.expose_secret(), "123456"); + } + + #[test] + #[ignore = "`echo -n` is not portable"] + fn test_command_produces_stdout() { + let source = CodeSource::Command("echo -n 654321".into()); + let secret = obtain_code(&source, &ctx()).unwrap(); + assert_eq!(secret.expose_secret(), "654321"); + } + + #[test] + fn test_command_failure_is_mfa_failed() { + let source = CodeSource::Command("exit 2".into()); + let err = obtain_code(&source, &ctx()).unwrap_err(); + assert!(matches!(err, CliError::MfaFailed(_))); + assert!(err.to_string().contains("exited")); + } + + #[test] + fn test_command_empty_output_is_mfa_failed() { + let source = CodeSource::Command("true".into()); // produces no stdout + let err = obtain_code(&source, &ctx()).unwrap_err(); + assert!(matches!(err, CliError::MfaFailed(_))); + assert!(err.to_string().contains("no output")); + } + + #[test] + #[ignore = "`echo -n` is not portable"] + fn test_command_receives_env_vars() { + // Print the env vars to stdout so we can assert they're set. + let source = CodeSource::Command("echo -n $DG_INSTANCE/$DG_LOCATION".into()); + let secret = obtain_code(&source, &ctx()).unwrap(); + assert_eq!(secret.expose_secret(), "test-inst/test-loc"); + } + + #[test] + fn test_literal_is_redacted_in_debug() { + let source = CodeSource::Literal("secret123".into()); + let debug = format!("{source:?}"); + assert!(!debug.contains("secret123")); + assert!(debug.contains("")); + } + + #[test] + fn test_command_is_not_redacted_in_debug() { + let source = CodeSource::Command("echo code".into()); + let debug = format!("{source:?}"); + assert!(debug.contains("echo code")); + } + + #[test] + fn test_interactive_shows_in_debug() { + let source = CodeSource::Interactive; + let debug = format!("{source:?}"); + assert!(debug.contains("Interactive")); + } +} diff --git a/src-tauri/client-cli/src/mfa_qr.rs b/src-tauri/client-cli/src/mfa_qr.rs new file mode 100644 index 000000000..9da9cce7d --- /dev/null +++ b/src-tauri/client-cli/src/mfa_qr.rs @@ -0,0 +1,133 @@ +//! Mobile-approve MFA QR code payload construction and rendering. + +#[cfg(not(test))] +use std::io::{stderr, IsTerminal}; +use std::path::Path; + +use base64::{prelude::BASE64_STANDARD, Engine as _}; +#[cfg(not(test))] +use image::imageops::{resize, FilterType}; +use image::Luma; +#[cfg(not(test))] +use qrcode::render::unicode::Dense1x2; +use qrcode::QrCode; +use serde_json::json; + +use crate::state::CliError; + +#[cfg(not(test))] +// Target minimum size (in pixels) for QR PNG output. +const QR_PNG_MIN_SIZE: u32 = 300; + +/// Build the base64-encoded QR payload for mobile-approve MFA. +/// +/// QR payload format: +/// Base64(JSON{token, challenge, instance_id}) +pub(crate) fn build_qr_payload(token: &str, challenge: &str, instance_id: &str) -> String { + let json = json!({ + "token": token, + "challenge": challenge, + "instance_id": instance_id, + }); + let raw = serde_json::to_string(&json).expect("JSON serialization is infallible"); + BASE64_STANDARD.encode(raw.as_bytes()) +} + +/// Render the QR code for a payload string to available output(s). +/// +/// * When **stderr is a TTY** and `json_mode` is false, prints a Unicode +/// `Dense1x2` QR to stderr. +/// * When **`qr_file` is `Some`**, always writes a PNG image to that path +/// (regardless of `json_mode` - the file is machine-readable output). +/// * If **neither** output is viable (non-TTY + no `qr_file`), returns +/// [`CliError::InvalidInput`] with guidance to use `--qr-file`. +/// +/// Terminal and file outputs are independent: when both are available +/// and `!json_mode`, the user sees the terminal QR *and* gets a PNG file. +#[cfg(not(test))] +pub(crate) fn render_qr( + payload: &str, + qr_file: Option<&str>, + json_mode: bool, +) -> Result<(), CliError> { + let is_tty = stderr().is_terminal(); + + if !is_tty && qr_file.is_none() { + return Err(CliError::InvalidInput( + "No QR display available (stderr is not a TTY). \ + Use --qr-file to save the QR as a PNG image." + .into(), + )); + } + + if is_tty && !json_mode { + let code = QrCode::new(payload.as_bytes()) + .map_err(|e| CliError::Other(format!("Failed to generate QR code: {e}")))?; + let rendered = code.render::().build(); + eprintln!("{rendered}"); + } + + if let Some(path) = qr_file { + let code = QrCode::new(payload.as_bytes()) + .map_err(|e| CliError::Other(format!("Failed to generate QR code: {e}")))?; + let image = code.render::>().build(); + // Scale up so the QR is large enough to scan. + // Nearest-neighbour preserves sharp module edges. + let max_dim = image.width().max(image.height()); + let scale = (QR_PNG_MIN_SIZE + max_dim - 1) / max_dim.max(1); + let scaled = resize( + &image, + image.width() * scale, + image.height() * scale, + FilterType::Nearest, + ); + scaled + .save(Path::new(path)) + .map_err(|e| CliError::Other(format!("Failed to save QR image: {e}")))?; + } + + Ok(()) +} + +#[cfg(test)] +pub(crate) fn render_qr( + payload: &str, + qr_file: Option<&str>, + _json_mode: bool, +) -> Result<(), CliError> { + // Test mode: never render to the terminal. Write to --qr-file + // only so that integration tests can verify the file was produced. + if let Some(path) = qr_file { + let code = QrCode::new(payload.as_bytes()) + .map_err(|e| CliError::Other(format!("Failed to generate QR code: {e}")))?; + let image = code.render::>().build(); + image + .save(Path::new(path)) + .map_err(|e| CliError::Other(format!("Failed to save QR image: {e}")))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_qr_payload_bytes() { + let payload = build_qr_payload("tok-abc", "chal-xyz", "uuid-001"); + // Decode and verify the JSON structure. + let decoded = BASE64_STANDARD.decode(&payload).expect("valid base64"); + let json: serde_json::Value = serde_json::from_slice(&decoded).expect("valid JSON"); + assert_eq!(json["token"], "tok-abc"); + assert_eq!(json["challenge"], "chal-xyz"); + assert_eq!(json["instance_id"], "uuid-001"); + } + + #[test] + fn test_build_qr_payload_deterministic() { + // Same inputs must produce identical payloads. + let a = build_qr_payload("tok", "chal", "inst"); + let b = build_qr_payload("tok", "chal", "inst"); + assert_eq!(a, b); + } +} diff --git a/src-tauri/client-cli/src/monitor.rs b/src-tauri/client-cli/src/monitor.rs new file mode 100644 index 000000000..ae776b976 --- /dev/null +++ b/src-tauri/client-cli/src/monitor.rs @@ -0,0 +1,53 @@ +use chrono::Utc; +use defguard_core::connection::{ + active_state::{active_state, ActiveConnectionInfo}, + tear_down, +}; +use tracing::error; + +use crate::state::State; + +/// Determine whether a connection is stale based on its latest WireGuard handshake. +/// +/// Returns `None` when live backend stats are unavailable or the connection has no +/// recorded handshake, because in that case the CLI cannot safely decide whether the +/// connection is stale. +fn is_stale(connection: &ActiveConnectionInfo, peer_alive_period: u32) -> bool { + if let Some(stats) = connection.stats.as_ref() { + if let Some(last_handshake) = stats.last_handshake { + let now = Utc::now().timestamp() as u64; + return now.saturating_sub(last_handshake) > u64::from(peer_alive_period); + } + } + false +} + +/// Disconnect active connections whose latest handshake is older than the configured +/// peer alive period. +/// +/// Connections without usable live stats are left untouched. Failures are logged and do +/// not stop cleanup of the remaining connections. +pub async fn tear_down_stale_connections(state: &State) { + let connections = match active_state(&state.pool).await { + Ok(connections) => connections, + Err(err) => { + error!("Failed to retrieve active connections: {err}"); + return; + } + }; + if connections.is_empty() { + return; + } + + let peer_alive_period = state.app_config.peer_alive_period; + + for connection in connections { + if is_stale(&connection, peer_alive_period) { + eprintln!("Removing stale connection {}", connection.name); + let result = tear_down(&connection).await; + if let Err(err) = result { + error!("Error removing stale connection {}: {err}", connection.name); + } + } + } +} diff --git a/src-tauri/client-cli/src/output.rs b/src-tauri/client-cli/src/output.rs new file mode 100644 index 000000000..3b4a8f0c8 --- /dev/null +++ b/src-tauri/client-cli/src/output.rs @@ -0,0 +1,130 @@ +use std::process::ExitCode; + +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::{exit, state::CliError}; + +/// Typed command output that owns both human and JSON representations. +pub trait CommandOutput { + /// Produce a human-readable string (no trailing newline required). + fn human(&self) -> String; + /// Produce a structured JSON value. + fn json(&self) -> Value; + /// Exit code override; defaults to 0 (success). + fn exit_code(&self) -> u8 { + 0 + } +} + +/// Render a `CommandOutput` value as either JSON or human-readable output. +pub fn emit(value: &T, json: bool) { + if json { + println!( + "{}", + serde_json::to_string_pretty(&value.json()) + .unwrap_or_else(|e| json!({ "error": e.to_string() }).to_string()) + ); + } else { + println!("{}", value.human()); + } +} + +#[derive(Serialize)] +struct JsonError { + kind: String, + message: String, +} + +/// Render an error. Under `--json`, prints a `{ "kind", "message" }` object. +pub fn emit_error(err: &CliError, json: bool) { + if json { + let je = JsonError { + kind: error_kind(err), + message: err.to_string(), + }; + eprintln!( + "{}", + serde_json::to_string(&je) + .unwrap_or_else(|e| json!({ "error": e.to_string() }).to_string()) + ); + } else { + eprintln!("Error: {err}"); + } +} + +#[derive(Serialize)] +pub struct InstanceEntry { + pub id: i64, + pub name: String, + pub url: String, +} + +#[derive(Serialize)] +pub struct LocationEntry { + pub id: i64, + pub name: String, + pub instance: Option, + pub address: String, + pub endpoint: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mfa_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mfa_method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub route_all_traffic: Option, +} + +#[derive(Serialize)] +pub struct TunnelEntry { + pub id: i64, + pub name: String, + pub address: String, + pub endpoint: String, +} + +#[derive(Serialize)] +pub struct ActiveEntry { + pub connection_type: String, + pub name: String, + pub interface: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listen_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tx_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rx_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_handshake_secs: Option, +} + +fn error_kind(err: &CliError) -> String { + match err { + CliError::Usage(_) => "usage".into(), + CliError::NotFound(_) => "notFound".into(), + CliError::DaemonUnavailable(_) => "unavailable".into(), + CliError::MfaFailed(_) => "mfaFailed".into(), + CliError::MfaInputRequired(_) => "mfaInputRequired".into(), + CliError::NotEnrolled(_) => "notEnrolled".into(), + CliError::InvalidInput(_) => "invalidInput".into(), + CliError::Cancelled(_) => "cancelled".into(), + CliError::Database(_) => "database".into(), + CliError::Other(_) => "other".into(), + } +} + +/// Finalize a `CommandOutput` result, emit output, and return the exit code. +pub fn finish(result: Result, json: bool) -> ExitCode { + match result { + Ok(output) => { + let code = output.exit_code(); + emit(&output, json); + ExitCode::from(code) + } + Err(err) => { + let code = exit::exit_code_for(&err); + emit_error(&err, json); + ExitCode::from(code) + } + } +} diff --git a/src-tauri/client-cli/src/polling.rs b/src-tauri/client-cli/src/polling.rs new file mode 100644 index 000000000..af790b1f8 --- /dev/null +++ b/src-tauri/client-cli/src/polling.rs @@ -0,0 +1,84 @@ +use std::collections::{HashMap, HashSet}; + +use defguard_client_config_sync::{poll_instances, PollInstanceResult}; +use defguard_core::{ + connection::active_state::active_state, + database::models::{location::Location, Id}, + error::Error, + ConnectionType, +}; +use tracing::debug; + +use crate::state::State; + +pub async fn poll_config(state: &State) { + let active_instance_ids = match active_instance_ids(state).await { + Ok(ids) => ids, + Err(err) => { + debug!("Skipping configuration polling, failed to detect active connections: {err}"); + return; + } + }; + + let outcomes = match poll_instances(&state.pool, &active_instance_ids).await { + Ok(outcomes) => outcomes, + Err(err) => { + debug!("Skipping configuration polling: {err}"); + return; + } + }; + + for outcome in outcomes { + match outcome.result { + Ok(PollInstanceResult::ChangedWhileActive { .. }) => { + eprintln!( + "Instance {} configuration changed, disconnect to apply changes", + outcome.instance_name + ); + } + Ok(PollInstanceResult::Updated { .. } | PollInstanceResult::Unchanged { .. }) => {} + Err(Error::CoreNotEnterprise) => { + debug!( + "Instance {} is not enterprise, skipping configuration polling", + outcome.instance_name + ); + } + Err(Error::NoToken) => { + debug!( + "Instance {} has no polling token, skipping configuration polling", + outcome.instance_name + ); + } + Err(err) => { + debug!( + "Failed to poll configuration for instance {}: {err}", + outcome.instance_name + ); + } + } + } +} + +async fn active_instance_ids(state: &State) -> Result, Error> { + let active_location_ids = active_state(&state.pool) + .await? + .into_iter() + .filter(|connection| connection.connection_type == ConnectionType::Location) + .map(|connection| connection.target_id) + .collect::>(); + + if active_location_ids.is_empty() { + return Ok(HashSet::new()); + } + + let location_instances = Location::all(&state.pool, false) + .await? + .into_iter() + .map(|location| (location.id, location.instance_id)) + .collect::>(); + + Ok(active_location_ids + .into_iter() + .filter_map(|location_id| location_instances.get(&location_id).copied()) + .collect()) +} diff --git a/src-tauri/client-cli/src/resolve.rs b/src-tauri/client-cli/src/resolve.rs new file mode 100644 index 000000000..645845657 --- /dev/null +++ b/src-tauri/client-cli/src/resolve.rs @@ -0,0 +1,583 @@ +use defguard_core::database::{ + models::{instance::Instance, location::Location, tunnel::Tunnel, Id}, + DbPool, +}; + +use crate::state::CliError; + +/// The user's target specification, parsed from CLI arguments. +pub struct TargetSpec { + pub name: Option, + pub tunnel: bool, + pub id: Option, + pub instance: Option, +} + +/// A resolved connection target. +pub enum ResolvedTarget { + Location(Location), + Tunnel(Tunnel), +} + +/// Resolve a target for the `connect` command. Rejects service locations. +pub async fn resolve_connect_target( + spec: &TargetSpec, + pool: &DbPool, +) -> Result { + let target = resolve_target(spec, pool).await?; + + if let ResolvedTarget::Location(location) = &target { + if location.is_service_location() { + return Err(CliError::InvalidInput(format!( + "'{}' is a service location and is managed by the defguard service", + location.name + ))); + } + } + + Ok(target) +} + +/// Resolves the CLI target before applying command-specific validation. +async fn resolve_target(spec: &TargetSpec, pool: &DbPool) -> Result { + // --id fast path + if let Some(id) = spec.id { + if spec.tunnel { + let tun = Tunnel::find_by_id(pool, id) + .await? + .ok_or_else(|| CliError::NotFound(format!("No tunnel with id {id}")))?; + return Ok(ResolvedTarget::Tunnel(tun)); + } + if let Some(loc) = Location::find_by_id(pool, id).await? { + return Ok(ResolvedTarget::Location(loc)); + } + if let Some(tun) = Tunnel::find_by_id(pool, id).await? { + return Ok(ResolvedTarget::Tunnel(tun)); + } + return Err(CliError::NotFound(format!( + "No location or tunnel with id {id}" + ))); + } + + // Named target + if let Some(ref name) = spec.name { + return resolve_named(name, spec.tunnel, spec.instance.as_deref(), pool).await; + } + + // No-arg: pick sole location + resolve_sole_location(pool).await +} + +/// Resolve a target for the `disconnect` command. +/// +/// No-arg / --all resolution is handled directly in the disconnect handler +/// using `active_state`; this function is only called when a target is named. +pub async fn resolve_disconnect_target( + spec: &TargetSpec, + pool: &DbPool, +) -> Result { + resolve_connect_target(spec, pool).await +} + +async fn resolve_named( + name: &str, + tunnel_only: bool, + instance_filter: Option<&str>, + pool: &DbPool, +) -> Result { + if tunnel_only { + let tunnels = Tunnel::find_by_name(pool, name).await?; + return match tunnels.len() { + 0 => Err(CliError::NotFound(format!("Tunnel '{name}' not found"))), + 1 => Ok(ResolvedTarget::Tunnel( + tunnels + .into_iter() + .next() + .expect("exactly one tunnel expected after length check"), + )), + _ => Err(CliError::NotFound(format!( + "Multiple tunnels named '{name}'" + ))), + }; + } + + // Fetch matching locations by name. Instance filter is applied in Rust + // since cross-instance ambiguity is a business rule, not a query concern. + let loc_matches = if let Some(inst_name) = instance_filter { + let inst = Instance::find_by_name(pool, inst_name) + .await? + .ok_or_else(|| CliError::NotFound(format!("Instance '{inst_name}' not found")))?; + Location::find_by_name(pool, name) + .await? + .into_iter() + .filter(|l| l.instance_id == inst.id) + .collect::>() + } else { + Location::find_by_name(pool, name).await? + }; + + let tun_matches = Tunnel::find_by_name(pool, name).await?; + + match (loc_matches.len(), tun_matches.len()) { + (0, 0) => Err(CliError::NotFound(format!("'{name}' not found"))), + (1, 0) => Ok(ResolvedTarget::Location( + loc_matches + .into_iter() + .next() + .expect("exactly one location expected after length check"), + )), + (0, 1) => Ok(ResolvedTarget::Tunnel( + tun_matches + .into_iter() + .next() + .expect("exactly one tunnel expected after length check"), + )), + (1, 1) => Err(CliError::NotFound(format!( + "'{name}' matches both a location and a tunnel. Use --tunnel." + ))), + (n, 0) if n > 1 => Err(CliError::NotFound(format!( + "'{name}' exists in multiple instances. Use --instance to pick one." + ))), + _ => Err(CliError::NotFound(format!( + "'{name}' matches multiple targets" + ))), + } +} + +async fn resolve_sole_location(pool: &DbPool) -> Result { + let locations = Location::all(pool, false).await?; + match locations.len() { + 0 => Err(CliError::NotEnrolled( + "No locations configured. Use the desktop app to enroll an instance first.".into(), + )), + 1 => Ok(ResolvedTarget::Location( + locations + .into_iter() + .next() + .expect("exactly one location expected after length check"), + )), + _ => Err(CliError::NotFound( + "Multiple locations available. Specify a name.".into(), + )), + } +} + +#[cfg(test)] +mod tests { + use defguard_core::database::models::{ + instance::{ClientTrafficPolicy, Instance}, + location::{Location, LocationMfaMode, ServiceLocationMode}, + tunnel::Tunnel, + Id, NoId, + }; + + use super::*; + + fn sample_instance(name: &str) -> Instance { + Instance { + id: NoId, + name: name.into(), + uuid: format!("uuid-{name}"), + url: format!("https://{name}.example"), + proxy_url: format!("https://proxy.{name}.example"), + username: "alice".into(), + token: None, + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: false, + disable_tunnels: false, + openid_display_name: None, + } + } + + fn sample_location(name: &str, instance_id: Id) -> Location { + Location { + id: NoId, + instance_id, + network_id: 1, + name: name.into(), + address: "10.0.0.2/24".into(), + pubkey: format!("pk-loc-{name}"), + endpoint: "1.2.3.4:51820".into(), + allowed_ips: "0.0.0.0/0".into(), + dns: None, + route_all_traffic: false, + keepalive_interval: 25, + location_mfa_mode: LocationMfaMode::Disabled, + service_location_mode: ServiceLocationMode::Disabled, + mfa_method: None, + posture_check_required: false, + } + } + + fn sample_tunnel(name: &str) -> Tunnel { + Tunnel { + id: NoId, + name: name.into(), + pubkey: format!("pk-tun-{name}"), + prvkey: format!("prvk-tun-{name}"), + address: "10.1.0.2/24".into(), + server_pubkey: format!("spk-tun-{name}"), + preshared_key: None, + allowed_ips: Some("0.0.0.0/0".into()), + endpoint: "5.6.7.8:51820".into(), + dns: None, + persistent_keep_alive: 25, + route_all_traffic: false, + pre_up: None, + post_up: None, + pre_down: None, + post_down: None, + } + } + + /// Unwrap helper: avoids the `Debug` bound on `ResolvedTarget`. + fn expect_ok(result: Result) -> ResolvedTarget { + match result { + Ok(r) => r, + Err(e) => panic!("expected Ok, got Err: {e}"), + } + } + + fn expect_err(result: Result) -> CliError { + match result { + Ok(_) => panic!("expected Err, got Ok"), + Err(e) => e, + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_unique_location_by_name(pool: DbPool) { + let i = sample_instance("acme").save(&pool).await.unwrap(); + sample_location("office", i.id).save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: Some("office".into()), + tunnel: false, + id: None, + instance: None, + }; + let result = expect_ok(resolve_connect_target(&spec, &pool).await); + match result { + ResolvedTarget::Location(l) => { + assert_eq!(l.name, "office"); + assert_eq!(l.instance_id, i.id); + } + ResolvedTarget::Tunnel(_) => panic!("expected Location"), + } + } + + /// Service locations belong to the daemon: connecting to one would make the app and the daemon + /// supersede each other's session indefinitely. `--name` is safe because + /// `Location::find_by_name` filters them out in SQL; `--id` is not safe. + #[sqlx::test(migrations = "../migrations")] + async fn test_service_location_is_not_resolvable(pool: DbPool) { + let i = sample_instance("acme").save(&pool).await.unwrap(); + let mut location = sample_location("headless", i.id); + location.service_location_mode = ServiceLocationMode::AlwaysOn; + let location = location.save(&pool).await.unwrap(); + + let by_name = TargetSpec { + name: Some("headless".into()), + tunnel: false, + id: None, + instance: None, + }; + // Already filtered in SQL, so this reports "not found" rather than reaching the guard. + let err = expect_err(resolve_connect_target(&by_name, &pool).await); + assert!( + matches!(err, CliError::NotFound(_)), + "expected NotFound for a service location by name, got {err:?}" + ); + + let by_id = TargetSpec { + name: None, + tunnel: false, + id: Some(location.id), + instance: None, + }; + let err = expect_err(resolve_connect_target(&by_id, &pool).await); + assert!( + matches!(err, CliError::InvalidInput(_)), + "expected InvalidInput for a service location by id, got {err:?}" + ); + + // Disconnect resolves through the same function, so it is covered too. + let err = expect_err(resolve_disconnect_target(&by_id, &pool).await); + assert!( + matches!(err, CliError::InvalidInput(_)), + "expected InvalidInput when disconnecting a service location, got {err:?}" + ); + } + + /// A regular location on the same instance must still resolve, so the check above is not simply + /// rejecting everything. + #[sqlx::test(migrations = "../migrations")] + async fn test_regular_location_resolves_alongside_a_service_location(pool: DbPool) { + let i = sample_instance("acme").save(&pool).await.unwrap(); + let mut service = sample_location("headless", i.id); + service.service_location_mode = ServiceLocationMode::AlwaysOn; + service.save(&pool).await.unwrap(); + sample_location("office", i.id).save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: Some("office".into()), + tunnel: false, + id: None, + instance: None, + }; + match expect_ok(resolve_connect_target(&spec, &pool).await) { + ResolvedTarget::Location(l) => assert_eq!(l.name, "office"), + ResolvedTarget::Tunnel(_) => panic!("expected Location"), + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_unique_tunnel_by_name(pool: DbPool) { + sample_tunnel("gateway").save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: Some("gateway".into()), + tunnel: false, + id: None, + instance: None, + }; + let result = expect_ok(resolve_connect_target(&spec, &pool).await); + match result { + ResolvedTarget::Tunnel(t) => assert_eq!(t.name, "gateway"), + ResolvedTarget::Location(_) => panic!("expected Tunnel"), + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnel_by_name_with_tunnel_flag(pool: DbPool) { + let i = sample_instance("acme").save(&pool).await.unwrap(); + sample_location("office", i.id).save(&pool).await.unwrap(); + sample_tunnel("office").save(&pool).await.unwrap(); + + // Without --tunnel, the name clash produces an error. + let spec = TargetSpec { + name: Some("office".into()), + tunnel: false, + id: None, + instance: None, + }; + let err = expect_err(resolve_connect_target(&spec, &pool).await); + assert!(matches!(err, CliError::NotFound(_))); + assert!(err.to_string().contains("--tunnel")); + + // With --tunnel, the tunnel is resolved. + let spec = TargetSpec { + name: Some("office".into()), + tunnel: true, + id: None, + instance: None, + }; + let result = expect_ok(resolve_connect_target(&spec, &pool).await); + match result { + ResolvedTarget::Tunnel(t) => assert_eq!(t.name, "office"), + ResolvedTarget::Location(_) => panic!("expected Tunnel"), + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_cross_instance_name_collision(pool: DbPool) { + let i1 = sample_instance("acme").save(&pool).await.unwrap(); + let i2 = sample_instance("global").save(&pool).await.unwrap(); + sample_location("office", i1.id).save(&pool).await.unwrap(); + sample_location("office", i2.id).save(&pool).await.unwrap(); + + // Without --instance, ambiguous. + let spec = TargetSpec { + name: Some("office".into()), + tunnel: false, + id: None, + instance: None, + }; + let err = expect_err(resolve_connect_target(&spec, &pool).await); + assert!(matches!(err, CliError::NotFound(_))); + assert!(err.to_string().contains("--instance")); + + // With --instance, resolves to the correct one. + let spec = TargetSpec { + name: Some("office".into()), + tunnel: false, + id: None, + instance: Some("acme".into()), + }; + let result = expect_ok(resolve_connect_target(&spec, &pool).await); + match result { + ResolvedTarget::Location(l) => assert_eq!(l.instance_id, i1.id), + ResolvedTarget::Tunnel(_) => panic!("expected Location"), + } + + let spec = TargetSpec { + name: Some("office".into()), + tunnel: false, + id: None, + instance: Some("global".into()), + }; + let result = expect_ok(resolve_connect_target(&spec, &pool).await); + match result { + ResolvedTarget::Location(l) => assert_eq!(l.instance_id, i2.id), + ResolvedTarget::Tunnel(_) => panic!("expected Location"), + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_id_fast_path_location(pool: DbPool) { + let i = sample_instance("acme").save(&pool).await.unwrap(); + let saved = sample_location("office", i.id).save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: None, + tunnel: false, + id: Some(saved.id), + instance: None, + }; + let result = expect_ok(resolve_connect_target(&spec, &pool).await); + match result { + ResolvedTarget::Location(l) => assert_eq!(l.id, saved.id), + ResolvedTarget::Tunnel(_) => panic!("expected Location"), + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_id_fast_path_tunnel(pool: DbPool) { + let t = sample_tunnel("gateway").save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: None, + tunnel: false, + id: Some(t.id), + instance: None, + }; + let result = expect_ok(resolve_connect_target(&spec, &pool).await); + match result { + ResolvedTarget::Tunnel(tun) => assert_eq!(tun.id, t.id), + ResolvedTarget::Location(_) => panic!("expected Tunnel"), + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_id_fast_path_tunnel_flag_skips_location(pool: DbPool) { + let i = sample_instance("acme").save(&pool).await.unwrap(); + let saved = sample_location("office", i.id).save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: None, + tunnel: true, + id: Some(saved.id), + instance: None, + }; + // --tunnel skips Location lookup; the ID belongs to a Location so + // no Tunnel match is found. + let err = expect_err(resolve_connect_target(&spec, &pool).await); + assert!(matches!(err, CliError::NotFound(_))); + assert!(err.to_string().contains("No tunnel with id")); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_id_fast_path_tunnel_flag_with_tunnel(pool: DbPool) { + let i = sample_instance("acme").save(&pool).await.unwrap(); + sample_location("office", i.id).save(&pool).await.unwrap(); + let t = sample_tunnel("gateway").save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: None, + tunnel: true, + id: Some(t.id), + instance: None, + }; + let result = expect_ok(resolve_connect_target(&spec, &pool).await); + match result { + ResolvedTarget::Tunnel(tun) => assert_eq!(tun.id, t.id), + ResolvedTarget::Location(_) => panic!("expected Tunnel"), + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_id_not_found(pool: DbPool) { + let spec = TargetSpec { + name: None, + tunnel: false, + id: Some(9999), + instance: None, + }; + let err = expect_err(resolve_connect_target(&spec, &pool).await); + assert!(matches!(err, CliError::NotFound(_))); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_sole_location_no_arg(pool: DbPool) { + let i = sample_instance("acme").save(&pool).await.unwrap(); + sample_location("office", i.id).save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: None, + tunnel: false, + id: None, + instance: None, + }; + let result = expect_ok(resolve_connect_target(&spec, &pool).await); + match result { + ResolvedTarget::Location(l) => assert_eq!(l.name, "office"), + ResolvedTarget::Tunnel(_) => panic!("expected Location"), + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_no_arg_with_tunnels_only(pool: DbPool) { + // Tunnels are ignored by the no-arg path (only locations considered). + sample_tunnel("gateway").save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: None, + tunnel: false, + id: None, + instance: None, + }; + let err = expect_err(resolve_connect_target(&spec, &pool).await); + assert!(matches!(err, CliError::NotEnrolled(_))); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_multiple_locations_no_arg(pool: DbPool) { + let i = sample_instance("acme").save(&pool).await.unwrap(); + sample_location("office", i.id).save(&pool).await.unwrap(); + sample_location("home", i.id).save(&pool).await.unwrap(); + + let spec = TargetSpec { + name: None, + tunnel: false, + id: None, + instance: None, + }; + let err = expect_err(resolve_connect_target(&spec, &pool).await); + assert!(matches!(err, CliError::NotFound(_))); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_name_not_found(pool: DbPool) { + let spec = TargetSpec { + name: Some("nope".into()), + tunnel: false, + id: None, + instance: None, + }; + let err = expect_err(resolve_connect_target(&spec, &pool).await); + assert!(matches!(err, CliError::NotFound(_))); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_instance_not_found(pool: DbPool) { + let spec = TargetSpec { + name: Some("office".into()), + tunnel: false, + id: None, + instance: Some("ghost".into()), + }; + let err = expect_err(resolve_connect_target(&spec, &pool).await); + assert!(matches!(err, CliError::NotFound(_))); + assert!(err.to_string().contains("ghost")); + } +} diff --git a/src-tauri/client-cli/src/state.rs b/src-tauri/client-cli/src/state.rs new file mode 100644 index 000000000..29b14fbb1 --- /dev/null +++ b/src-tauri/client-cli/src/state.rs @@ -0,0 +1,95 @@ +//! CLI runtime state and error types. + +use std::path::Path; + +use defguard_core::{ + app_config::AppConfig, + database::{handle_db_migrations, DbPool, DB_POOL}, + error::Error as CoreError, +}; +use thiserror::Error; +use tracing::{debug, info}; + +/// Resolved CLI runtime state +pub struct State { + /// shared SQLite pool + pub pool: DbPool, + /// loaded application configuration (theme, log level, MTU, etc.) + pub app_config: AppConfig, +} + +#[derive(Debug, Error)] +pub enum CliError { + #[error("usage: {0}")] + Usage(String), + + #[error("{0}")] + NotFound(String), + + #[error("daemon unavailable: {0}")] + DaemonUnavailable(String), + + #[error("MFA failed: {0}")] + MfaFailed(String), + + #[error("MFA input required but no TTY: {0}")] + MfaInputRequired(String), + + #[error("enrollment required: {0}")] + NotEnrolled(String), + + #[error("invalid input: {0}")] + InvalidInput(String), + + #[error("{0}")] + Cancelled(String), + + #[error("{0}")] + Other(String), + + #[error("database error: {0}")] + Database(#[from] sqlx::Error), +} + +impl From for CliError { + fn from(err: CoreError) -> Self { + match err { + CoreError::NotFound => CliError::NotFound(err.to_string()), + CoreError::Database(inner_err) => CliError::Database(inner_err), + CoreError::BackendUnavailable(_) => CliError::DaemonUnavailable(err.to_string()), + CoreError::InvalidInput(_) => CliError::InvalidInput(err.to_string()), + _ => CliError::Other(err.to_string()), + } + } +} + +impl State { + /// Initialize the CLI runtime state: resolve data directory, open the + /// shared SQLite pool, and run migrations. + pub async fn init() -> Result { + let data_dir = defguard_core::app_data_dir() + .map(|p| p.to_string_lossy().to_string()) + .ok_or_else(|| { + CliError::Other( + "Could not determine the application data directory. Ensure a home/data \ + directory is configured for the current user." + .into(), + ) + })?; + + debug!("Using data directory: {data_dir}"); + + // Load application configuration (theme, MTU, log level, etc.). + let app_config = AppConfig::new(Path::new(&data_dir)); + + // Access the pool to trigger lazy initialization. + let pool = DB_POOL.clone(); + + // Run migrations. + handle_db_migrations().await; + + info!("CLI state initialized"); + + Ok(State { pool, app_config }) + } +} diff --git a/src-tauri/client-cli/src/tests_daemon.rs b/src-tauri/client-cli/src/tests_daemon.rs new file mode 100644 index 000000000..7f298616d --- /dev/null +++ b/src-tauri/client-cli/src/tests_daemon.rs @@ -0,0 +1,234 @@ +use std::{ + collections::HashMap, + env::set_var, + fs::remove_file, + os::unix::net::UnixStream, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread::sleep, + time::Duration, +}; + +use defguard_client_proto::defguard::client::v1::{ + desktop_daemon_service_server::{DesktopDaemonService, DesktopDaemonServiceServer}, + CreateInterfaceRequest, DeleteServiceLocationsRequest, InterfaceData, ManagedInterfaceData, + Peer, ReadInterfaceDataRequest, RemoveInterfaceRequest, SaveServiceLocationsRequest, +}; +use defguard_core::{ + connection::active_state::active_state, + database::{ + models::{ + instance::{ClientTrafficPolicy, Instance}, + location::{Location, LocationMfaMode, ServiceLocationMode}, + NoId, + }, + DbPool, + }, + proto::{client::v1::ListInterfacesResponse, enterprise::posture::v2::DevicePostureData}, + ConnectionType, +}; +use tempfile::{tempdir, TempDir}; +use tokio::{net::UnixListener, sync::mpsc, task::JoinHandle}; +use tokio_stream::wrappers::{ReceiverStream, UnixListenerStream}; +use tonic::{transport::Server, Request, Response, Status}; + +const READY_POLL_INTERVAL: Duration = Duration::from_millis(10); +const READY_POLL_ATTEMPTS: u32 = 50; + +type StreamItem = Result; + +#[derive(Clone, Default)] +pub(crate) struct MockDaemonState { + pub(crate) interfaces: Arc>>, + pub(crate) create_count: Arc, + pub(crate) remove_count: Arc, +} + +struct MockDaemon { + state: MockDaemonState, +} + +#[tonic::async_trait] +impl DesktopDaemonService for MockDaemon { + async fn create_interface( + &self, + _req: Request, + ) -> Result, Status> { + self.state.create_count.fetch_add(1, Ordering::SeqCst); + Ok(Response::new(())) + } + + async fn remove_interface( + &self, + req: Request, + ) -> Result, Status> { + self.state.remove_count.fetch_add(1, Ordering::SeqCst); + self.state + .interfaces + .lock() + .unwrap() + .remove(&req.into_inner().interface_name); + Ok(Response::new(())) + } + + type ReadInterfaceDataStream = ReceiverStream; + + async fn read_interface_data( + &self, + req: Request, + ) -> Result, Status> { + let name = req.into_inner().interface_name; + let data = self.state.interfaces.lock().unwrap().get(&name).cloned(); + let (tx, rx) = mpsc::channel(1); + if let Some(d) = data { + let _ = tx.send(Ok(d)).await; + } + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn save_service_locations( + &self, + _req: Request, + ) -> Result, Status> { + Err(Status::unimplemented("not mocked")) + } + + async fn delete_service_locations( + &self, + _req: Request, + ) -> Result, Status> { + Err(Status::unimplemented("not mocked")) + } + + async fn get_posture_data( + &self, + _req: Request<()>, + ) -> Result, Status> { + Err(Status::unimplemented("not mocked")) + } + + async fn list_interfaces( + &self, + _req: Request<()>, + ) -> Result, Status> { + let interfaces = self + .state + .interfaces + .lock() + .unwrap() + .iter() + .map(|(ifname, data)| ManagedInterfaceData { + interface_name: ifname.clone(), + data: Some(data.clone()), + }) + .collect::>(); + Ok(Response::new(ListInterfacesResponse { interfaces })) + } +} + +/// Spawn a mock daemon on a temp Unix socket. Returns the mock state, the +/// server handle, and the temp dir (which owns the socket file). +fn spawn_mock() -> (MockDaemonState, JoinHandle<()>, TempDir) { + let state = MockDaemonState::default(); + let daemon = MockDaemon { + state: state.clone(), + }; + let dir = tempdir().unwrap(); + let socket_path = dir.path().join("defguard.sock"); + let _ = remove_file(&socket_path); + let uds = UnixListener::bind(&socket_path).unwrap(); + let handle = tokio::spawn(async move { + let incoming = UnixListenerStream::new(uds); + Server::builder() + .add_service(DesktopDaemonServiceServer::new(daemon)) + .serve_with_incoming(incoming) + .await + .ok(); + }); + set_var("DEFGUARD_DAEMON_SOCKET", socket_path.to_str().unwrap()); + // Poll until the socket accepts connections instead of sleeping a fixed amount. + for _ in 0..READY_POLL_ATTEMPTS { + if UnixStream::connect(&socket_path).is_ok() { + break; + } + sleep(READY_POLL_INTERVAL); + } + (state, handle, dir) +} + +#[sqlx::test(migrations = "../migrations")] +async fn test_active_state_lists_interfaces(pool: DbPool) { + let instance = Instance { + id: NoId, + name: "acme".into(), + uuid: "uuid-1".into(), + url: "https://core.example".into(), + proxy_url: "https://proxy.example".into(), + username: "alice".into(), + token: None, + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: false, + disable_tunnels: false, + openid_display_name: None, + } + .save(&pool) + .await + .unwrap(); + let location = Location { + id: NoId, + instance_id: instance.id, + network_id: 1, + name: "office".into(), + address: "10.0.0.2/24".into(), + pubkey: "pk-loc".into(), + endpoint: "1.2.3.4:51820".into(), + allowed_ips: "0.0.0.0/0".into(), + dns: None, + route_all_traffic: false, + keepalive_interval: 25, + location_mfa_mode: LocationMfaMode::Disabled, + service_location_mode: ServiceLocationMode::Disabled, + mfa_method: None, + posture_check_required: false, + } + .save(&pool) + .await + .unwrap(); + + let (state, _server, _dir) = spawn_mock(); + + let hex_pubkey = "abba"; + let b64_pubkey = "q7o="; + sqlx::query("UPDATE location SET pubkey = $1 WHERE id = $2") + .bind(b64_pubkey) + .bind(location.id) + .execute(&pool) + .await + .unwrap(); + + state.interfaces.lock().unwrap().insert( + "wg0".into(), + InterfaceData { + listen_port: 51820, + peers: vec![Peer { + public_key: hex_pubkey.into(), + preshared_key: None, + protocol_version: Some(1), + endpoint: Some("1.2.3.4:51820".into()), + last_handshake: Some(1700000000), + tx_bytes: 1000, + rx_bytes: 2000, + persistent_keepalive_interval: Some(0), + allowed_ips: vec!["0.0.0.0/0".into()], + }], + }, + ); + + let connections = active_state(&pool).await.unwrap(); + assert_eq!(connections.len(), 1, "expected 1 active connection"); + assert_eq!(connections[0].name, "office"); + assert_eq!(connections[0].interface_name, "wg0"); + assert_eq!(connections[0].connection_type, ConnectionType::Location); +} diff --git a/src-tauri/client-proto/Cargo.toml b/src-tauri/client-proto/Cargo.toml new file mode 100644 index 000000000..3046dc492 --- /dev/null +++ b/src-tauri/client-proto/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "defguard-client-proto" +description = "Protobuf definitions for the Defguard desktop client" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license-file.workspace = true +rust-version.workspace = true +version.workspace = true + +[build-dependencies] +tonic-prost-build.workspace = true + +[dependencies] +prost.workspace = true +serde.workspace = true +serde_with = "3.11" +tonic.workspace = true +tonic-prost.workspace = true +tracing.workspace = true + +defguard_wireguard_rs.workspace = true + +[target.'cfg(windows)'.dependencies] +wmi = { version = "0.18", default-features = false } + +[dev-dependencies] +x25519-dalek = { workspace = true, features = ["getrandom", "static_secrets"] } diff --git a/src-tauri/client-proto/build.rs b/src-tauri/client-proto/build.rs new file mode 100644 index 000000000..1529c1578 --- /dev/null +++ b/src-tauri/client-proto/build.rs @@ -0,0 +1,56 @@ +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=../proto"); + + tonic_prost_build::configure() + // These types contain sensitive data. + .skip_debug(["SaveServiceLocationsRequest"]) + // Enable optional fields. + .protoc_arg("--experimental_allow_proto3_optional") + // Make sure empty DNS is deserialized correctly as `None`. + .type_attribute(".DeviceConfig", "#[serde_as]") + .field_attribute( + ".DeviceConfig.dns", + "#[serde_as(deserialize_as = \"NoneAsEmptyString\")]", + ) + // Make all messages serde-serializable. + .type_attribute(".", "#[derive(serde::Serialize,serde::Deserialize)]") + // `ServiceLocation` is persisted as JSON by the daemon. Tolerate these fields being absent + // in files written by older clients. Deliberately per-field rather than a container-level + // `#[serde(default)]`, so a truncated or corrupt file still fails to deserialize instead of + // quietly becoming "no locations". + .field_attribute( + ".defguard.client.v1.ServiceLocation.core_location_id", + "#[serde(default)]", + ) + .field_attribute( + ".defguard.client.v1.ServiceLocation.posture_check_required", + "#[serde(default)]", + ) + // Use proto defaults for missing fields in enrollment types that + // may differ across proxy versions. + .type_attribute(".defguard.client_types.AdminInfo", "#[serde(default)]") + .type_attribute( + ".defguard.client_types.InitialUserInfo", + "#[serde(default)]", + ) + .type_attribute( + ".defguard.client_types.EnrollmentSettings", + "#[serde(default)]", + ) + .type_attribute(".defguard.client_types.InstanceInfo", "#[serde(default)]") + .type_attribute( + ".defguard.client_types.EnrollmentStartResponse", + "#[serde(default)]", + ) + .compile_protos( + &[ + "../proto/v1/client/client.proto", + "../proto/v1/core/proxy.proto", + "../proto/enterprise/v2/posture/posture.proto", + "../proto/common/client_types.proto", + ], + &["../proto"], + )?; + + Ok(()) +} diff --git a/src-tauri/client-proto/src/conversions.rs b/src-tauri/client-proto/src/conversions.rs new file mode 100644 index 000000000..4f7e8c368 --- /dev/null +++ b/src-tauri/client-proto/src/conversions.rs @@ -0,0 +1,378 @@ +use std::{ + collections::HashSet, + mem::take, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + str::FromStr, + time::{Duration, UNIX_EPOCH}, +}; + +use defguard_wireguard_rs::{ + host::Host, key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, +}; +use tonic::Status; +use tracing::debug; + +use crate::defguard::client::v1::{InterfaceConfig, InterfaceData, Peer as ProtoPeer}; + +/// Truncates host bits from a peer allowed IP. +/// +/// This runs before `WGApi` classifies default routes. In particular, a non-canonical `/0` must +/// become an unspecified address so it takes the default-route loop-prevention path. +#[must_use] +fn truncate_to_network(mut allowed_ip: IpAddrMask) -> IpAddrMask { + let max_cidr = if allowed_ip.address.is_ipv4() { + Ipv4Addr::BITS + } else { + Ipv6Addr::BITS + }; + + // Unreachable via `FromStr`, which rejects an out-of-range cidr, but `IpAddrMask::new` and the + // public `cidr` field don't. Bail out rather than let `mask()` underflow its shift. + if allowed_ip.cidr as u32 > max_cidr { + debug!("Leaving allowed IP {allowed_ip} unnormalized, its cidr exceeds {max_cidr}"); + return allowed_ip; + } + + allowed_ip.address = match (allowed_ip.address, allowed_ip.mask()) { + (IpAddr::V4(address), IpAddr::V4(mask)) => IpAddr::V4(address & mask), + (IpAddr::V6(address), IpAddr::V6(mask)) => IpAddr::V6(address & mask), + _ => return allowed_ip, + }; + allowed_ip +} + +/// Normalizes and deduplicates peer allowed IPs before they reach `WGApi`. +pub fn normalize_allowed_ips(config: &mut InterfaceConfiguration) { + for peer in &mut config.peers { + let mut seen = HashSet::new(); + peer.allowed_ips = take(&mut peer.allowed_ips) + .into_iter() + .map(truncate_to_network) + .filter(|allowed_ip| seen.insert(allowed_ip.clone())) + .collect(); + } +} + +impl From for InterfaceConfig { + fn from(config: InterfaceConfiguration) -> Self { + Self { + name: config.name, + prvkey: config.prvkey, + address: config + .addresses + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + port: u32::from(config.port), + peers: config.peers.into_iter().map(Into::into).collect(), + mtu: config.mtu, + } + } +} + +impl TryFrom for InterfaceConfiguration { + type Error = Status; + + fn try_from(config: InterfaceConfig) -> Result { + let addresses = config + .address + .split(',') + .filter_map(|ip| IpAddrMask::from_str(ip.trim()).ok()) + .collect(); + let peers = config + .peers + .into_iter() + .map(Peer::try_from) + .collect::, _>>()?; + Ok(Self { + name: config.name, + prvkey: config.prvkey, + addresses, + port: config.port as u16, + peers, + mtu: config.mtu, + fwmark: None, // TODO: add to config + }) + } +} + +impl From for ProtoPeer { + fn from(peer: Peer) -> Self { + Self { + public_key: peer.public_key.to_lower_hex(), + preshared_key: peer.preshared_key.map(|key| key.to_lower_hex()), + protocol_version: peer.protocol_version, + endpoint: peer.endpoint.map(|addr| addr.to_string()), + last_handshake: peer.last_handshake.map(|time| { + time.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }), + tx_bytes: peer.tx_bytes, + rx_bytes: peer.rx_bytes, + persistent_keepalive_interval: peer.persistent_keepalive_interval.map(u32::from), + allowed_ips: peer + .allowed_ips + .into_iter() + .map(|addr| addr.to_string()) + .collect(), + } + } +} + +impl TryFrom for Peer { + type Error = Status; + + fn try_from(peer: ProtoPeer) -> Result { + let public_key = Key::decode(peer.public_key) + .map_err(|err| Status::invalid_argument(format!("Invalid peer public key: {err}")))?; + let preshared_key = peer + .preshared_key + .map(|key| { + Key::decode(key).map_err(|err| { + Status::invalid_argument(format!("Invalid preshared key: {err}")) + }) + }) + .transpose()?; + let endpoint = peer + .endpoint + .map(|addr| { + addr.parse().map_err(|err| { + Status::invalid_argument(format!("Invalid endpoint {addr}: {err}")) + }) + }) + .transpose()?; + let allowed_ips = peer + .allowed_ips + .into_iter() + .map(|addr| { + addr.parse().map_err(|err| { + Status::invalid_argument(format!("Invalid allowed IP {addr}: {err}")) + }) + }) + .collect::, _>>()?; + Ok(Self { + public_key, + preshared_key, + protocol_version: peer.protocol_version, + endpoint, + last_handshake: peer + .last_handshake + .map(|timestamp| UNIX_EPOCH + Duration::from_secs(timestamp)), + tx_bytes: peer.tx_bytes, + rx_bytes: peer.rx_bytes, + persistent_keepalive_interval: peer + .persistent_keepalive_interval + .and_then(|interval| u16::try_from(interval).ok()), + allowed_ips, + }) + } +} + +impl From for InterfaceData { + fn from(host: Host) -> Self { + Self { + listen_port: u32::from(host.listen_port), + peers: host.peers.into_values().map(Into::into).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use defguard_wireguard_rs::{key::Key, net::IpAddrMask, peer::Peer}; + use x25519_dalek::{EphemeralSecret, PublicKey}; + + use super::*; + + #[test] + fn convert_peer() { + let secret = EphemeralSecret::random(); + let key = PublicKey::from(&secret); + let peer_key: Key = key.as_ref().try_into().unwrap(); + let mut base_peer = Peer::new(peer_key); + let addr = IpAddrMask::from_str("10.20.30.2/32").unwrap(); + base_peer.allowed_ips.push(addr); + // Workaround since nanoseconds are lost in conversion. + base_peer.last_handshake = Some(SystemTime::UNIX_EPOCH); + base_peer.protocol_version = Some(3); + base_peer.endpoint = Some("127.0.0.1:8080".parse().unwrap()); + base_peer.tx_bytes = 100; + base_peer.rx_bytes = 200; + + let proto_peer: ProtoPeer = base_peer.clone().into(); + + let converted_peer: Peer = proto_peer.try_into().unwrap(); + + assert_eq!(base_peer, converted_peer); + } + + fn sample_peer() -> Peer { + let secret = EphemeralSecret::random(); + let peer_key: Key = PublicKey::from(&secret).as_ref().try_into().unwrap(); + let mut peer = Peer::new(peer_key); + peer.allowed_ips + .push(IpAddrMask::from_str("10.20.30.2/32").unwrap()); + peer.endpoint = Some("127.0.0.1:8080".parse().unwrap()); + peer.persistent_keepalive_interval = Some(25); + peer + } + + #[test] + fn test_truncate_to_network_clears_ipv4_host_bits() { + let allowed_ip = "172.16.0.1/24".parse::().unwrap(); + + assert_eq!( + truncate_to_network(allowed_ip), + "172.16.0.0/24".parse::().unwrap() + ); + } + + #[test] + fn test_truncate_to_network_keeps_ipv4_host_route() { + let allowed_ip = "172.16.0.1/32".parse::().unwrap(); + + assert_eq!(truncate_to_network(allowed_ip.clone()), allowed_ip); + } + + #[test] + fn test_truncate_to_network_keeps_canonical_address() { + let allowed_ip = "172.16.0.0/24".parse::().unwrap(); + + assert_eq!(truncate_to_network(allowed_ip.clone()), allowed_ip); + } + + #[test] + fn test_truncate_to_network_handles_ipv4_default_route() { + let allowed_ip = "10.0.0.1/0".parse::().unwrap(); + + assert_eq!( + truncate_to_network(allowed_ip), + "0.0.0.0/0".parse::().unwrap() + ); + } + + #[test] + fn test_truncate_to_network_clears_ipv6_host_bits() { + let allowed_ip = "2001:db8::1/96".parse::().unwrap(); + + assert_eq!( + truncate_to_network(allowed_ip), + "2001:db8::/96".parse::().unwrap() + ); + } + + #[test] + fn test_truncate_to_network_preserves_invalid_ipv4_cidr() { + let allowed_ip = IpAddrMask::new("172.16.0.1".parse().unwrap(), 33); + + assert_eq!(truncate_to_network(allowed_ip.clone()), allowed_ip); + } + + #[test] + fn test_truncate_to_network_preserves_invalid_ipv6_cidr() { + let allowed_ip = IpAddrMask::new("2001:db8::1".parse().unwrap(), 129); + + assert_eq!(truncate_to_network(allowed_ip.clone()), allowed_ip); + } + + #[test] + fn test_normalize_allowed_ips_deduplicates_after_masking() { + let mut peer = sample_peer(); + peer.allowed_ips = ["172.16.0.1/24", "172.16.0.2/24", "10.0.0.0/24"] + .into_iter() + .map(|allowed_ip| allowed_ip.parse().unwrap()) + .collect(); + let mut config = InterfaceConfiguration { + name: "wg0".into(), + prvkey: String::new(), + addresses: vec!["10.0.0.1/24".parse().unwrap()], + port: 0, + peers: vec![peer], + mtu: None, + fwmark: None, + }; + + normalize_allowed_ips(&mut config); + + assert_eq!( + config.peers[0].allowed_ips, + ["172.16.0.0/24", "10.0.0.0/24"] + .into_iter() + .map(|allowed_ip| allowed_ip.parse().unwrap()) + .collect::>() + ); + assert_eq!(config.addresses, vec!["10.0.0.1/24".parse().unwrap()]); + } + + #[test] + fn test_host_to_interface_data() { + let secret = EphemeralSecret::random(); + let host_key: Key = PublicKey::from(&secret).as_ref().try_into().unwrap(); + let mut host = Host::new(51820, host_key); + let peer = sample_peer(); + host.peers.insert(peer.public_key.clone(), peer.clone()); + + let data: InterfaceData = host.into(); + + assert_eq!(data.listen_port, 51820); + assert_eq!(data.peers.len(), 1); + assert_eq!(data.peers[0].public_key, peer.public_key.to_lower_hex()); + } + + #[test] + fn test_proto_peer_to_peer_roundtrip() { + let peer = sample_peer(); + let proto: ProtoPeer = peer.clone().into(); + let converted: Peer = proto.try_into().unwrap(); + assert_eq!(peer, converted); + } + + #[test] + fn test_invalid_peer_fields_are_rejected() { + let base: ProtoPeer = sample_peer().into(); + let invalid = [ + ProtoPeer { + public_key: "NOT-A-VALID-WIREGUARD-KEY".to_string(), + ..base.clone() + }, + ProtoPeer { + preshared_key: Some("not-a-key".to_string()), + ..base.clone() + }, + ProtoPeer { + endpoint: Some("not-an-endpoint".to_string()), + ..base.clone() + }, + ProtoPeer { + allowed_ips: vec!["999.999.999.999/32".to_string()], + ..base + }, + ]; + + for peer in invalid { + let config = InterfaceConfig { + name: "dg0".to_string(), + prvkey: String::new(), + address: "10.20.30.1/24".to_string(), + port: 51820, + peers: vec![peer], + mtu: None, + }; + let err = InterfaceConfiguration::try_from(config).unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } + } + + #[test] + fn test_keepalive_overflow_maps_to_none() { + let mut proto: ProtoPeer = sample_peer().into(); + // A value exceeding u16::MAX can't be represented as a keepalive interval. + proto.persistent_keepalive_interval = Some(u32::from(u16::MAX) + 1); + let converted: Peer = proto.try_into().unwrap(); + assert_eq!(converted.persistent_keepalive_interval, None); + } +} diff --git a/src-tauri/client-proto/src/lib.rs b/src-tauri/client-proto/src/lib.rs new file mode 100644 index 000000000..36eb75471 --- /dev/null +++ b/src-tauri/client-proto/src/lib.rs @@ -0,0 +1,28 @@ +pub mod conversions; +pub mod posture_ext; + +pub mod defguard { + pub mod client_types { + tonic::include_proto!("defguard.client_types"); + } + + pub mod client { + pub mod v1 { + tonic::include_proto!("defguard.client.v1"); + } + } + + pub mod proxy { + pub mod v1 { + tonic::include_proto!("defguard.proxy.v1"); + } + } + + pub mod enterprise { + pub mod posture { + pub mod v2 { + tonic::include_proto!("defguard.enterprise.posture.v2"); + } + } + } +} diff --git a/src-tauri/client-proto/src/posture_ext.rs b/src-tauri/client-proto/src/posture_ext.rs new file mode 100644 index 000000000..ffbe4911b --- /dev/null +++ b/src-tauri/client-proto/src/posture_ext.rs @@ -0,0 +1,143 @@ +use std::fmt; + +use crate::defguard::enterprise::posture::v2::{ + bool_check, int32_check, string_check, BoolCheck, Int32Check, StringCheck, UnavailableReason, +}; + +impl fmt::Display for UnavailableReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unspecified => f.write_str("unspecified"), + Self::DetectionFailed => f.write_str("detection failed"), + Self::NotApplicable => f.write_str("not applicable on this platform"), + Self::InsufficientPermissions => f.write_str("insufficient permissions"), + } + } +} + +/// Convert `Result` to `BoolCheck`. +impl From> for BoolCheck { + fn from(value: Result) -> Self { + Self { + result: Some(match value { + Ok(inner) => bool_check::Result::Value(inner), + Err(err) => bool_check::Result::Unavailable(err as i32), + }), + } + } +} + +/// Convert `Result` to `Int32Check`. +impl From> for Int32Check { + fn from(value: Result) -> Self { + Self { + result: Some(match value { + Ok(inner) => int32_check::Result::Value(inner), + Err(err) => int32_check::Result::Unavailable(err as i32), + }), + } + } +} + +/// Convert `Result` to `StringCheck`. +impl From> for StringCheck { + fn from(value: Result) -> Self { + Self { + result: Some(match value { + Ok(inner) => string_check::Result::Value(inner), + Err(err) => string_check::Result::Unavailable(err as i32), + }), + } + } +} + +/// Convert `WMIError` to `UnavailableReason`. +#[cfg(windows)] +impl From for UnavailableReason { + fn from(err: wmi::WMIError) -> Self { + if let wmi::WMIError::HResultError { .. } = err { + UnavailableReason::InsufficientPermissions + } else { + UnavailableReason::DetectionFailed + } + } +} + +#[cfg(test)] +mod tests { + use crate::defguard::enterprise::posture::v2::{ + bool_check, int32_check, string_check, BoolCheck, Int32Check, StringCheck, + UnavailableReason, + }; + + #[test] + fn test_bool_check_ok() { + let check = BoolCheck::from(Ok(true)); + assert_eq!(check.result, Some(bool_check::Result::Value(true))); + } + + #[test] + fn test_bool_check_unavailable() { + let check = BoolCheck::from(Err(UnavailableReason::DetectionFailed)); + assert_eq!( + check.result, + Some(bool_check::Result::Unavailable( + UnavailableReason::DetectionFailed as i32 + )) + ); + } + + #[test] + fn test_int32_check_ok() { + let check = Int32Check::from(Ok(42)); + assert_eq!(check.result, Some(int32_check::Result::Value(42))); + } + + #[test] + fn test_int32_check_unavailable() { + let check = Int32Check::from(Err(UnavailableReason::NotApplicable)); + assert_eq!( + check.result, + Some(int32_check::Result::Unavailable( + UnavailableReason::NotApplicable as i32 + )) + ); + } + + #[test] + fn test_string_check_ok() { + let check = StringCheck::from(Ok("1.2.3".to_string())); + assert_eq!( + check.result, + Some(string_check::Result::Value("1.2.3".to_string())) + ); + } + + #[test] + fn test_string_check_unavailable() { + let check = StringCheck::from(Err(UnavailableReason::InsufficientPermissions)); + assert_eq!( + check.result, + Some(string_check::Result::Unavailable( + UnavailableReason::InsufficientPermissions as i32 + )) + ); + } + + #[test] + fn test_unavailable_reason_display() { + assert_eq!(UnavailableReason::Unspecified.to_string(), "unspecified"); + assert_eq!( + UnavailableReason::DetectionFailed.to_string(), + "detection failed" + ); + assert_eq!( + UnavailableReason::NotApplicable.to_string(), + "not applicable on this platform" + ); + assert_eq!( + UnavailableReason::InsufficientPermissions.to_string(), + "insufficient permissions" + ); + } +} diff --git a/src-tauri/common/Cargo.toml b/src-tauri/common/Cargo.toml index 43c7fd19f..3822c9a5f 100644 --- a/src-tauri/common/Cargo.toml +++ b/src-tauri/common/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "common" +name = "defguard-client-common" authors.workspace = true edition.workspace = true homepage.workspace = true @@ -9,5 +9,8 @@ version.workspace = true [dependencies] +[build-dependencies] +vergen-git2.workspace = true + [target.'cfg(unix)'.dependencies] nix = { version = "0.31", features = ["net"] } diff --git a/src-tauri/common/build.rs b/src-tauri/common/build.rs new file mode 100644 index 000000000..1a5094db2 --- /dev/null +++ b/src-tauri/common/build.rs @@ -0,0 +1,7 @@ +use vergen_git2::{Emitter, Git2}; + +fn main() -> Result<(), Box> { + let git2 = Git2::builder().sha(true).build(); + Emitter::default().add_instructions(&git2)?.emit()?; + Ok(()) +} diff --git a/src-tauri/common/src/lib.rs b/src-tauri/common/src/lib.rs index 190fcbd38..8a74889d0 100644 --- a/src-tauri/common/src/lib.rs +++ b/src-tauri/common/src/lib.rs @@ -1,4 +1,43 @@ -use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; +use std::{ + env, + net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, + process, +}; + +/// Package version from the workspace (shared across all binaries). +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Build a `--version` output string for a given binary name. +/// +/// Uses the `DEFGUARD_CLIENT_BUILD_VERSION` environment variable when set (CI pre-release +/// builds), falling back to `CARGO_PKG_VERSION` + short commit hash. +#[must_use] +pub fn version_string(binary_name: &str) -> String { + let sha = option_env!("VERGEN_GIT_SHA") + .filter(|s| *s != "VERGEN_IDEMPOTENT_OUTPUT" && !s.trim().is_empty()); + let version = option_env!("DEFGUARD_CLIENT_BUILD_VERSION") + .filter(|v| !v.trim().is_empty()) + .map_or_else( + || match sha { + Some(s) => format!("{} ({s})", env!("CARGO_PKG_VERSION")), + None => env!("CARGO_PKG_VERSION").to_string(), + }, + |v| match sha { + Some(s) => format!("{v} ({s})"), + None => v.to_string(), + }, + ); + format!("{binary_name} {version}") +} + +/// Check for `--version` / `-V` in command-line arguments and exit with the version +/// string if found. Call this early in `main()` before argument parsing. +pub fn check_version_flag(binary_name: &str) { + if env::args().any(|a| a == "--version" || a == "-V") { + println!("{}", version_string(binary_name)); + process::exit(0); + } +} /// Obtain a free TCP port on localhost. #[must_use] @@ -90,4 +129,80 @@ mod tests { let port = find_free_tcp_port().unwrap(); assert_ne!(port, 0); } + + fn ip(addr: &str) -> IpAddr { + addr.parse().unwrap() + } + + #[test] + fn test_dns_owned_none_and_empty() { + assert_eq!(dns_owned(&None), (Vec::new(), Vec::new())); + assert_eq!(dns_owned(&Some(String::new())), (Vec::new(), Vec::new())); + } + + #[test] + fn test_dns_owned_ipv4_only() { + let (ips, domains) = dns_owned(&Some("10.0.0.2".to_string())); + assert_eq!(ips, [ip("10.0.0.2")]); + assert!(domains.is_empty()); + } + + #[test] + fn test_dns_owned_ipv6_only() { + let (ips, domains) = dns_owned(&Some("fd00::1".to_string())); + assert_eq!(ips, vec![ip("fd00::1")]); + assert!(domains.is_empty()); + } + + #[test] + fn test_dns_owned_domains_only() { + let (ips, domains) = dns_owned(&Some("tnt,teonite.net".to_string())); + assert!(ips.is_empty()); + assert_eq!(domains, ["tnt".to_string(), "teonite.net".to_string()]); + } + + #[test] + fn test_dns_owned_mixed_with_whitespace() { + // Entries are trimmed; parseable ones become resolver IPs, the rest search domains. + let (ips, domains) = dns_owned(&Some("10.0.0.2, tnt , teonite.net".to_string())); + assert_eq!(ips, [ip("10.0.0.2")]); + assert_eq!(domains, ["tnt".to_string(), "teonite.net".to_string()]); + } + + #[test] + fn test_dns_owned_trailing_comma_yields_empty_domains() { + // Pins current behavior: empty entries between commas are treated as (empty) domains. + let (ips, domains) = dns_owned(&Some("10.0.0.2,,".to_string())); + assert_eq!(ips, [ip("10.0.0.2")]); + assert_eq!(domains, [String::new(), String::new()]); + } + + #[test] + fn test_dns_borrow_mixed_with_whitespace() { + let config = Some("10.0.0.2, tnt , teonite.net".to_string()); + let (ips, domains) = dns_borrow(&config); + assert_eq!(ips, [ip("10.0.0.2")]); + assert_eq!(domains, ["tnt", "teonite.net"]); + } + + #[test] + fn test_dns_borrow_none() { + let (ips, domains) = dns_borrow(&None); + assert!(ips.is_empty()); + assert!(domains.is_empty()); + } + + #[cfg(any(windows, target_os = "macos"))] + #[test] + fn test_interface_name_strips_non_alphanumeric() { + assert_eq!(get_interface_name("My Loc-ation!"), "MyLocation"); + assert_eq!(get_interface_name("wg0"), "wg0"); + } + + #[cfg(not(any(windows, target_os = "macos")))] + #[test] + fn test_interface_name_returns_wg_prefixed() { + // The Linux variant searches for the next free `wgN` interface, ignoring the input name. + assert!(get_interface_name("ignored").starts_with("wg")); + } } diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml new file mode 100644 index 000000000..4da9bbab6 --- /dev/null +++ b/src-tauri/core/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "defguard-client-core" +description = "Shared business logic for the Defguard desktop client (Tauri-free)" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license-file.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +base64.workspace = true +chrono = { version = "0.4", features = ["serde"] } +hex = "0.4" +defguard-client-common = { path = "../common" } +defguard-client-proto = { path = "../client-proto" } +defguard_wireguard_rs.workspace = true +dirs-next.workspace = true +futures-util.workspace = true +hyper-util = "0.1" +log.workspace = true +os_info = { version = "3.14", default-features = false } +prost.workspace = true +reqwest.workspace = true +rust-ini = "0.21" +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_with.workspace = true +sqlx.workspace = true +strum = { version = "0.28", features = ["derive"] } +struct-patch = "0.12" +thiserror.workspace = true +tokio.workspace = true +tokio-tungstenite.workspace = true +tokio-util = "0.7" +tonic.workspace = true +tower = "0.5" +tracing.workspace = true +x25519-dalek.workspace = true + +[dev-dependencies] +futures-util.workspace = true +tempfile.workspace = true +tokio-tungstenite.workspace = true +wiremock.workspace = true + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31", features = ["user", "fs"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation"] } + +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6" +objc2 = "0.6" +objc2-app-kit = "0.3" +objc2-foundation = "0.3" +objc2-network-extension = "0.3" diff --git a/src-tauri/core/src/app_config.rs b/src-tauri/core/src/app_config.rs new file mode 100644 index 000000000..ef4b566b2 --- /dev/null +++ b/src-tauri/core/src/app_config.rs @@ -0,0 +1,218 @@ +use std::{ + fs::{create_dir_all, File, OpenOptions}, + path::Path, +}; + +use log::LevelFilter; +use serde::{Deserialize, Serialize}; +use struct_patch::Patch; + +#[cfg(unix)] +use crate::set_perms; + +static APP_CONFIG_FILE_NAME: &str = "config.json"; + +fn get_config_file_path(config_dir: &Path) -> std::path::PathBuf { + let mut config_file_path = config_dir.to_path_buf(); + if !config_file_path.exists() { + create_dir_all(&config_file_path).expect("Failed to create missing app data dir"); + } + #[cfg(unix)] + set_perms(&config_file_path); + config_file_path.push(APP_CONFIG_FILE_NAME); + #[cfg(unix)] + set_perms(&config_file_path); + config_file_path +} + +fn get_config_file(config_dir: &Path, for_write: bool) -> File { + let config_file_path = get_config_file_path(config_dir); + OpenOptions::new() + .create(true) + .read(true) + .truncate(for_write) + .write(true) + .open(config_file_path) + .expect("Failed to create and open app config.") +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AppTheme { + Light, + Dark, +} + +// config stored in config.json in app data +// config is loaded once at startup and saved when modified to the app data file +// information's needed at startup of the application. +#[derive(Clone, Debug, Deserialize, Patch, Serialize)] +#[patch(attribute(derive(Debug, Deserialize, Serialize)))] +pub struct AppConfig { + pub theme: AppTheme, + pub check_for_updates: bool, + pub log_level: LevelFilter, + /// In seconds. How much time after last network activity the connection is automatically dropped. + pub peer_alive_period: u32, + /// Maximal transmission unit. 0 means default value. + mtu: u32, + pub auto_start_openid_mfa: bool, +} + +// Important: keep in sync with client store default in frontend +impl Default for AppConfig { + fn default() -> Self { + Self { + theme: AppTheme::Light, + check_for_updates: true, + log_level: LevelFilter::Info, + peer_alive_period: 300, + mtu: 0, + auto_start_openid_mfa: false, + } + } +} + +impl AppConfig { + /// Try to load application configuration from the given config directory. + /// If reading the configuration file fails, default settings will be returned. + #[must_use] + pub fn new(config_dir: &Path) -> Self { + let config_path = get_config_file_path(config_dir); + if !config_path.exists() { + eprintln!( + "Application configuration file doesn't exist; initializing it with the defaults." + ); + let res = Self::default(); + res.save(config_dir); + return res; + } + let config_file = get_config_file(config_dir, false); + let mut app_config = Self::default(); + match serde_json::from_reader::<_, AppConfigPatch>(config_file) { + Ok(patch) => { + app_config.apply(patch); + } + // If deserialization fails, remove file and return the default. + Err(err) => { + eprintln!( + "Failed to deserialize application configuration file: {err}. Using defaults." + ); + app_config.save(config_dir); + } + } + app_config + } + + /// Saves currently loaded AppConfig into the given config directory file. + /// Warning: this will always overwrite file contents. + pub fn save(&self, config_dir: &Path) { + let file = get_config_file(config_dir, true); + match serde_json::to_writer(file, &self) { + Ok(()) => debug!("Application configuration file has been saved."), + Err(err) => { + error!( + "Application configuration file couldn't be saved. Failed to serialize: {err}", + ); + } + } + } + + /// Wraps MTU in an Option. We don't store Option directly in AppConfig to avoid struct-patch + /// ambiguity when applying updates coming from the frontend. An incoming MTU value of 0 is + /// interpreted as a request to fall back to the default. + #[must_use] + pub fn mtu(&self) -> Option { + match self.mtu { + 0 => None, + v => Some(v), + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{AppConfig, AppTheme, APP_CONFIG_FILE_NAME}; + + #[test] + fn test_new_creates_defaults_when_missing() { + let dir = tempdir().unwrap(); + let config = AppConfig::new(dir.path()); + let default = AppConfig::default(); + + assert_eq!(config.theme, default.theme); + assert_eq!(config.check_for_updates, default.check_for_updates); + assert_eq!(config.log_level, default.log_level); + assert_eq!(config.peer_alive_period, default.peer_alive_period); + assert_eq!(config.mtu(), default.mtu()); + // The config file is written out on first load. + assert!(dir.path().join(APP_CONFIG_FILE_NAME).exists()); + } + + #[test] + fn test_new_falls_back_on_corrupt_json() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(APP_CONFIG_FILE_NAME), b"{ not valid json").unwrap(); + + let config = AppConfig::new(dir.path()); + + assert_eq!(config.theme, AppConfig::default().theme); + assert_eq!( + config.peer_alive_period, + AppConfig::default().peer_alive_period + ); + } + + #[test] + fn test_new_applies_partial_patch() { + let dir = tempdir().unwrap(); + // Only override peer_alive_period; everything else stays at the defaults. + fs::write( + dir.path().join(APP_CONFIG_FILE_NAME), + br#"{"peer_alive_period": 42}"#, + ) + .unwrap(); + + let config = AppConfig::new(dir.path()); + + assert_eq!(config.peer_alive_period, 42); + assert_eq!(config.theme, AppConfig::default().theme); + assert_eq!( + config.check_for_updates, + AppConfig::default().check_for_updates + ); + } + + #[test] + fn test_save_round_trip() { + let dir = tempdir().unwrap(); + let config = AppConfig { + theme: AppTheme::Dark, + ..AppConfig::default() + }; + config.save(dir.path()); + + let reloaded = AppConfig::new(dir.path()); + assert_eq!(reloaded.theme, AppTheme::Dark); + } + + #[test] + fn test_mtu_zero_is_none() { + let config = AppConfig::default(); + assert_eq!(config.mtu, 0); + assert_eq!(config.mtu(), None); + } + + #[test] + fn test_mtu_nonzero_is_some() { + let config = AppConfig { + mtu: 1400, + ..AppConfig::default() + }; + assert_eq!(config.mtu(), Some(1400)); + } +} diff --git a/src-tauri/src/active_connections.rs b/src-tauri/core/src/connection/active_connections.rs similarity index 78% rename from src-tauri/src/active_connections.rs rename to src-tauri/core/src/connection/active_connections.rs index 970a31bd4..abe8ec54a 100644 --- a/src-tauri/src/active_connections.rs +++ b/src-tauri/core/src/connection/active_connections.rs @@ -3,22 +3,31 @@ use std::{collections::HashSet, sync::LazyLock}; use tokio::sync::Mutex; use crate::{ + connection::disconnect_interface, database::{ models::{connection::ActiveConnection, instance::Instance, location::Location, Id}, DB_POOL, }, error::Error, - utils::disconnect_interface, ConnectionType, }; -pub(crate) static ACTIVE_CONNECTIONS: LazyLock>> = +pub static ACTIVE_CONNECTIONS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); -pub(crate) async fn get_connection_id_by_type(connection_type: ConnectionType) -> Vec { +pub(crate) async fn active_connection_ids() -> Vec<(Id, ConnectionType)> { + ACTIVE_CONNECTIONS + .lock() + .await + .iter() + .map(|con| (con.location_id, con.connection_type)) + .collect() +} + +pub async fn get_connection_id_by_type(connection_type: ConnectionType) -> Vec { let active_connections = ACTIVE_CONNECTIONS.lock().await; - let connection_ids = active_connections + active_connections .iter() .filter_map(|con| { if con.connection_type == connection_type { @@ -27,9 +36,7 @@ pub(crate) async fn get_connection_id_by_type(connection_type: ConnectionType) - None } }) - .collect(); - - connection_ids + .collect() } pub async fn close_all_connections() -> Result<(), Error> { @@ -54,10 +61,7 @@ pub async fn close_all_connections() -> Result<(), Error> { Ok(()) } -pub(crate) async fn find_connection( - id: Id, - connection_type: ConnectionType, -) -> Option { +pub async fn find_connection(id: Id, connection_type: ConnectionType) -> Option { let connections = ACTIVE_CONNECTIONS.lock().await; trace!( "Checking for active connection with ID {id}, type {connection_type} in active connections." @@ -67,7 +71,6 @@ pub(crate) async fn find_connection( .iter() .find(|conn| conn.location_id == id && conn.connection_type == connection_type) { - // 'connection' now contains the first element with the specified id and connection_type trace!("Found connection: {connection:?}"); Some(connection.to_owned()) } else { @@ -79,9 +82,7 @@ pub(crate) async fn find_connection( } /// Returns active connections for a given instance. -pub(crate) async fn active_connections( - instance: &Instance, -) -> Result, Error> { +pub async fn active_connections(instance: &Instance) -> Result, Error> { let locations: HashSet = Location::find_by_instance_id(&*DB_POOL, instance.id, false) .await? .iter() diff --git a/src-tauri/core/src/connection/active_state.rs b/src-tauri/core/src/connection/active_state.rs new file mode 100644 index 000000000..d8e1ce4ec --- /dev/null +++ b/src-tauri/core/src/connection/active_state.rs @@ -0,0 +1,247 @@ +//! Reconstruct currently-active WireGuard connections by querying the platform backend. +//! +//! The daemon (Linux/Windows) and Network Extension managers (macOS) are the shared +//! source of truth for interface state. + +#[cfg(target_os = "macos")] +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +#[cfg(not(target_os = "macos"))] +use base64::Engine as _; +#[cfg(not(target_os = "macos"))] +use defguard_client_proto::defguard::client::v1::{InterfaceData, Peer}; +#[cfg(target_os = "macos")] +use objc2_network_extension::NEVPNStatus; +#[cfg(not(target_os = "macos"))] +use tonic::Code; + +#[cfg(target_os = "macos")] +use crate::{connection::apple::tunnel_stats, database::models::get_all_tunnels_locations}; +#[cfg(not(target_os = "macos"))] +use crate::{ + connection::daemon_client::DAEMON_CLIENT, + database::models::{location::Location, tunnel::Tunnel}, +}; +use crate::{ + database::{models::Id, DbPool}, + error::Error, + ConnectionType, +}; + +/// Describes a currently-active WireGuard connection. +#[derive(Clone, Debug)] +pub struct ActiveConnectionInfo { + /// Whether this is a server-defined `Location` or an imported `Tunnel`. + pub connection_type: ConnectionType, + /// The database id of the target (`Location.id` or `Tunnel.id`). + pub target_id: Id, + /// Human-readable name of the location or tunnel. + pub name: String, + /// Platform interface name, e.g. `"wg0"` on Linux. + pub interface_name: String, + /// Live statistics from the most recent backend probe, if any. + pub stats: Option, +} + +/// Snapshot of per-interface statistics retrieved from the backend. +#[derive(Clone, Debug)] +pub struct InterfaceStats { + pub listen_port: u32, + pub tx_bytes: u64, + pub rx_bytes: u64, + pub last_handshake: Option, +} + +#[cfg(target_os = "macos")] +impl From for InterfaceStats { + fn from(stats: super::apple::Stats) -> Self { + Self { + listen_port: 0, + tx_bytes: stats.tx_bytes, + rx_bytes: stats.rx_bytes, + last_handshake: (stats.last_handshake != 0).then_some(stats.last_handshake), + } + } +} + +/// Query the platform backend for all currently-up WireGuard interfaces and match each +/// peer back to a known `Location` or `Tunnel`. +/// +/// On Linux/Windows this calls the daemon's `ListInterfaces` RPC, which returns an +/// **unfiltered** snapshot of all managed interfaces (unlike `ReadInterfaceData`, which +/// drops peers that haven't completed a handshake or whose stats haven't changed). +/// +/// On macOS this queries Network Extension managers and asks connected providers for stats. +#[cfg(target_os = "macos")] +pub async fn active_state(_pool: &DbPool) -> Result, Error> { + let (tunnels, locations) = get_all_tunnels_locations().await; + let semaphore = Arc::new(AtomicBool::new(false)); + let semaphore_clone = Arc::clone(&semaphore); + + let handle = tokio::spawn(async move { + let mut result = Vec::new(); + for location in locations { + if let Some(NEVPNStatus::Connected) = location.status() { + let stats = tunnel_stats(location.id, &ConnectionType::Location).map(Into::into); + let info = ActiveConnectionInfo { + connection_type: ConnectionType::Location, + target_id: location.id, + name: location.name, + interface_name: String::new(), + stats, + }; + result.push(info); + } + } + + for tunnel in tunnels { + if let Some(NEVPNStatus::Connected) = tunnel.status() { + let stats = tunnel_stats(tunnel.id, &ConnectionType::Tunnel).map(Into::into); + let info = ActiveConnectionInfo { + connection_type: ConnectionType::Tunnel, + target_id: tunnel.id, + name: tunnel.name, + interface_name: String::new(), + stats, + }; + result.push(info); + } + } + + semaphore_clone.store(true, Ordering::Release); + + result + }); + super::apple::spawn_runloop_and_wait_for(&semaphore); + let result = handle.await.unwrap_or_default(); + + Ok(result) +} + +#[cfg(not(target_os = "macos"))] +pub async fn active_state(pool: &DbPool) -> Result, Error> { + let request = tonic::Request::new(()); + let response = DAEMON_CLIENT + .clone() + .list_interfaces(request) + .await + .map_err(|err| { + if err.code() == Code::Unavailable || err.code() == Code::Unimplemented { + error!("Daemon unavailable or outdated: {err}"); + Error::BackendUnavailable( + "Background service is unavailable or outdated. Start or update the background service.".into(), + ) + } else { + error!("Failed to call ListInterfaces: {err}"); + Error::InternalError(format!("ListInterfaces failed: {err}")) + } + })?; + let inner = response.into_inner(); + + info!( + "ListInterfaces returned {} managed interface(s)", + inner.interfaces.len() + ); + + let mut results = Vec::new(); + + for managed in &inner.interfaces { + let Some(iface_data) = &managed.data else { + continue; + }; + + for peer in &iface_data.peers { + // The daemon returns public keys as lower hex (Key::to_lower_hex()), + // but the database stores them as base64. Convert for matching. + let public_key_hex = &peer.public_key; + let public_key_b64 = match hex_to_base64(public_key_hex) { + Ok(k) => k, + Err(e) => { + warn!("Failed to convert hex pubkey to base64: {e}"); + continue; + } + }; + + // Try matching the peer to a Location first. + match Location::find_by_public_key(pool, &public_key_b64).await { + Ok(location) => { + info!( + "Matched peer to location {} (id={})", + location.name, location.id + ); + results.push(ActiveConnectionInfo { + connection_type: ConnectionType::Location, + target_id: location.id, + name: location.name.clone(), + interface_name: managed.interface_name.clone(), + stats: peer_stats(iface_data, peer), + }); + continue; + } + Err(sqlx::Error::RowNotFound) => { + // Not a Location, try Tunnel below. + } + Err(err) => { + warn!("DB error looking up public key: {err}"); + continue; + } + } + + // Then try matching to a Tunnel. + match Tunnel::find_by_server_public_key(pool, &public_key_b64).await { + Ok(tunnel) => { + info!("Matched peer to tunnel {} (id={})", tunnel.name, tunnel.id); + results.push(ActiveConnectionInfo { + connection_type: ConnectionType::Tunnel, + target_id: tunnel.id, + name: tunnel.name.clone(), + interface_name: managed.interface_name.clone(), + stats: peer_stats(iface_data, peer), + }); + continue; + } + Err(sqlx::Error::RowNotFound) => { + // Not a Tunnel either. + } + Err(err) => { + warn!("DB error looking up server public key: {err}"); + continue; + } + } + + debug!("Peer does not match any Location or Tunnel, skipping"); + } + } + + info!("active_state: found {} active connection(s)", results.len()); + Ok(results) +} + +/// Extract per-peer stats from an `InterfaceData` response. +/// +/// `ListInterfaces` returns an unfiltered snapshot that includes peers which have never +/// completed a handshake. Such a peer is not actually connected, so return `None` for it - +/// keeping the `Option` contract meaningful for callers. +#[cfg(not(target_os = "macos"))] +fn peer_stats(iface: &InterfaceData, peer: &Peer) -> Option { + match peer.last_handshake { + Some(ts) if ts > 0 => Some(InterfaceStats { + listen_port: iface.listen_port, + tx_bytes: peer.tx_bytes, + rx_bytes: peer.rx_bytes, + last_handshake: peer.last_handshake, + }), + _ => None, + } +} + +/// Convert a hex-encoded public key to base64, matching the database format. +#[cfg(not(target_os = "macos"))] +fn hex_to_base64(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str) + .map_err(|e| Error::ConversionError(format!("Invalid hex pubkey: {e}")))?; + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} diff --git a/src-tauri/core/src/connection/apple.rs b/src-tauri/core/src/connection/apple.rs new file mode 100644 index 000000000..83d0570fa --- /dev/null +++ b/src-tauri/core/src/connection/apple.rs @@ -0,0 +1,453 @@ +//! Interchangeability and communication with VPNExtension (written in Swift). + +use std::{ + collections::HashMap, + hint::spin_loop, + ptr::NonNull, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, channel, Receiver, RecvTimeoutError, Sender}, + Arc, LazyLock, Mutex, + }, + time::Duration, +}; + +const OBSERVER_CLEANUP_INTERVAL: Duration = Duration::from_secs(30); + +use block2::RcBlock; +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_foundation::{ + ns_string, NSArray, NSData, NSDate, NSError, NSNotification, NSNotificationCenter, NSNumber, + NSObjectProtocol, NSOperationQueue, NSRunLoop, NSString, +}; +use objc2_network_extension::{ + NETunnelProviderManager, NETunnelProviderProtocol, NETunnelProviderSession, NEVPNConnection, + NEVPNStatusDidChangeNotification, +}; +use serde::Deserialize; + +use crate::{ + database::{ + models::{location::Location, tunnel::Tunnel, Id}, + DB_POOL, + }, + ConnectionType, +}; + +pub const PLUGIN_BUNDLE_ID: &str = "net.defguard.VPNExtension"; +pub const LOCATION_ID: &str = "locationId"; +pub const TUNNEL_ID: &str = "tunnelId"; + +type ObserverSender = Mutex>; +type ObserverReceiver = Mutex>>; + +pub static OBSERVER_COMMS: LazyLock<(ObserverSender, ObserverReceiver)> = LazyLock::new(|| { + let (tx, rx) = mpsc::channel(); + (Mutex::new(tx), Mutex::new(Some(rx))) +}); + +type VpnStateSender = Mutex>; +type VpnStateReceiver = Mutex>>; + +pub static VPN_STATE_UPDATE_COMMS: LazyLock<(VpnStateSender, VpnStateReceiver)> = + LazyLock::new(|| { + let (tx, rx) = mpsc::channel(); + (Mutex::new(tx), Mutex::new(Some(rx))) + }); + +/// Thread responsible for observing VPN status changes. +/// This is intentionally a blocking function, as it uses the Objective-C objects which are not +/// thread safe. +pub fn observer_thread( + initial_managers: HashMap<(&'static str, Id), Retained>, +) { + debug!("Starting VPN connection observer thread"); + let receiver = { + let mut rx_opt = OBSERVER_COMMS + .1 + .lock() + .expect("Failed to lock observer receiver"); + rx_opt.take().expect("Receiver already taken") + }; + + let mut observers = HashMap::new(); + + // spawn initial observers for existing managers + for ((key, value), manager) in initial_managers { + debug!("Spawning initial observer for manager with key: {key}, value: {value}"); + let connection = unsafe { manager.connection() }; + let observer = create_observer(&connection); + debug!("Registered initial observer for manager with key: {key}, value: {value}"); + observers.insert((key, value), observer); + } + + loop { + match receiver.recv_timeout(OBSERVER_CLEANUP_INTERVAL) { + Ok(message) => { + debug!("Received message to observe the following connection: {message:?}"); + + let (key, value) = message; + + if observers.contains_key(&(key, value)) { + debug!( + "Observer for manager with key: {key}, value: {value} already exists, + skipping", + ); + continue; + } + + let manager = manager_for_key_and_value(key, value).unwrap(); + let connection = unsafe { manager.connection() }; + let observer = create_observer(&connection); + + observers.insert((key, value), observer); + debug!("Registered observer for manager with key: {key}, value: {value}"); + } + Err(RecvTimeoutError::Timeout) => { + debug!("Performing periodic cleanup of dead observers"); + let mut dead_keys = Vec::new(); + + for (key, value) in observers.keys() { + if manager_for_key_and_value(key, *value).is_none() { + debug!( + "Manager for key: {key}, value: {value} no longer exists, marking for + removal" + ); + dead_keys.push((*key, *value)); + } + } + + for dead_key in dead_keys { + if let Some(_observer) = observers.remove(&dead_key) { + debug!( + "Removed dead VPN connection observer for key: {}, value: {}", + dead_key.0, dead_key.1 + ); + } + } + } + Err(RecvTimeoutError::Disconnected) => { + error!("Observer receiver channel disconnected, exiting observer thread"); + break; + } + } + } + + debug!("Exiting VPN connection observer thread"); +} + +/// Run [`NSRunLoop`] until semaphore becomes `true`. +pub fn spawn_runloop_and_wait_for(semaphore: &Arc) { + const ONE_SECOND: f64 = 1.; + let run_loop = NSRunLoop::currentRunLoop(); + let mut date = NSDate::dateWithTimeIntervalSinceNow(ONE_SECOND); + loop { + run_loop.runUntilDate(&date); + if semaphore.load(Ordering::Acquire) { + break; + } + date = date.dateByAddingTimeInterval(ONE_SECOND); + } +} + +/// Tunnel statistics shared with VPNExtension (written in Swift). +#[derive(Deserialize)] +#[repr(C)] +#[serde(rename_all = "camelCase")] +pub struct Stats { + pub location_id: Option, + pub tunnel_id: Option, + pub tx_bytes: u64, + pub rx_bytes: u64, + pub last_handshake: u64, +} + +/// Retrieve VPN tunnel statistics from VPNExtension. +pub fn tunnel_stats(id: Id, connection_type: &ConnectionType) -> Option { + let new_stats = Arc::new(Mutex::new(None)); + let plugin_bundle_id = ns_string!(PLUGIN_BUNDLE_ID); + + let new_stats_clone = Arc::clone(&new_stats); + + let finished = Arc::new(AtomicBool::new(false)); + let finished_clone = Arc::clone(&finished); + + let response_handler = RcBlock::new(move |data_ptr: *mut NSData| { + if let Some(data) = unsafe { data_ptr.as_ref() } { + if let Ok(stats) = serde_json::from_slice(data.to_vec().as_slice()) { + if let Ok(mut new_stats_locked) = new_stats_clone.lock() { + *new_stats_locked = Some(stats); + } + } else { + warn!("Failed to deserialize tunnel stats"); + } + } else { + debug!("No data received in tunnel stats response, skipping"); + } + finished_clone.store(true, Ordering::Release); + }); + + let manager = manager_for_key_and_value( + match connection_type { + ConnectionType::Location => LOCATION_ID, + ConnectionType::Tunnel => TUNNEL_ID, + }, + id, + )?; + + let vpn_protocol = (unsafe { manager.protocolConfiguration() })?; + let Ok(tunnel_protocol) = vpn_protocol.downcast::() else { + error!("Failed to downcast to NETunnelProviderProtocol"); + return None; + }; + + // Sometimes all managers from all apps come through, so filter by bundle ID. + if let Some(bundle_id) = unsafe { tunnel_protocol.providerBundleIdentifier() } { + if &*bundle_id != plugin_bundle_id { + return None; + } + } + + let Ok(session) = unsafe { manager.connection() }.downcast::() else { + error!("Failed to downcast to NETunnelProviderSession"); + return None; + }; + + let message_data = NSData::new(); + if unsafe { + session.sendProviderMessage_returnError_responseHandler( + &message_data, + None, + Some(&response_handler), + ) + } { + debug!("Message sent to NETunnelProviderSession"); + } else { + error!("Failed to send to NETunnelProviderSession while requesting stats"); + } + + // Wait for the response handler to complete. + while !finished.load(Ordering::Acquire) { + spin_loop(); + } + + new_stats + .lock() + .map_or(None, |mut new_stats_locked| new_stats_locked.take()) +} + +/// Handle VPN status change. +fn vpn_status_change_handler(notification: &NSNotification) { + let name = notification.name(); + debug!("Received VPN status change notification: {name:?}"); + VPN_STATE_UPDATE_COMMS + .0 + .lock() + .expect("Failed to lock state update sender") + .send(()) + .expect("Failed to send to state update channel"); + debug!("Sent status update request to channel"); +} + +/// Observe VPN status change. +fn create_observer(object: &NEVPNConnection) -> Retained> { + let center = NSNotificationCenter::defaultCenter(); + let block = RcBlock::new(move |notification: NonNull| { + vpn_status_change_handler(unsafe { notification.as_ref() }); + }); + let queue = NSOperationQueue::mainQueue(); + unsafe { + let name = NEVPNStatusDidChangeNotification; + center.addObserverForName_object_queue_usingBlock( + Some(name), + Some(object), + Some(&queue), + &block, + ) + } +} + +#[must_use] +pub fn get_managers_for_tunnels_and_locations( + tunnels: &[Tunnel], + locations: &[Location], +) -> HashMap<(&'static str, Id), Retained> { + let mut managers = HashMap::new(); + + for location in locations { + if let Some(manager) = manager_for_key_and_value(LOCATION_ID, location.id) { + managers.insert((LOCATION_ID, location.id), manager); + } + } + + for tunnel in tunnels { + if let Some(manager) = manager_for_key_and_value(TUNNEL_ID, tunnel.id) { + managers.insert((TUNNEL_ID, tunnel.id), manager); + } + } + + managers +} + +/// Try to get `Id` out of manager. ID is embedded in configuration dictionary under `key`. +fn id_from_manager(manager: &NETunnelProviderManager, key: &NSString) -> Option { + let plugin_bundle_id = ns_string!(PLUGIN_BUNDLE_ID); + + let vpn_protocol = (unsafe { manager.protocolConfiguration() })?; + let Ok(tunnel_protocol) = vpn_protocol.downcast::() else { + error!("Failed to downcast to NETunnelProviderProtocol"); + return None; + }; + // Sometimes all managers from all apps come through, so filter by bundle ID. + if let Some(bundle_id) = unsafe { tunnel_protocol.providerBundleIdentifier() } { + if &*bundle_id != plugin_bundle_id { + return None; + } + } + + if let Some(config_dict) = unsafe { tunnel_protocol.providerConfiguration() } { + if let Some(any_object) = config_dict.objectForKey(key) { + let Ok(id) = any_object.downcast::() else { + warn!("Failed to downcast ID to NSNumber"); + return None; + }; + return Some(id.as_i64()); + } + } + + None +} + +/// Try to find [`NETunnelProviderManager`] in system settings that matches key and value. +/// Key is usually `locationId` or `tunnelId`. +#[must_use] +pub fn manager_for_key_and_value( + key: &str, + value: Id, +) -> Option> { + let key_string = NSString::from_str(key); + let (tx, rx) = channel(); + + let handler = RcBlock::new( + move |managers_ptr: *mut NSArray, error_ptr: *mut NSError| { + if !error_ptr.is_null() { + error!("Failed to load tunnel provider managers."); + return; + } + + let Some(managers) = (unsafe { managers_ptr.as_ref() }) else { + error!("No managers"); + return; + }; + + for manager in managers { + if let Some(id) = id_from_manager(&manager, &key_string) { + if id == value { + // This is the manager we were looking for. + tx.send(Some(manager)).expect("Sender is dead"); + return; + } + } + } + + tx.send(None).expect("Sender is dead"); + }, + ); + unsafe { + NETunnelProviderManager::loadAllFromPreferencesWithCompletionHandler(&handler); + } + + rx.recv().expect("Receiver is dead") +} + +/// Synchronize locations and tunnels with system settings. +pub async fn sync_locations_and_tunnels(mtu: Option) -> Result<(), sqlx::Error> { + // Update location settings. + let all_locations = Location::all(&*DB_POOL, false).await?; + for location in &all_locations { + // For syncing, set `preshred_key` to `None`. + let Ok(tunnel_config) = location.tunnel_configuration(None, mtu).await else { + error!( + "Failed to convert location {} to tunnel configuration.", + location.name + ); + continue; + }; + tunnel_config.save(); + } + + // Update tunnel settings. + let all_tunnels = Tunnel::all(&*DB_POOL).await?; + for tunnel in &all_tunnels { + let Ok(tunnel_config) = tunnel.tunnel_configuration(mtu) else { + error!( + "Failed to convert tunnel {} to tunnel configuration.", + tunnel.name + ); + continue; + }; + tunnel_config.save(); + } + + debug!("Saved all configurations with system settings."); + + // Convert to Vec. + let mut all_location_ids = all_locations + .into_iter() + .map(|entry| entry.id) + .collect::>(); + let mut all_tunnel_ids = all_tunnels + .into_iter() + .map(|entry| entry.id) + .collect::>(); + // For faster lookup using binary search (see below). + all_location_ids.sort_unstable(); + all_tunnel_ids.sort_unstable(); + + let spinlock = Arc::new(AtomicBool::new(false)); + let spinlock_clone = Arc::clone(&spinlock); + let handler = RcBlock::new( + move |managers_ptr: *mut NSArray, error_ptr: *mut NSError| { + if !error_ptr.is_null() { + error!("Failed to load tunnel provider managers."); + return; + } + + let Some(managers) = (unsafe { managers_ptr.as_ref() }) else { + error!("No managers"); + return; + }; + + let location_key = NSString::from_str(LOCATION_ID); + let tunnel_key = NSString::from_str(TUNNEL_ID); + for manager in managers { + if let Some(id) = id_from_manager(&manager, &location_key) { + if all_location_ids.binary_search(&id).is_ok() { + // Known location - skip. + continue; + } + } + if let Some(id) = id_from_manager(&manager, &tunnel_key) { + if all_tunnel_ids.binary_search(&id).is_ok() { + // Known tunnel - skip. + continue; + } + } + unsafe { manager.removeFromPreferencesWithCompletionHandler(None) }; + } + + spinlock_clone.store(true, Ordering::Release); + }, + ); + unsafe { + NETunnelProviderManager::loadAllFromPreferencesWithCompletionHandler(&handler); + } + + while !spinlock.load(Ordering::Acquire) { + spin_loop(); + } + + debug!("Removed unknown configurations from system settings."); + + Ok(()) +} diff --git a/src-tauri/core/src/connection/daemon_client.rs b/src-tauri/core/src/connection/daemon_client.rs new file mode 100644 index 000000000..46c9268c8 --- /dev/null +++ b/src-tauri/core/src/connection/daemon_client.rs @@ -0,0 +1,80 @@ +use std::sync::LazyLock; + +use defguard_client_proto::defguard::client::v1::desktop_daemon_service_client::DesktopDaemonServiceClient; +use hyper_util::rt::TokioIo; +#[cfg(windows)] +use tokio::net::windows::named_pipe::ClientOptions; +#[cfg(unix)] +use tokio::net::UnixStream; +use tonic::transport::channel::{Channel, Endpoint}; +#[cfg(unix)] +use tonic::transport::Uri; +use tower::service_fn; +#[cfg(windows)] +use windows_sys::Win32::Foundation::ERROR_PIPE_BUSY; + +#[cfg(unix)] +const DAEMON_SOCKET_PATH: &str = "/var/run/defguard.socket"; + +/// Returns the daemon socket path. In test/debug builds the +/// `DEFGUARD_DAEMON_SOCKET` environment variable can override the default +/// (useful for integration tests). In release builds the override is +/// disabled to prevent an undocumented channel-redirection surface. +#[cfg(unix)] +#[must_use] +pub fn daemon_socket_path() -> String { + #[cfg(any(test, debug_assertions))] + if let Ok(path) = std::env::var("DEFGUARD_DAEMON_SOCKET") { + return path; + } + DAEMON_SOCKET_PATH.to_string() +} +#[cfg(windows)] +const PIPE_NAME: &str = r"\\.\pipe\defguard_daemon"; + +pub static DAEMON_CLIENT: LazyLock> = LazyLock::new(|| { + debug!("Setting up gRPC client"); + let endpoint = Endpoint::from_static("http://localhost"); + let channel; + #[cfg(unix)] + { + channel = endpoint.connect_with_connector_lazy(service_fn(|_: Uri| async { + let stream = match UnixStream::connect(daemon_socket_path()).await { + Ok(stream) => stream, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + error!( + "Permission denied for UNIX domain socket; please refer to \ + https://docs.defguard.net/support-1/troubleshooting#\ + unix-socket-permission-errors-when-desktop-client-attempts-to-connect-\ + to-vpn-on-linux-machines" + ); + return Err(err); + } + Err(err) => { + error!("Problem connecting to UNIX domain socket: {err}"); + return Err(err); + } + }; + info!("Created unix gRPC client"); + Ok::<_, std::io::Error>(TokioIo::new(stream)) + })); + }; + #[cfg(windows)] + { + channel = endpoint.connect_with_connector_lazy(service_fn(|_| async { + let client = loop { + match ClientOptions::new().open(PIPE_NAME) { + Ok(client) => break client, + Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => (), + Err(err) => { + error!("Problem connecting to named pipe: {err}"); + return Err(err); + } + } + }; + info!("Created windows gRPC client"); + Ok::<_, std::io::Error>(TokioIo::new(client)) + })); + } + DesktopDaemonServiceClient::new(channel) +}); diff --git a/src-tauri/core/src/connection/mod.rs b/src-tauri/core/src/connection/mod.rs new file mode 100644 index 000000000..d289f2d8c --- /dev/null +++ b/src-tauri/core/src/connection/mod.rs @@ -0,0 +1,173 @@ +pub mod active_connections; +pub mod active_state; +pub mod daemon_client; +pub mod setup; + +#[cfg(target_os = "macos")] +pub mod apple; + +#[cfg(target_os = "macos")] +use std::time::Duration; + +use active_state::ActiveConnectionInfo; +#[cfg(target_os = "macos")] +pub use apple::sync_locations_and_tunnels; +use chrono::Utc; +pub use setup::{disconnect_interface, execute_command}; +#[cfg(not(target_os = "macos"))] +pub use setup::{setup_interface, setup_interface_tunnel}; +#[cfg(target_os = "macos")] +use tokio::time::sleep; + +use crate::{ + connection::active_connections::active_connection_ids, + database::{ + models::{connection::ActiveConnection, location::Location, tunnel::Tunnel, Id}, + DbPool, + }, + error::Error, + ConnectionType, +}; + +#[cfg(target_os = "macos")] +const TUNNEL_START_DELAY: Duration = Duration::from_secs(1); + +/// Identifies the type of connection target. +pub enum ConnectionTarget { + Location(Location), + Tunnel(Tunnel), +} + +impl ConnectionTarget { + pub async fn ensure_single_all_traffic_connection( + &self, + pool: &DbPool, + route_all_traffic: Option, + ) -> Result<(), Error> { + let (id, connection_type, name, holds_default_route) = match self { + Self::Location(location) => ( + location.id, + ConnectionType::Location, + &location.name, + location + .holds_default_route(pool, route_all_traffic) + .await?, + ), + Self::Tunnel(tunnel) => ( + tunnel.id, + ConnectionType::Tunnel, + &tunnel.name, + tunnel.holds_default_route(route_all_traffic), + ), + }; + + if !holds_default_route { + return Ok(()); + } + + if let Some((active_type, active_name)) = + find_default_route_owner(pool, (id, connection_type)).await? + { + error!( + "Refusing to connect {connection_type} \"{name}\" (ID {id}): it routes all \ + traffic, but {active_type} \"{active_name}\" already holds the default route." + ); + return Err(Error::AllTrafficConflict(format!( + "Can't connect to {connection_type} \"{name}\": {active_type} \"{active_name}\" \ + is already routing all traffic. Only one connection can route all traffic at a \ + time, so disconnect it first or turn off \"route all traffic\" for one of them." + ))); + } + + Ok(()) + } +} + +async fn find_default_route_owner( + pool: &DbPool, + exclude: (Id, ConnectionType), +) -> Result, Error> { + for (id, connection_type) in active_connection_ids().await { + if (id, connection_type) == exclude { + continue; + } + let owner = match connection_type { + ConnectionType::Location => match Location::find_by_id(pool, id).await? { + Some(location) => location + .holds_default_route(pool, None) + .await? + .then_some(location.name), + None => None, + }, + ConnectionType::Tunnel => match Tunnel::find_by_id(pool, id).await? { + Some(tunnel) => tunnel.holds_default_route(None).then_some(tunnel.name), + None => None, + }, + }; + if let Some(name) = owner { + return Ok(Some((connection_type, name))); + } + } + + Ok(None) +} + +/// Bring a WireGuard interface up for the given target. +pub async fn bring_up( + target: ConnectionTarget, + psk: Option, + mtu: Option, + pool: &DbPool, + route_all_traffic: Option, +) -> Result { + target + .ensure_single_all_traffic_connection(pool, route_all_traffic) + .await?; + + #[cfg(not(target_os = "macos"))] + { + match target { + ConnectionTarget::Location(loc) => { + let name = loc.name.clone(); + setup::setup_interface(loc, &name, psk, mtu, pool, route_all_traffic).await + } + ConnectionTarget::Tunnel(tun) => { + let name = tun.name.clone(); + setup::setup_interface_tunnel(tun, &name, mtu, route_all_traffic).await + } + } + } + + #[cfg(target_os = "macos")] + { + let tunnel_config = match target { + ConnectionTarget::Location(loc) => loc.tunnel_configuration(psk, mtu).await, + ConnectionTarget::Tunnel(tun) => tun.tunnel_configuration(mtu), + }?; + + tunnel_config.save(); + sleep(TUNNEL_START_DELAY).await; + tunnel_config.start_tunnel(); + + // On macOS the interface name is managed by the system. + Ok(String::new()) + } +} + +/// Tear down a WireGuard interface identified by `ActiveConnectionInfo`. +// +// FIXME: This constructs an `ActiveConnection` with `start: Utc::now()`, +// which records a zero-duration connection when saved. This impacts the +// connection history overview (all entries appear instant). Connection +// tracking should be refactored to carry the real start time from the +// active-state record through to the history persistence path. +pub async fn tear_down(conn: &ActiveConnectionInfo) -> Result<(), Error> { + let connection = ActiveConnection { + location_id: conn.target_id, + connection_type: conn.connection_type, + start: Utc::now().naive_utc(), + interface_name: conn.interface_name.clone(), + }; + + disconnect_interface(&connection).await +} diff --git a/src-tauri/core/src/connection/setup.rs b/src-tauri/core/src/connection/setup.rs new file mode 100644 index 000000000..e98cf9fab --- /dev/null +++ b/src-tauri/core/src/connection/setup.rs @@ -0,0 +1,419 @@ +use std::process::Command; +/// Connection setup helpers. +use std::str::FromStr; + +use defguard_client_common::{find_free_tcp_port, get_interface_name}; +use defguard_client_proto::defguard::client::v1::CreateInterfaceRequest; +#[cfg(not(target_os = "macos"))] +use defguard_client_proto::defguard::client::v1::RemoveInterfaceRequest; +use defguard_wireguard_rs::{key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration}; +#[cfg(not(target_os = "macos"))] +use tonic::Code; + +#[cfg(not(target_os = "macos"))] +use crate::database::DbPool; +use crate::{ + connection::daemon_client::DAEMON_CLIENT, + database::{ + models::{ + connection::{ActiveConnection, Connection}, + location::Location, + tunnel::{Tunnel, TunnelConnection}, + Id, + }, + DB_POOL, + }, + error::Error, + ConnectionType, DEFAULT_ROUTE_IPV4, DEFAULT_ROUTE_IPV6, +}; + +#[cfg(not(target_os = "macos"))] +pub async fn setup_interface( + location: Location, + name: &str, + preshared_key: Option, + mtu: Option, + pool: &DbPool, + route_all_traffic: Option, +) -> Result { + debug!("Setting up interface for location: {location}"); + let interface_name = get_interface_name(name); + + debug!("Looking for a free port for interface {interface_name}."); + let Some(port) = find_free_tcp_port() else { + let msg = format!( + "Couldn't find free port during interface {interface_name} setup for location {location}" + ); + error!("{msg}"); + return Err(Error::InternalError(msg)); + }; + debug!("Found free port: {port} for interface {interface_name}."); + + let interface_config = location + .interface_configuration( + pool, + interface_name.clone(), + preshared_key, + mtu, + route_all_traffic, + ) + .await?; + debug!("Creating interface for location {location} with configuration {interface_config:?}"); + let request = CreateInterfaceRequest { + config: Some(interface_config.clone().into()), + dns: location.dns.clone(), + }; + if let Err(error) = DAEMON_CLIENT.clone().create_interface(request).await { + if error.code() == Code::Unavailable { + error!( + "Failed to set up connection for location {location}; background service is \ + unavailable. Make sure the service is running. Error: {error}" + ); + Err(Error::BackendUnavailable( + "Background service is unavailable. Make sure the service is running.".into(), + )) + } else { + error!( + "Failed to send a request to the background service to create an interface for \ + location {location}. Error: {error}" + ); + Err(Error::InternalError(format!( + "Failed to send a request to the background service to create an interface for \ + location {location}. Error: {error}. Check logs for details." + ))) + } + } else { + info!( + "The interface for location {location} has been created successfully, interface \ + name: {}.", + interface_config.name + ); + Ok(interface_name) + } +} + +pub async fn setup_interface_tunnel( + tunnel: Tunnel, + name: &str, + mtu: Option, + route_all_traffic: Option, +) -> Result { + debug!("Setting up interface for tunnel {tunnel}"); + let interface_name = get_interface_name(name); + + debug!( + "Decoding tunnel {tunnel} public key: {}.", + tunnel.server_pubkey + ); + let peer_key = Key::from_str(&tunnel.server_pubkey)?; + debug!("Tunnel {tunnel} public key decoded."); + let mut peer = Peer::new(peer_key); + + debug!("Parsing tunnel {tunnel} endpoint: {}", tunnel.endpoint); + peer.set_endpoint(&tunnel.endpoint)?; + peer.persistent_keepalive_interval = Some( + tunnel + .persistent_keep_alive + .try_into() + .expect("Failed to parse persistent keep alive"), + ); + debug!("Parsed tunnel {tunnel} endpoint: {}", tunnel.endpoint); + + if let Some(psk) = &tunnel.preshared_key { + debug!("Decoding tunnel {tunnel} preshared key."); + let peer_psk = Key::from_str(psk)?; + debug!("Preshared key for tunnel {tunnel} decoded."); + peer.preshared_key = Some(peer_psk); + } + + debug!( + "Parsing tunnel {tunnel} allowed ips: {:?}", + tunnel.allowed_ips + ); + let route_all_traffic = tunnel.effective_route_all_traffic(route_all_traffic); + let allowed_ips = if route_all_traffic { + debug!("Using all traffic routing for tunnel {tunnel}"); + vec![DEFAULT_ROUTE_IPV4.into(), DEFAULT_ROUTE_IPV6.into()] + } else { + let msg = match &tunnel.allowed_ips { + Some(ips) => format!("Using predefined location traffic for tunnel {tunnel}: {ips}"), + None => format!("No allowed IP addresses found in tunnel {tunnel} configuration"), + }; + debug!("{msg}"); + tunnel + .allowed_ips + .as_ref() + .map(|ips| ips.split(',').map(str::to_string).collect()) + .unwrap_or_default() + }; + for allowed_ip in &allowed_ips { + match IpAddrMask::from_str(allowed_ip.trim()) { + Ok(addr) => { + peer.allowed_ips.push(addr); + } + Err(err) => { + error!("Error parsing IP address {allowed_ip}: {err}"); + } + } + } + debug!("Parsed tunnel {tunnel} allowed IPs: {:?}", peer.allowed_ips); + + debug!("Looking for a free port for interface {interface_name}."); + let Some(port) = find_free_tcp_port() else { + let msg = format!( + "Couldn't find free port for interface {interface_name} while setting up tunnel \ + {tunnel}" + ); + error!("{msg}"); + return Err(Error::InternalError(msg)); + }; + debug!("Found free port: {port} for interface {interface_name}."); + + let addresses = tunnel + .address + .split(',') + .map(str::trim) + .map(IpAddrMask::from_str) + .collect::>() + .map_err(|err| { + let msg = format!("Failed to parse IP addresses '{}': {err}", tunnel.address); + error!("{msg}"); + Error::InternalError(msg) + })?; + let interface_config = InterfaceConfiguration { + name: interface_name.clone(), + prvkey: tunnel.prvkey.clone(), + addresses, + port, + peers: vec![peer.clone()], + mtu, + fwmark: None, + }; + + debug!("Creating interface {interface_config:?}"); + let request = CreateInterfaceRequest { + config: Some(interface_config.clone().into()), + dns: tunnel.dns.clone(), + }; + if let Some(pre_up) = &tunnel.pre_up { + debug!( + "Executing defined PreUp command before setting up the interface {} for the tunnel \ + {tunnel}: {pre_up}", + interface_config.name + ); + let _ = execute_command(pre_up); + info!( + "Executed defined PreUp command before setting up the interface {} for the tunnel \ + {tunnel}: {pre_up}", + interface_config.name + ); + } + if let Err(error) = DAEMON_CLIENT.clone().create_interface(request).await { + error!( + "Failed to create a network interface ({}) for tunnel {tunnel}: {error}", + interface_config.name + ); + return Err(Error::InternalError(format!( + "Failed to create a network interface ({}) for tunnel {tunnel}, error message: {}. \ + Check logs for more details.", + interface_config.name, + error.message() + ))); + } + + info!( + "Network interface {} for tunnel {tunnel} created successfully.", + interface_config.name + ); + if let Some(post_up) = &tunnel.post_up { + debug!( + "Executing defined PostUp command after setting up the interface {} for the tunnel \ + {tunnel}: {post_up}", + interface_config.name + ); + let _ = execute_command(post_up); + info!( + "Executed defined PostUp command after setting up the interface {} for the tunnel \ + {tunnel}: {post_up}", + interface_config.name + ); + } + debug!( + "Created interface {} with config: {interface_config:?}", + interface_config.name + ); + + Ok(interface_name) +} + +pub async fn disconnect_interface(active_connection: &ActiveConnection) -> Result<(), Error> { + debug!( + "Disconnecting interface {}.", + active_connection.interface_name + ); + let location_id = active_connection.location_id; + let interface_name = active_connection.interface_name.clone(); + + match active_connection.connection_type { + ConnectionType::Location => { + let Some(location) = Location::find_by_id(&*DB_POOL, location_id).await? else { + error!( + "Error while disconnecting interface {interface_name}, location with ID \ + {location_id} not found" + ); + return Err(Error::NotFound); + }; + + #[cfg(target_os = "macos")] + { + let result = location.stop_vpn_tunnel(); + if !result { + error!("stop_tunnel() for location {} failed", location.name); + return Err(Error::InternalError("Error from tunnel".into())); + } + debug!("stop_tunnel() for location {} succeeded", location.name); + } + + #[cfg(not(target_os = "macos"))] + { + let request = RemoveInterfaceRequest { + interface_name, + endpoint: location.endpoint.clone(), + }; + debug!( + "Sending request to the background service to remove interface {} for \ + location {}...", + active_connection.interface_name, location.name + ); + if let Err(error) = DAEMON_CLIENT.clone().remove_interface(request).await { + let msg = if error.code() == Code::Unavailable { + format!( + "Couldn't remove interface {}. Background service is unavailable. \ + Please make sure the service is running. Error: {error}.", + active_connection.interface_name + ) + } else { + format!( + "Failed to send a request to the background service to remove \ + interface {}. Error: {error}.", + active_connection.interface_name + ) + }; + error!("{msg}"); + } + } + + let connection: Connection = active_connection.into(); + let connection = connection.save(&*DB_POOL).await?; + debug!( + "Saved location {} new connection status in the database", + location.name + ); + trace!("Saved connection: {connection:?}"); + info!( + "Network interface {} for location {location} has been removed", + active_connection.interface_name + ); + debug!("Finished disconnecting from location {}", location.name); + } + ConnectionType::Tunnel => { + let Some(tunnel) = Tunnel::find_by_id(&*DB_POOL, location_id).await? else { + error!( + "Error while disconnecting interface {interface_name}, tunnel with ID \ + {location_id} not found" + ); + return Err(Error::NotFound); + }; + if let Some(pre_down) = &tunnel.pre_down { + debug!( + "Executing defined PreDown command before setting up the interface {} for \ + the tunnel {tunnel}: {pre_down}", + active_connection.interface_name + ); + let _ = execute_command(pre_down); + info!( + "Executed defined PreDown command before setting up the interface {} for \ + the tunnel {tunnel}: {pre_down}", + active_connection.interface_name + ); + } + + #[cfg(target_os = "macos")] + { + let result = tunnel.stop_vpn_tunnel(); + if !result { + error!("stop_tunnel() for tunnel {} failed", tunnel.name); + return Err(Error::InternalError("Error from tunnel".into())); + } + debug!("stop_tunnel() for tunnel {} succeeded", tunnel.name); + } + + #[cfg(not(target_os = "macos"))] + { + let request = RemoveInterfaceRequest { + interface_name, + endpoint: tunnel.endpoint.clone(), + }; + if let Err(error) = DAEMON_CLIENT.clone().remove_interface(request).await { + error!( + "Error while removing interface {}, error details: {error:?}", + active_connection.interface_name + ); + return Err(Error::InternalError(format!( + "Failed to remove interface, error message: {}", + error.message() + ))); + } + } + if let Some(post_down) = &tunnel.post_down { + debug!( + "Executing defined PostDown command after removing the interface {} for \ + the tunnel {tunnel}: {post_down}", + active_connection.interface_name + ); + let _ = execute_command(post_down); + info!( + "Executed defined PostDown command after removing the interface {} for \ + the tunnel {tunnel}: {post_down}", + active_connection.interface_name + ); + } + let connection: TunnelConnection = active_connection.into(); + let connection = connection.save(&*DB_POOL).await?; + debug!( + "Saved new tunnel {} connection status in the database", + tunnel.name + ); + trace!("Saved connection: {connection:#?}"); + info!( + "Network interface {} for tunnel {tunnel} has been removed", + active_connection.interface_name + ); + debug!("Finished disconnecting from tunnel {}", tunnel.name); + } + } + + Ok(()) +} + +pub fn execute_command(command: &str) -> Result<(), Error> { + debug!("Executing command: {command}"); + let mut command_parts = command.split_whitespace(); + + if let Some(command) = command_parts.next() { + let output = Command::new(command).args(command_parts).output()?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + debug!("Command {command} executed successfully. Stdout: {stdout}"); + if !stderr.is_empty() { + error!("Command produced the following output on stderr: {stderr}"); + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + error!("Error while executing command: {command}. Stderr: {stderr}"); + } + } + Ok(()) +} diff --git a/src-tauri/core/src/database/mod.rs b/src-tauri/core/src/database/mod.rs new file mode 100644 index 000000000..7b82e3176 --- /dev/null +++ b/src-tauri/core/src/database/mod.rs @@ -0,0 +1,133 @@ +use std::{ + env, + fs::{create_dir_all, File}, + path::PathBuf, + str::FromStr, + sync::LazyLock, + time::Duration, +}; + +use sqlx::sqlite::{SqliteAutoVacuum, SqliteConnectOptions, SqliteJournalMode, SqlitePool}; + +#[cfg(unix)] +use crate::set_perms; +use crate::{app_data_dir, error::Error}; + +const DB_NAME: &str = "defguard.db"; + +pub mod models; + +pub type DbPool = SqlitePool; + +pub static DB_POOL: LazyLock = LazyLock::new(|| { + let db_url = prepare_db_url().expect("Wrong database URL."); + let opts = SqliteConnectOptions::from_str(&db_url) + .expect("Failed to set database connenction options.") + .create_if_missing(true) + .auto_vacuum(SqliteAutoVacuum::Incremental) + .journal_mode(SqliteJournalMode::Wal) + .busy_timeout(Duration::from_secs(5)); + debug!("Connecting to database: {db_url} with options: {opts:?}"); + SqlitePool::connect_lazy_with(opts) +}); + +/// Extracts a filesystem path from a SQLite connection URL, returning `None` for +/// non-file databases (e.g. `:memory:`) or empty paths. +fn sqlite_url_to_path(url: &str) -> Option { + let path = url + .strip_prefix("sqlite://") + .or_else(|| url.strip_prefix("sqlite:")) + .unwrap_or(url); + let path = path.split('?').next().unwrap_or(path); + if path.is_empty() || path == ":memory:" { + return None; + } + Some(PathBuf::from(path)) +} + +/// Returns the filesystem path of the client's SQLite database file. +/// Mirrors the resolution used by [`prepare_db_url`]. +#[must_use] +pub fn db_file_path() -> Option { + if let Ok(url) = env::var("DATABASE_URL") { + sqlite_url_to_path(&url) + } else { + Some(app_data_dir()?.join(DB_NAME)) + } +} + +/// Returns database URL. Checks for custom URL in `DATABASE_URL` environment variable. +/// Handles creating appropriate directories if they don't exist. +fn prepare_db_url() -> Result { + if let Ok(url) = env::var("DATABASE_URL") { + info!( + "The default database location has been just overridden by the DATABASE_URL \ + environment variable. The application will use the database located at: {url}" + ); + Ok(url) + } else { + debug!("A production database will be used as no custom DATABASE_URL was provided."); + // Check if database directory and file exists, create if they don't. + let app_dir = app_data_dir().ok_or(Error::Config( + "Application data directory is not defined. Cannot proceed. Is the application \ + running on a supported platform?" + .to_string(), + ))?; + if app_dir.exists() { + debug!( + "Application data directory already exists at: {}, skipping its creation.", + app_dir.to_string_lossy() + ); + } else { + debug!( + "Creating application data directory at: {}", + app_dir.to_string_lossy() + ); + create_dir_all(&app_dir)?; + debug!( + "Created application data directory at: {}", + app_dir.to_string_lossy() + ); + } + #[cfg(unix)] + set_perms(&app_dir); + let db_path = app_dir.join(DB_NAME); + if db_path.exists() { + debug!( + "Database file already exists at: {}. Skipping its creation.", + db_path.to_string_lossy() + ); + } else { + debug!( + "Database file not found at {}. Creating a new one.", + db_path.to_string_lossy() + ); + File::create(&db_path)?; + info!( + "A new, empty database file has been created at: {} as no previous database file \ + was found. This file will be used to store application data.", + db_path.to_string_lossy() + ); + } + #[cfg(unix)] + set_perms(&db_path); + debug!( + "Application's database file is located at: {}", + db_path.to_string_lossy() + ); + Ok(format!( + "sqlite://{}", + db_path.to_str().expect("Failed to format DB path") + )) + } +} + +pub async fn handle_db_migrations() { + debug!("Running database migrations, if there are any."); + sqlx::migrate!("../migrations") + .run(&*DB_POOL) + .await + .expect("Failed to apply database migrations."); + debug!("Applied all database migrations that were pending. If any."); + debug!("Database setup has been completed successfully."); +} diff --git a/src-tauri/core/src/database/models/connection.rs b/src-tauri/core/src/database/models/connection.rs new file mode 100644 index 000000000..9cb87d079 --- /dev/null +++ b/src-tauri/core/src/database/models/connection.rs @@ -0,0 +1,268 @@ +use chrono::{NaiveDateTime, Utc}; +use serde::Serialize; +use sqlx::{query_as, query_scalar, SqliteExecutor}; + +use super::{Id, NoId}; +use crate::{error::Error, CommonConnection, CommonConnectionInfo, ConnectionType}; + +#[derive(Debug, Serialize, Clone)] +pub struct Connection { + pub id: I, + pub location_id: Id, + pub start: NaiveDateTime, + pub end: NaiveDateTime, +} + +impl Connection { + pub async fn save<'e, E>(self, executor: E) -> Result, Error> + where + E: SqliteExecutor<'e>, + { + let id = query_scalar!( + "INSERT INTO connection (location_id, start, end) \ + VALUES ($1, $2, $3) RETURNING id \"id!\"", + self.location_id, + self.start, + self.end, + ) + .fetch_one(executor) + .await?; + + Ok(Connection:: { + id, + location_id: self.location_id, + start: self.start, + end: self.end, + }) + } + + pub async fn latest_by_location_id<'e, E>( + executor: E, + location_id: Id, + ) -> Result>, Error> + where + E: SqliteExecutor<'e>, + { + let connection = query_as!( + Connection, + "SELECT id, location_id, start, end \ + FROM connection WHERE location_id = $1 \ + ORDER BY end DESC LIMIT 1", + location_id + ) + .fetch_optional(executor) + .await?; + Ok(connection) + } +} + +/// Historical connection +#[derive(Debug, Serialize)] +pub struct ConnectionInfo { + pub id: Id, + pub location_id: Id, + pub start: NaiveDateTime, + pub end: NaiveDateTime, + pub upload: Option, + pub download: Option, +} + +impl From for CommonConnectionInfo { + fn from(val: ConnectionInfo) -> Self { + CommonConnectionInfo { + id: val.id, + location_id: val.location_id, + start: val.start, + end: val.end, + upload: val.upload, + download: val.download, + } + } +} + +impl ConnectionInfo { + pub async fn all_by_location_id<'e, E>(executor: E, location_id: Id) -> Result, Error> + where + E: SqliteExecutor<'e>, + { + // Because we store interface information for given timestamp, + // select last upload and download before connection ended. + // FIXME: Optimize query + let connections = query_as!( + ConnectionInfo, + "SELECT c.id, c.location_id, c.start, c.end, \ + COALESCE((\ + SELECT ls.upload \ + FROM location_stats ls \ + WHERE ls.location_id = c.location_id \ + AND ls.collected_at BETWEEN c.start AND c.end \ + ORDER BY ls.collected_at DESC LIMIT 1 \ + ), 0) \"upload: _\", \ + COALESCE((\ + SELECT ls.download \ + FROM location_stats ls \ + WHERE ls.location_id = c.location_id \ + AND ls.collected_at BETWEEN c.start AND c.end \ + ORDER BY ls.collected_at DESC LIMIT 1 \ + ), 0) \"download: _\" \ + FROM connection c WHERE location_id = $1 \ + ORDER BY start DESC", + location_id + ) + .fetch_all(executor) + .await?; + + Ok(connections) + } +} + +/// Connections stored in memory after creating a network interface. +#[derive(Clone, Debug, Serialize)] +pub struct ActiveConnection { + pub location_id: Id, + pub start: NaiveDateTime, + pub interface_name: String, + pub connection_type: ConnectionType, +} + +impl ActiveConnection { + #[must_use] + pub fn new(location_id: Id, interface_name: String, connection_type: ConnectionType) -> Self { + let start = Utc::now().naive_utc(); + Self { + location_id, + start, + interface_name, + connection_type, + } + } +} + +impl From<&ActiveConnection> for Connection { + fn from(active_connection: &ActiveConnection) -> Self { + Connection { + id: NoId, + location_id: active_connection.location_id, + start: active_connection.start, + end: Utc::now().naive_utc(), + } + } +} + +impl From> for CommonConnection { + fn from(connection: Connection) -> Self { + CommonConnection { + id: connection.id, + location_id: connection.location_id, + start: connection.start, + end: connection.end, + connection_type: ConnectionType::Location, + } + } +} + +#[cfg(test)] +mod tests { + use sqlx::SqlitePool; + + use super::*; + use crate::database::models::{ + instance::{ClientTrafficPolicy, Instance}, + location::{Location, LocationMfaMode, ServiceLocationMode}, + }; + + async fn seed_location(pool: &SqlitePool) -> (Id, Id) { + let instance = Instance { + id: NoId, + name: "instance".into(), + uuid: "uuid-1".into(), + url: "https://core.example".into(), + proxy_url: "https://proxy.example".into(), + username: "alice".into(), + token: None, + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: false, + disable_tunnels: false, + openid_display_name: None, + } + .save(pool) + .await + .unwrap(); + + let location = Location { + id: NoId, + instance_id: instance.id, + network_id: 1, + name: "loc".into(), + address: "10.0.0.2/24".into(), + pubkey: "pk".into(), + endpoint: "1.2.3.4:51820".into(), + allowed_ips: "0.0.0.0/0".into(), + dns: None, + route_all_traffic: false, + keepalive_interval: 25, + location_mfa_mode: LocationMfaMode::Disabled, + service_location_mode: ServiceLocationMode::Disabled, + mfa_method: None, + posture_check_required: false, + } + .save(pool) + .await + .unwrap(); + + (instance.id, location.id) + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_connection_round_trip(pool: SqlitePool) { + let (_instance_id, location_id) = seed_location(&pool).await; + let now = Utc::now().naive_utc(); + + Connection { + id: NoId, + location_id, + start: now, + end: now, + } + .save(&pool) + .await + .unwrap(); + + let latest = Connection::latest_by_location_id(&pool, location_id) + .await + .unwrap() + .expect("connection should exist"); + assert_eq!(latest.location_id, location_id); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_delete_instance_cascades_to_location_and_connection(pool: SqlitePool) { + let (instance_id, location_id) = seed_location(&pool).await; + let now = Utc::now().naive_utc(); + Connection { + id: NoId, + location_id, + start: now, + end: now, + } + .save(&pool) + .await + .unwrap(); + + // Deleting the parent instance must cascade through location to its connections. + let instance = Instance::find_by_id(&pool, instance_id) + .await + .unwrap() + .unwrap(); + instance.delete(&pool).await.unwrap(); + + assert!(Location::find_by_id(&pool, location_id) + .await + .unwrap() + .is_none()); + assert!(Connection::latest_by_location_id(&pool, location_id) + .await + .unwrap() + .is_none()); + } +} diff --git a/src-tauri/core/src/database/models/instance.rs b/src-tauri/core/src/database/models/instance.rs new file mode 100644 index 000000000..38c03d587 --- /dev/null +++ b/src-tauri/core/src/database/models/instance.rs @@ -0,0 +1,544 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sqlx::{prelude::Type, query, query_as, query_scalar, SqliteExecutor}; + +use super::{Id, NoId}; +use crate::proto; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Instance { + pub id: I, + pub name: String, + pub uuid: String, + pub url: String, + pub proxy_url: String, + pub username: String, + pub token: Option, + pub client_traffic_policy: ClientTrafficPolicy, + pub enterprise_enabled: bool, + pub disable_tunnels: bool, + pub openid_display_name: Option, +} + +impl fmt::Display for Instance { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}(ID: {})", self.name, self.id) + } +} + +impl From for Instance { + fn from(instance_info: proto::client_types::InstanceInfo) -> Self { + let client_traffic_policy = ClientTrafficPolicy::from(&instance_info); + Self { + id: NoId, + name: instance_info.name, + uuid: instance_info.id, + url: instance_info.url, + proxy_url: instance_info.proxy_url, + username: instance_info.username, + token: None, + client_traffic_policy, + enterprise_enabled: instance_info.enterprise_enabled, + disable_tunnels: instance_info.disable_tunnels.unwrap_or(false), + openid_display_name: instance_info.openid_display_name, + } + } +} + +impl Instance { + pub async fn save<'e, E>(&mut self, executor: E) -> Result<(), sqlx::Error> + where + E: SqliteExecutor<'e>, + { + query!( + "UPDATE instance SET name = $1, uuid = $2, url = $3, proxy_url = $4, username = $5, \ + client_traffic_policy = $6, enterprise_enabled = $7, disable_tunnels = $8, token = $9, \ + openid_display_name = $10 \ + WHERE id = $11;", + self.name, + self.uuid, + self.url, + self.proxy_url, + self.username, + self.client_traffic_policy, + self.enterprise_enabled, + self.disable_tunnels, + self.token, + self.openid_display_name, + self.id + ) + .execute(executor) + .await?; + Ok(()) + } + + pub async fn all<'e, E>(executor: E) -> Result, sqlx::Error> + where + E: SqliteExecutor<'e>, + { + let instances = query_as!( + Self, + "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", \ + client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name \ + FROM instance ORDER BY name ASC;" + ) + .fetch_all(executor) + .await?; + Ok(instances) + } + + pub async fn find_by_id<'e, E>(executor: E, id: Id) -> Result, sqlx::Error> + where + E: SqliteExecutor<'e>, + { + let instance = query_as!( + Self, + "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", \ + client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name \ + FROM instance WHERE id = $1;", + id + ) + .fetch_optional(executor) + .await?; + Ok(instance) + } + + pub async fn find_by_name<'e, E>(executor: E, name: &str) -> Result, sqlx::Error> + where + E: SqliteExecutor<'e>, + { + let instance = query_as!( + Self, + "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", \ + client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name \ + FROM instance WHERE name = $1;", + name + ) + .fetch_optional(executor) + .await?; + Ok(instance) + } + + pub async fn delete_by_id<'e, E>(executor: E, id: Id) -> Result<(), sqlx::Error> + where + E: SqliteExecutor<'e>, + { + // delete instance + query!("DELETE FROM instance WHERE id = $1", id) + .execute(executor) + .await?; + Ok(()) + } + + pub async fn delete<'e, E>(&self, executor: E) -> Result<(), sqlx::Error> + where + E: SqliteExecutor<'e>, + { + Instance::delete_by_id(executor, self.id).await?; + Ok(()) + } + + pub async fn all_with_token<'e, E>(executor: E) -> Result, sqlx::Error> + where + E: SqliteExecutor<'e>, + { + let instances = query_as!( + Self, + "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token, \ + client_traffic_policy, enterprise_enabled, disable_tunnels, openid_display_name \ + FROM instance \ + WHERE token IS NOT NULL ORDER BY name ASC;" + ) + .fetch_all(executor) + .await?; + Ok(instances) + } + + /// True if ANY enrolled instance has `disable_tunnels = true`. + /// False when there are 0 instances (no policy delivery path, so tunnels are available). + pub async fn tunnels_disabled<'e, E>(executor: E) -> Result + where + E: SqliteExecutor<'e>, + { + let flags = query_scalar!(r#"SELECT disable_tunnels as "disable_tunnels!" FROM instance"#) + .fetch_all(executor) + .await?; + Ok(!flags.is_empty() && flags.iter().any(|v| *v)) + } + + /// Hard-refuse guard for tunnel operations: returns `Error::TunnelsDisabled` + /// when any enrolled instance disables tunnels, `Ok(())` otherwise. + pub async fn ensure_tunnels_enabled<'e, E>(executor: E) -> Result<(), crate::error::Error> + where + E: SqliteExecutor<'e>, + { + if Self::tunnels_disabled(executor).await? { + return Err(crate::error::Error::TunnelsDisabled); + } + Ok(()) + } +} + +// This compares proto::InstanceInfo, not to be confused with regular InstanceInfo defined below +impl PartialEq for Instance { + fn eq(&self, other: &proto::client_types::InstanceInfo) -> bool { + let other_policy = ClientTrafficPolicy::from(other); + self.name == other.name + && self.uuid == other.id + && self.url == other.url + && self.proxy_url == other.proxy_url + && self.username == other.username + && self.client_traffic_policy == other_policy + && self.enterprise_enabled == other.enterprise_enabled + && self.disable_tunnels == other.disable_tunnels.unwrap_or(false) + && self.openid_display_name == other.openid_display_name + } +} + +impl Instance { + pub async fn save<'e, E>(self, executor: E) -> Result, sqlx::Error> + where + E: SqliteExecutor<'e>, + { + let url = self.url.clone(); + let proxy_url = self.proxy_url.clone(); + let result = query!( + "INSERT INTO instance (name, uuid, url, proxy_url, username, token, \ + client_traffic_policy , enterprise_enabled, disable_tunnels) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id;", + self.name, + self.uuid, + url, + proxy_url, + self.username, + self.token, + self.client_traffic_policy, + self.enterprise_enabled, + self.disable_tunnels + ) + .fetch_one(executor) + .await?; + Ok(Instance:: { + id: result.id, + name: self.name, + uuid: self.uuid, + url: self.url, + proxy_url: self.proxy_url, + username: self.username, + token: self.token, + client_traffic_policy: self.client_traffic_policy, + enterprise_enabled: self.enterprise_enabled, + disable_tunnels: self.disable_tunnels, + openid_display_name: self.openid_display_name, + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct InstanceInfo { + pub id: I, + pub name: String, + pub uuid: String, + pub url: String, + pub proxy_url: String, + pub active: bool, + pub pubkey: String, + pub client_traffic_policy: ClientTrafficPolicy, + pub enterprise_enabled: bool, + pub disable_tunnels: bool, + pub openid_display_name: Option, +} + +impl fmt::Display for InstanceInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}(ID: {})", self.name, self.id) + } +} + +/// Describes allowed traffic options for clients connecting to an instance. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Type)] +#[repr(u32)] +#[serde(rename_all = "snake_case")] +pub enum ClientTrafficPolicy { + /// No restrictions + None = 0, + /// Clients are not allowed to route all traffic through the VPN. + DisableAllTraffic = 1, + /// Clients are forced to route all traffic through the VPN. + ForceAllTraffic = 2, +} + +/// Retrieves `ClientTrafficPolicy` from `proto::InstanceInfo` while ensuring backwards compatibility +impl From<&proto::client_types::InstanceInfo> for ClientTrafficPolicy { + fn from(instance: &proto::client_types::InstanceInfo) -> Self { + match ( + instance.client_traffic_policy, + #[allow(deprecated)] + instance.disable_all_traffic, + ) { + (Some(policy), _) => ClientTrafficPolicy::from(policy), + (None, true) => ClientTrafficPolicy::DisableAllTraffic, + (None, false) => ClientTrafficPolicy::None, + } + } +} + +impl From for ClientTrafficPolicy { + fn from(value: i32) -> Self { + match value { + 1 => ClientTrafficPolicy::DisableAllTraffic, + 2 => ClientTrafficPolicy::ForceAllTraffic, + _ => ClientTrafficPolicy::None, + } + } +} + +impl From> for ClientTrafficPolicy { + fn from(value: Option) -> Self { + match value { + None => Self::None, + Some(v) => Self::from(v), + } + } +} + +impl From for ClientTrafficPolicy { + fn from(value: i64) -> Self { + Self::from(value as i32) + } +} + +#[cfg(test)] +mod tests { + use sqlx::SqlitePool; + + use super::*; + + fn new_instance() -> Instance { + Instance { + id: NoId, + name: "instance".into(), + uuid: "uuid-1".into(), + url: "https://core.example".into(), + proxy_url: "https://proxy.example".into(), + username: "alice".into(), + token: Some("token".into()), + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: false, + disable_tunnels: false, + openid_display_name: None, + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_instance_crud_round_trip(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + + let found = Instance::find_by_id(&pool, instance.id) + .await + .unwrap() + .expect("instance should exist"); + assert_eq!(found.uuid, "uuid-1"); + assert_eq!(found.name, "instance"); + + let all = Instance::all(&pool).await.unwrap(); + assert_eq!(all.len(), 1); + + let by_name = Instance::find_by_name(&pool, "instance") + .await + .unwrap() + .expect("instance should be found by name"); + assert_eq!(by_name.id, instance.id); + + instance.delete(&pool).await.unwrap(); + assert!(Instance::find_by_id(&pool, instance.id) + .await + .unwrap() + .is_none()); + } + + #[test] + fn test_client_traffic_policy_from_i32() { + assert_eq!(ClientTrafficPolicy::from(0), ClientTrafficPolicy::None); + assert_eq!( + ClientTrafficPolicy::from(1), + ClientTrafficPolicy::DisableAllTraffic + ); + assert_eq!( + ClientTrafficPolicy::from(2), + ClientTrafficPolicy::ForceAllTraffic + ); + // Unknown discriminants fall back to None. + assert_eq!(ClientTrafficPolicy::from(99), ClientTrafficPolicy::None); + assert_eq!(ClientTrafficPolicy::from(-1), ClientTrafficPolicy::None); + } + + #[test] + fn test_client_traffic_policy_from_i64() { + assert_eq!( + ClientTrafficPolicy::from(2_i64), + ClientTrafficPolicy::ForceAllTraffic + ); + assert_eq!(ClientTrafficPolicy::from(99_i64), ClientTrafficPolicy::None); + } + + #[test] + fn test_client_traffic_policy_from_option() { + assert_eq!(ClientTrafficPolicy::from(None), ClientTrafficPolicy::None); + assert_eq!( + ClientTrafficPolicy::from(Some(2)), + ClientTrafficPolicy::ForceAllTraffic + ); + } + + fn base_info() -> proto::client_types::InstanceInfo { + proto::client_types::InstanceInfo { + id: "uuid-1".into(), + name: "instance".into(), + url: "https://core.example".into(), + proxy_url: "https://proxy.example".into(), + username: "alice".into(), + enterprise_enabled: true, + openid_display_name: Some("OIDC".into()), + ..Default::default() + } + } + + #[test] + fn test_client_traffic_policy_from_instance_info() { + // Explicit policy wins over the deprecated bool, even when the bool is set. + let mut info = base_info(); + info.client_traffic_policy = Some(2); + #[allow(deprecated)] + { + info.disable_all_traffic = true; + } + assert_eq!( + ClientTrafficPolicy::from(&info), + ClientTrafficPolicy::ForceAllTraffic + ); + + // No explicit policy: the deprecated bool decides. + let mut info = base_info(); + #[allow(deprecated)] + { + info.disable_all_traffic = true; + } + assert_eq!( + ClientTrafficPolicy::from(&info), + ClientTrafficPolicy::DisableAllTraffic + ); + + let info = base_info(); + assert_eq!(ClientTrafficPolicy::from(&info), ClientTrafficPolicy::None); + } + + #[test] + fn test_instance_from_instance_info() { + let info = base_info(); + let instance: Instance = info.into(); + + assert_eq!(instance.uuid, "uuid-1"); + assert_eq!(instance.name, "instance"); + assert_eq!(instance.url, "https://core.example"); + assert_eq!(instance.proxy_url, "https://proxy.example"); + assert_eq!(instance.username, "alice"); + assert!(instance.token.is_none()); + assert!(instance.enterprise_enabled); + assert_eq!(instance.openid_display_name, Some("OIDC".to_string())); + assert_eq!(instance.client_traffic_policy, ClientTrafficPolicy::None); + assert!(!instance.disable_tunnels); + } + + fn new_instance_with_tunnels_disabled(disable: bool) -> Instance { + let mut inst = new_instance(); + inst.disable_tunnels = disable; + inst + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_zero_instances(pool: SqlitePool) { + assert!(!Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_one_on(pool: SqlitePool) { + new_instance_with_tunnels_disabled(true) + .save(&pool) + .await + .unwrap(); + assert!(Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_one_off(pool: SqlitePool) { + new_instance_with_tunnels_disabled(false) + .save(&pool) + .await + .unwrap(); + assert!(!Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_mixed(pool: SqlitePool) { + new_instance_with_tunnels_disabled(true) + .save(&pool) + .await + .unwrap(); + new_instance_with_tunnels_disabled(false) + .save(&pool) + .await + .unwrap(); + assert!(Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnels_disabled_all_off(pool: SqlitePool) { + new_instance_with_tunnels_disabled(false) + .save(&pool) + .await + .unwrap(); + new_instance_with_tunnels_disabled(false) + .save(&pool) + .await + .unwrap(); + assert!(!Instance::tunnels_disabled(&pool).await.unwrap()); + } + + #[test] + fn test_instance_from_proto_disable_tunnels_none() { + let info = base_info(); + let instance: Instance = info.into(); + assert!(!instance.disable_tunnels); + } + + #[test] + fn test_instance_from_proto_disable_tunnels_true() { + let mut info = base_info(); + info.disable_tunnels = Some(true); + let instance: Instance = info.into(); + assert!(instance.disable_tunnels); + } + + #[test] + fn test_instance_partial_eq_detect_disable_tunnels_flip() { + let mut info = base_info(); + info.disable_tunnels = Some(true); + let instance = Instance:: { + id: 1, + name: info.name.clone(), + uuid: info.id.clone(), + url: info.url.clone(), + proxy_url: info.proxy_url.clone(), + username: info.username.clone(), + token: Some("tok".into()), + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: info.enterprise_enabled, + disable_tunnels: false, + openid_display_name: info.openid_display_name.clone(), + }; + // Model has false, proto has true → not equal. + assert_ne!(instance, info); + } +} diff --git a/src-tauri/core/src/database/models/location.rs b/src-tauri/core/src/database/models/location.rs new file mode 100644 index 000000000..cf8e9a9f8 --- /dev/null +++ b/src-tauri/core/src/database/models/location.rs @@ -0,0 +1,779 @@ +use std::fmt; +#[cfg(not(target_os = "macos"))] +use std::str::FromStr; + +#[cfg(not(target_os = "macos"))] +use defguard_wireguard_rs::{key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration}; +use serde::{Deserialize, Serialize}; +use sqlx::{prelude::Type, query, query_as, query_scalar, SqliteExecutor}; + +#[cfg(not(target_os = "macos"))] +use super::wireguard_keys::WireguardKeys; +use super::{Id, NoId}; +use crate::{ + contains_default_route, + database::{ + models::instance::{ClientTrafficPolicy, Instance}, + DbPool, + }, + error::Error, + proto::client_types::{ + LocationMfaMode as ProtoLocationMfaMode, ServiceLocationMode as ProtoServiceLocationMode, + }, +}; +#[cfg(not(target_os = "macos"))] +use crate::{DEFAULT_ROUTE_IPV4, DEFAULT_ROUTE_IPV6}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize, Type)] +#[repr(u32)] +#[serde(rename_all = "lowercase")] +pub enum LocationMfaMode { + Disabled = 1, + Internal = 2, + External = 3, +} + +impl From for LocationMfaMode { + fn from(value: ProtoLocationMfaMode) -> Self { + match value { + ProtoLocationMfaMode::Unspecified | ProtoLocationMfaMode::Disabled => { + LocationMfaMode::Disabled + } + ProtoLocationMfaMode::Internal => LocationMfaMode::Internal, + ProtoLocationMfaMode::External => LocationMfaMode::External, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize, Type)] +#[repr(u32)] +#[serde(rename_all = "lowercase")] +pub enum ServiceLocationMode { + Disabled = 1, + PreLogon = 2, + AlwaysOn = 3, +} + +impl From for ServiceLocationMode { + fn from(value: ProtoServiceLocationMode) -> Self { + match value { + ProtoServiceLocationMode::Unspecified | ProtoServiceLocationMode::Disabled => { + ServiceLocationMode::Disabled + } + ProtoServiceLocationMode::Prelogon => ServiceLocationMode::PreLogon, + ProtoServiceLocationMode::Alwayson => ServiceLocationMode::AlwaysOn, + } + } +} + +/// Discriminants match the proto `MfaMethod` enum. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Type)] +#[repr(u32)] +#[serde(rename_all = "lowercase")] +pub enum LocationMfaMethod { + Totp = 0, + Email = 1, + Oidc = 2, + Biometric = 3, + MobileApprove = 4, +} + +impl LocationMfaMethod { + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Totp => "totp", + Self::Email => "email", + Self::Oidc => "oidc", + Self::Biometric => "biometric", + Self::MobileApprove => "mobile", + } + } +} + +#[must_use] +pub fn infer_mfa_method( + mode: LocationMfaMode, + method: Option, +) -> Option { + match mode { + LocationMfaMode::Disabled => method, + LocationMfaMode::Internal => match method { + Some(LocationMfaMethod::Oidc) | None => Some(LocationMfaMethod::Totp), + Some(m) => Some(m), + }, + LocationMfaMode::External => Some(LocationMfaMethod::Oidc), + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, Hash, PartialEq)] +pub struct Location { + pub id: I, + pub instance_id: Id, + // Native ID of network from Defguard + pub network_id: Id, + pub name: String, + pub address: String, + pub pubkey: String, // Remote + pub endpoint: String, + pub allowed_ips: String, + pub dns: Option, + pub route_all_traffic: bool, + pub keepalive_interval: i64, + pub location_mfa_mode: LocationMfaMode, + pub service_location_mode: ServiceLocationMode, + pub mfa_method: Option, + #[serde(default)] + pub posture_check_required: bool, +} + +impl fmt::Display for Location { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}(ID: {})", self.name, self.id) + } +} + +impl fmt::Display for Location { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Location { + /// Ignores service locations + pub async fn all<'e, E>(executor: E, include_service_locations: bool) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let max_service_location_mode = + Self::get_service_location_mode_filter(include_service_locations); + query_as!( + Self, + "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, \ + network_id, route_all_traffic, keepalive_interval, \ + location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + service_location_mode \"service_location_mode: ServiceLocationMode\", \ + mfa_method \"mfa_method: _\", posture_check_required \ + FROM location WHERE service_location_mode <= $1 \ + ORDER BY name ASC", + max_service_location_mode + ) + .fetch_all(executor) + .await + } + + pub async fn exist<'e, E>(executor: E, include_service_locations: bool) -> sqlx::Result + where + E: SqliteExecutor<'e>, + { + let max_service_location_mode = + Self::get_service_location_mode_filter(include_service_locations); + let result = query_scalar!( + "SELECT EXISTS (SELECT 1 FROM location WHERE service_location_mode <= $1)", + max_service_location_mode + ) + .fetch_one(executor) + .await?; + + Ok(result != 0) + } + + /// Find locations by name (excluding service locations). + /// Returns all matches - callers must handle cross-instance ambiguity. + pub async fn find_by_name<'e, E>(executor: E, name: &str) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let max = Self::get_service_location_mode_filter(false); + query_as!( + Self, + "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, \ + network_id, route_all_traffic, keepalive_interval, \ + location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + service_location_mode \"service_location_mode: ServiceLocationMode\", \ + mfa_method \"mfa_method: _\", posture_check_required \ + FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", + name, + max, + ) + .fetch_all(executor) + .await + } + + pub async fn save<'e, E>(&mut self, executor: E) -> sqlx::Result<()> + where + E: SqliteExecutor<'e>, + { + // Update the existing record when there is an ID + query!( + "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, \ + endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, \ + keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, \ + mfa_method = $13, posture_check_required = $14 \ + WHERE id = $15", + self.instance_id, + self.name, + self.address, + self.pubkey, + self.endpoint, + self.allowed_ips, + self.dns, + self.network_id, + self.route_all_traffic, + self.keepalive_interval, + self.location_mfa_mode, + self.service_location_mode, + self.mfa_method, + self.posture_check_required, + self.id, + ) + .execute(executor) + .await?; + + Ok(()) + } + + pub async fn find_by_id<'e, E>(executor: E, location_id: Id) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + query_as!( + Self, + "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, \ + network_id, route_all_traffic, keepalive_interval, \ + location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + service_location_mode \"service_location_mode: ServiceLocationMode\", + mfa_method \"mfa_method: _\", posture_check_required \ + FROM location WHERE id = $1", + location_id + ) + .fetch_optional(executor) + .await + } + + pub async fn find_by_instance_id<'e, E>( + executor: E, + instance_id: Id, + include_service_locations: bool, + ) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let max_service_location_mode = + Self::get_service_location_mode_filter(include_service_locations); + query_as!( + Self, + "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, \ + network_id, route_all_traffic, keepalive_interval, \ + location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + service_location_mode \"service_location_mode: ServiceLocationMode\", + mfa_method \"mfa_method: _\", posture_check_required \ + FROM location WHERE instance_id = $1 AND service_location_mode <= $2 \ + ORDER BY name ASC", + instance_id, + max_service_location_mode + ) + .fetch_all(executor) + .await + } + + pub async fn find_by_public_key<'e, E>(executor: E, pubkey: &str) -> sqlx::Result + where + E: SqliteExecutor<'e>, + { + query_as!( + Self, + "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, \ + network_id, route_all_traffic, keepalive_interval, \ + location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + service_location_mode \"service_location_mode: ServiceLocationMode\", + mfa_method \"mfa_method: _\", posture_check_required \ + FROM location WHERE pubkey = $1", + pubkey + ) + .fetch_one(executor) + .await + } + + pub async fn delete<'e, E>(&self, executor: E) -> sqlx::Result<()> + where + E: SqliteExecutor<'e>, + { + query!("DELETE FROM location WHERE id = $1", self.id) + .execute(executor) + .await?; + Ok(()) + } + + /// Disables all traffic for locations related to the given instance + pub async fn disable_all_traffic_for_all<'e, E>( + executor: E, + instance_id: Id, + ) -> Result<(), Error> + where + E: SqliteExecutor<'e>, + { + query!( + "UPDATE location SET route_all_traffic = 0 WHERE instance_id = $1", + instance_id + ) + .execute(executor) + .await?; + Ok(()) + } + + #[must_use] + pub fn mfa_enabled(&self) -> bool { + match self.location_mfa_mode { + LocationMfaMode::Disabled => false, + LocationMfaMode::Internal | LocationMfaMode::External => true, + } + } + + pub async fn effective_route_all_traffic( + &self, + pool: &DbPool, + route_all_traffic: Option, + ) -> Result { + let Some(instance) = Instance::find_by_id(pool, self.instance_id).await? else { + error!("Instance {} not found", self.instance_id); + return Err(Error::InternalError(format!( + "Instance {} not found", + self.instance_id + ))); + }; + Ok(match instance.client_traffic_policy { + ClientTrafficPolicy::ForceAllTraffic => true, + ClientTrafficPolicy::DisableAllTraffic => false, + ClientTrafficPolicy::None => route_all_traffic.unwrap_or(self.route_all_traffic), + }) + } + + pub async fn holds_default_route( + &self, + pool: &DbPool, + route_all_traffic: Option, + ) -> Result { + Ok(self + .effective_route_all_traffic(pool, route_all_traffic) + .await? + || contains_default_route(&self.allowed_ips)) + } + + #[cfg(not(target_os = "macos"))] + pub async fn interface_configuration( + &self, + pool: &DbPool, + interface_name: String, + preshared_key: Option, + mtu: Option, + route_all_traffic: Option, + ) -> Result { + debug!("Looking for WireGuard keys for location {self} instance"); + let Some(keys) = WireguardKeys::find_by_instance_id(pool, self.instance_id).await? else { + error!("No keys found for instance: {}", self.instance_id); + return Err(Error::InternalError( + "No keys found for instance".to_string(), + )); + }; + debug!("WireGuard keys found for location {self} instance"); + + // prepare peer config + debug!("Decoding location {self} public key: {}.", self.pubkey); + let peer_key = Key::from_str(&self.pubkey)?; + debug!("Location {self} public key decoded: {peer_key}"); + let mut peer = Peer::new(peer_key); + + debug!("Parsing location {self} endpoint: {}", self.endpoint); + peer.set_endpoint(&self.endpoint)?; + peer.persistent_keepalive_interval = Some(25); + debug!("Parsed location {self} endpoint: {}", self.endpoint); + + if let Some(psk) = preshared_key { + debug!("Decoding location {self} preshared key."); + let peer_psk = Key::from_str(&psk)?; + info!("Location {self} preshared key decoded."); + peer.preshared_key = Some(peer_psk); + } + + debug!("Parsing location {self} allowed IPs: {}", self.allowed_ips); + let route_all_traffic = self + .effective_route_all_traffic(pool, route_all_traffic) + .await?; + let allowed_ips = if route_all_traffic { + debug!("Using all traffic routing for location {self}"); + vec![DEFAULT_ROUTE_IPV4.into(), DEFAULT_ROUTE_IPV6.into()] + } else { + debug!( + "Using predefined location {self} traffic: {}", + self.allowed_ips + ); + self.allowed_ips.split(',').map(str::to_string).collect() + }; + for allowed_ip in &allowed_ips { + match IpAddrMask::from_str(allowed_ip) { + Ok(addr) => { + peer.allowed_ips.push(addr); + } + Err(err) => { + // Handle the error from IpAddrMask::from_str, if needed + error!( + "Error parsing IP address {allowed_ip} while setting up interface for \ + location {self}, error details: {err}" + ); + } + } + } + debug!( + "Parsed allowed IPs for location {self}: {:?}", + peer.allowed_ips + ); + + let addresses = self + .address + .split(',') + .map(str::trim) + .map(IpAddrMask::from_str) + .collect::>() + .map_err(|err| { + let msg = format!("Failed to parse IP addresses '{}': {err}", self.address); + error!("{msg}"); + Error::InternalError(msg) + })?; + let interface_config = InterfaceConfiguration { + name: interface_name, + prvkey: keys.prvkey, + addresses, + port: 0, + peers: vec![peer], + mtu, + fwmark: None, // TODO: add + }; + + Ok(interface_config) + } + + /// Persist a per-location MFA method override, clamped via [`infer_mfa_method`] + /// against the location's MFA mode (`location_mfa_mode`). + pub async fn set_mfa_method( + pool: &DbPool, + location_id: Id, + method: LocationMfaMethod, + ) -> Result<(), Error> { + let mut location = Self::find_by_id(pool, location_id) + .await? + .ok_or(Error::NotFound)?; + let inferred = infer_mfa_method(location.location_mfa_mode, Some(method)); + location.mfa_method = inferred; + location.save(pool).await?; + Ok(()) + } + + /// Persist the route-all-traffic flag for a location, rejecting the update if + /// the owning instance's [`ClientTrafficPolicy`] forbids the requested value. + pub async fn update_routing( + pool: &DbPool, + location_id: Id, + route_all_traffic: bool, + ) -> Result<(), Error> { + let mut location = Self::find_by_id(pool, location_id) + .await? + .ok_or(Error::NotFound)?; + + let instance = Instance::find_by_id(pool, location.instance_id) + .await? + .ok_or(Error::NotFound)?; + + if instance.client_traffic_policy == ClientTrafficPolicy::DisableAllTraffic + && route_all_traffic + { + return Err(Error::InvalidInput( + "Instance has route_all_traffic disabled.".into(), + )); + } + if instance.client_traffic_policy == ClientTrafficPolicy::ForceAllTraffic + && !route_all_traffic + { + return Err(Error::InvalidInput( + "Instance has route_all_traffic enforced.".into(), + )); + } + + location.route_all_traffic = route_all_traffic; + location.save(pool).await?; + Ok(()) + } + + /// Returns a filter value that can be used in SQL queries like `service_location_mode <= ?` + /// when querying locations to exclude (<= 1) or include service locations (all service + /// locations modes). + fn get_service_location_mode_filter(include_service_locations: bool) -> i32 { + if include_service_locations { + i32::MAX + } else { + ServiceLocationMode::Disabled as i32 + } + } +} + +impl Location { + pub async fn save<'e, E>(self, executor: E) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + // Insert a new record when there is no ID + let id = query_scalar!( + "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, \ + dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, \ + service_location_mode, mfa_method, posture_check_required) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) \ + RETURNING id \"id!\"", + self.instance_id, + self.name, + self.address, + self.pubkey, + self.endpoint, + self.allowed_ips, + self.dns, + self.network_id, + self.route_all_traffic, + self.keepalive_interval, + self.location_mfa_mode, + self.service_location_mode, + self.mfa_method, + self.posture_check_required, + ) + .fetch_one(executor) + .await?; + + Ok(Location:: { + id, + instance_id: self.instance_id, + name: self.name, + address: self.address, + pubkey: self.pubkey, + endpoint: self.endpoint, + allowed_ips: self.allowed_ips, + dns: self.dns, + network_id: self.network_id, + route_all_traffic: self.route_all_traffic, + keepalive_interval: self.keepalive_interval, + location_mfa_mode: self.location_mfa_mode, + service_location_mode: self.service_location_mode, + mfa_method: self.mfa_method, + posture_check_required: self.posture_check_required, + }) + } +} + +impl Location { + pub fn is_service_location(&self) -> bool { + self.service_location_mode != ServiceLocationMode::Disabled + && self.location_mfa_mode == LocationMfaMode::Disabled + } +} + +impl From> for Location { + fn from(location: Location) -> Self { + Self { + id: NoId, + instance_id: location.instance_id, + network_id: location.network_id, + name: location.name, + address: location.address, + pubkey: location.pubkey, + endpoint: location.endpoint, + allowed_ips: location.allowed_ips, + dns: location.dns, + route_all_traffic: location.route_all_traffic, + keepalive_interval: location.keepalive_interval, + location_mfa_mode: location.location_mfa_mode, + service_location_mode: location.service_location_mode, + mfa_method: location.mfa_method, + posture_check_required: location.posture_check_required, + } + } +} + +#[cfg(test)] +mod tests { + use sqlx::SqlitePool; + + use super::*; + use crate::database::models::instance::{ClientTrafficPolicy, Instance}; + + fn new_instance() -> Instance { + Instance { + id: NoId, + name: "instance".into(), + uuid: "uuid-1".into(), + url: "https://core.example".into(), + proxy_url: "https://proxy.example".into(), + username: "alice".into(), + token: None, + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: false, + disable_tunnels: false, + openid_display_name: None, + } + } + + fn new_location(instance_id: Id) -> Location { + Location { + id: NoId, + instance_id, + network_id: 1, + name: "loc".into(), + address: "10.0.0.2/24".into(), + pubkey: "pk".into(), + endpoint: "1.2.3.4:51820".into(), + allowed_ips: "0.0.0.0/0".into(), + dns: None, + route_all_traffic: false, + keepalive_interval: 25, + location_mfa_mode: LocationMfaMode::Disabled, + service_location_mode: ServiceLocationMode::Disabled, + mfa_method: None, + posture_check_required: false, + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_location_crud_round_trip(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let location = new_location(instance.id).save(&pool).await.unwrap(); + + let found = Location::find_by_id(&pool, location.id) + .await + .unwrap() + .expect("location should exist"); + assert_eq!(found.name, "loc"); + assert_eq!(found.instance_id, instance.id); + + location.delete(&pool).await.unwrap(); + assert!(Location::find_by_id(&pool, location.id) + .await + .unwrap() + .is_none()); + } + + #[test] + fn test_infer_mfa_method() { + use LocationMfaMethod::{Biometric, Email, Oidc, Totp}; + use LocationMfaMode::{Disabled, External, Internal}; + + // Disabled mode passes the configured method through unchanged. + assert_eq!(infer_mfa_method(Disabled, None), None); + assert_eq!(infer_mfa_method(Disabled, Some(Totp)), Some(Totp)); + + // Internal mode forces Totp when no method or OIDC is configured. + assert_eq!(infer_mfa_method(Internal, None), Some(Totp)); + assert_eq!(infer_mfa_method(Internal, Some(Oidc)), Some(Totp)); + // Internal mode keeps any other explicit method. + assert_eq!(infer_mfa_method(Internal, Some(Email)), Some(Email)); + assert_eq!(infer_mfa_method(Internal, Some(Biometric)), Some(Biometric)); + + // External mode always resolves to OIDC, ignoring the configured method. + assert_eq!(infer_mfa_method(External, None), Some(Oidc)); + assert_eq!(infer_mfa_method(External, Some(Totp)), Some(Oidc)); + } + + #[test] + fn test_location_mfa_mode_from_proto() { + assert_eq!( + LocationMfaMode::from(ProtoLocationMfaMode::Unspecified), + LocationMfaMode::Disabled + ); + assert_eq!( + LocationMfaMode::from(ProtoLocationMfaMode::Disabled), + LocationMfaMode::Disabled + ); + assert_eq!( + LocationMfaMode::from(ProtoLocationMfaMode::Internal), + LocationMfaMode::Internal + ); + assert_eq!( + LocationMfaMode::from(ProtoLocationMfaMode::External), + LocationMfaMode::External + ); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_effective_route_all_traffic(pool: SqlitePool) { + use ClientTrafficPolicy::{DisableAllTraffic, ForceAllTraffic, None as NoPolicy}; + + // (policy, stored flag, per-call override, expected) + let cases = [ + (NoPolicy, false, None, false), + (NoPolicy, false, Some(true), true), + (NoPolicy, true, None, true), + (NoPolicy, true, Some(false), false), + (ForceAllTraffic, false, None, true), + (ForceAllTraffic, false, Some(false), true), + (DisableAllTraffic, true, None, false), + (DisableAllTraffic, true, Some(true), false), + ]; + + let mut instance = new_instance().save(&pool).await.unwrap(); + let mut location = new_location(instance.id).save(&pool).await.unwrap(); + + for (policy, route_all_traffic, override_value, expected) in cases { + instance.client_traffic_policy = policy.clone(); + instance.save(&pool).await.unwrap(); + location.route_all_traffic = route_all_traffic; + + let effective = location + .effective_route_all_traffic(&pool, override_value) + .await + .unwrap(); + assert_eq!( + effective, expected, + "policy {policy:?}, flag {route_all_traffic}, override {override_value:?}" + ); + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_holds_default_route_detects_default_route_in_allowed_ips(pool: SqlitePool) { + let instance = new_instance().save(&pool).await.unwrap(); + let mut location = new_location(instance.id); + location.allowed_ips = "10.0.0.0/8, 0.0.0.0/0".into(); + let location = location.save(&pool).await.unwrap(); + + assert!(!location + .effective_route_all_traffic(&pool, None) + .await + .unwrap()); + assert!(location.holds_default_route(&pool, None).await.unwrap()); + + let mut location = new_location(instance.id); + location.allowed_ips = "10.0.0.0/8, 192.168.1.0/24".into(); + let location = location.save(&pool).await.unwrap(); + + assert!(!location.holds_default_route(&pool, None).await.unwrap()); + assert!(location + .holds_default_route(&pool, Some(true)) + .await + .unwrap()); + } + + #[test] + fn test_service_location_mode_from_proto() { + assert_eq!( + ServiceLocationMode::from(ProtoServiceLocationMode::Unspecified), + ServiceLocationMode::Disabled + ); + assert_eq!( + ServiceLocationMode::from(ProtoServiceLocationMode::Disabled), + ServiceLocationMode::Disabled + ); + assert_eq!( + ServiceLocationMode::from(ProtoServiceLocationMode::Prelogon), + ServiceLocationMode::PreLogon + ); + assert_eq!( + ServiceLocationMode::from(ProtoServiceLocationMode::Alwayson), + ServiceLocationMode::AlwaysOn + ); + } +} diff --git a/src-tauri/core/src/database/models/location_stats.rs b/src-tauri/core/src/database/models/location_stats.rs new file mode 100644 index 000000000..305157541 --- /dev/null +++ b/src-tauri/core/src/database/models/location_stats.rs @@ -0,0 +1,302 @@ +use std::time::SystemTime; + +use chrono::{NaiveDateTime, Utc}; +use defguard_wireguard_rs::peer::Peer; +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_as, query_scalar, SqliteExecutor}; + +use super::{location::Location, Id, NoId, PURGE_DURATION}; +use crate::{CommonLocationStats, ConnectionType, DateTimeAggregation}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct LocationStats { + id: I, + pub location_id: Id, + upload: i64, + download: i64, + upload_diff: i64, + download_diff: i64, + pub last_handshake: i64, + pub collected_at: NaiveDateTime, + listen_port: u32, + pub persistent_keepalive_interval: Option, +} + +impl From> for CommonLocationStats { + fn from(location_stats: LocationStats) -> Self { + CommonLocationStats { + id: location_stats.id, + location_id: location_stats.location_id, + upload: location_stats.upload, + download: location_stats.download, + last_handshake: location_stats.last_handshake, + collected_at: location_stats.collected_at, + listen_port: location_stats.listen_port, + persistent_keepalive_interval: location_stats.persistent_keepalive_interval, + connection_type: ConnectionType::Location, + } + } +} + +pub async fn peer_to_location_stats<'e, E>( + peer: &Peer, + listen_port: u32, + executor: E, +) -> sqlx::Result> +where + E: SqliteExecutor<'e>, +{ + let location = Location::find_by_public_key(executor, &peer.public_key.to_string()).await?; + Ok(LocationStats::new( + location.id, + peer.tx_bytes.cast_signed(), + peer.rx_bytes.cast_signed(), + peer.last_handshake.map_or(0, |ts| { + ts.duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs().cast_signed()) + }), + listen_port, + peer.persistent_keepalive_interval, + )) +} + +impl LocationStats { + // Although not used on macOS, allow dead code for `sqlx prepare`. + #[cfg_attr(target_os = "macos", allow(dead_code))] + pub async fn get_name<'e, E>(&self, executor: E) -> sqlx::Result + where + E: SqliteExecutor<'e>, + { + query_scalar!("SELECT name FROM location WHERE id = $1", self.location_id) + .fetch_one(executor) + .await + } +} + +impl LocationStats { + #[must_use] + pub fn new( + location_id: Id, + upload: i64, + download: i64, + last_handshake: i64, + listen_port: u32, + persistent_keepalive_interval: Option, + ) -> Self { + LocationStats { + id: NoId, + location_id, + upload, + download, + upload_diff: 0, + download_diff: 0, + last_handshake, + collected_at: Utc::now().naive_utc(), + listen_port, + persistent_keepalive_interval, + } + } + + #[must_use] + pub fn with_diffs(mut self, upload_diff: i64, download_diff: i64) -> Self { + self.upload_diff = upload_diff; + self.download_diff = download_diff; + self + } + + pub async fn save<'e, E>(self, executor: E) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let id = query_scalar!( + "INSERT INTO location_stats (location_id, upload, download, upload_diff, download_diff, \ + last_handshake, collected_at, listen_port, persistent_keepalive_interval) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \ + RETURNING id \"id!\"", + self.location_id, + self.upload, + self.download, + self.upload_diff, + self.download_diff, + self.last_handshake, + self.collected_at, + self.listen_port, + self.persistent_keepalive_interval, + ) + .fetch_one(executor) + .await?; + + Ok(LocationStats:: { + id, + location_id: self.location_id, + upload: self.upload, + download: self.download, + upload_diff: self.upload_diff, + download_diff: self.download_diff, + last_handshake: self.last_handshake, + collected_at: self.collected_at, + listen_port: self.listen_port, + persistent_keepalive_interval: self.persistent_keepalive_interval, + }) + } +} + +impl LocationStats { + pub async fn all_by_location_id<'e, E>( + executor: E, + location_id: Id, + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, + limit: Option, + ) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let aggregation = aggregation.fstring(); + // SQLite: If the LIMIT expression evaluates to a negative value, + // then there is no upper bound on the number of rows returned + let query_limit = limit.unwrap_or(-1); + let stats = query_as!( + LocationStats, + "SELECT id \"id!\", location_id, + SUM(MAX(upload_diff, 0)) \"upload!: i64\", \ + SUM(MAX(download_diff, 0)) \"download!: i64\", \ + 0 \"upload_diff!: i64\", \ + 0 \"download_diff!: i64\", \ + last_handshake \"last_handshake!: i64\", \ + strftime($1, collected_at) \"collected_at!: NaiveDateTime\", \ + listen_port \"listen_port!: u32\", \ + persistent_keepalive_interval \"persistent_keepalive_interval?: u16\" \ + FROM location_stats \ + WHERE location_id = $2 AND collected_at >= datetime(strftime($1, $3)) \ + GROUP BY strftime($1, collected_at) ORDER BY collected_at LIMIT $4", + aggregation, + location_id, + from, + query_limit + ) + .fetch_all(executor) + .await?; + Ok(stats) + } + + pub async fn latest_by_download_change<'e, E>( + executor: E, + location_id: Id, + ) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let res = query_as!( + LocationStats::, + "WITH prev_download AS ( + SELECT download + FROM location_stats + WHERE location_id = $1 + ORDER BY collected_at DESC + LIMIT 1 OFFSET 1 + ) + SELECT ls.id \"id!: i64\", + ls.location_id, + ls.upload \"upload!: i64\", + ls.download \"download!: i64\", + ls.upload_diff, + ls.download_diff, + ls.last_handshake, + ls.collected_at \"collected_at!: NaiveDateTime\", + ls.listen_port \"listen_port!: u32\", + ls.persistent_keepalive_interval \"persistent_keepalive_interval?: u16\" + FROM location_stats ls + LEFT JOIN prev_download pd + WHERE ls.location_id = $1 + AND (pd.download IS NULL OR ls.download != pd.download) + ORDER BY ls.collected_at DESC + LIMIT 1", + location_id + ) + .fetch_optional(executor) + .await?; + Ok(res) + } + + /// Purge old statistics. + pub async fn purge<'e, E>(executor: E) -> sqlx::Result<()> + where + E: SqliteExecutor<'e>, + { + debug!("Purging location statistics."); + + let past = (Utc::now() - PURGE_DURATION).naive_utc(); + query!("DELETE FROM location_stats WHERE collected_at < $1", past) + .execute(executor) + .await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use sqlx::SqlitePool; + + use super::*; + use crate::database::models::{ + instance::{ClientTrafficPolicy, Instance}, + location::{LocationMfaMode, ServiceLocationMode}, + }; + + async fn seed_location(pool: &SqlitePool) -> Id { + let instance = Instance { + id: NoId, + name: "instance".into(), + uuid: "uuid-1".into(), + url: "https://core.example".into(), + proxy_url: "https://proxy.example".into(), + username: "alice".into(), + token: None, + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: false, + disable_tunnels: false, + openid_display_name: None, + } + .save(pool) + .await + .unwrap(); + + Location { + id: NoId, + instance_id: instance.id, + network_id: 1, + name: "loc".into(), + address: "10.0.0.2/24".into(), + pubkey: "pk".into(), + endpoint: "1.2.3.4:51820".into(), + allowed_ips: "0.0.0.0/0".into(), + dns: None, + route_all_traffic: false, + keepalive_interval: 25, + location_mfa_mode: LocationMfaMode::Disabled, + service_location_mode: ServiceLocationMode::Disabled, + mfa_method: None, + posture_check_required: false, + } + .save(pool) + .await + .unwrap() + .id + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_location_stats_save_round_trip(pool: SqlitePool) { + let location_id = seed_location(&pool).await; + + let stats = LocationStats::new(location_id, 100, 200, 1_700_000_000, 51820, Some(25)) + .save(&pool) + .await + .unwrap(); + + // A real row id is returned on insert. + assert!(stats.id > 0); + assert_eq!(stats.location_id, location_id); + } +} diff --git a/src-tauri/core/src/database/models/mod.rs b/src-tauri/core/src/database/models/mod.rs new file mode 100644 index 000000000..429c8434d --- /dev/null +++ b/src-tauri/core/src/database/models/mod.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +pub mod connection; +pub mod instance; +pub mod location; +pub mod location_stats; +pub mod tunnel; +#[cfg(target_os = "macos")] +pub mod tunnel_configuration; +pub mod wireguard_keys; + +// Typestate structs to make working with optional IDs easier +pub type Id = i64; +#[derive(Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct NoId; + +const PURGE_DURATION: chrono::Duration = chrono::Duration::hours(28); + +#[cfg(target_os = "macos")] +use self::{location::Location, tunnel::Tunnel}; + +#[must_use] +/// Utility function to get all tunnels and locations from the database. +#[cfg(target_os = "macos")] +pub async fn get_all_tunnels_locations() -> (Vec>, Vec>) { + let tunnels = Tunnel::all(&*super::DB_POOL).await.unwrap_or_default(); + let locations = Location::all(&*super::DB_POOL, false) + .await + .unwrap_or_default(); + (tunnels, locations) +} diff --git a/src-tauri/src/database/models/settings.rs b/src-tauri/core/src/database/models/settings.rs similarity index 100% rename from src-tauri/src/database/models/settings.rs rename to src-tauri/core/src/database/models/settings.rs diff --git a/src-tauri/core/src/database/models/tunnel.rs b/src-tauri/core/src/database/models/tunnel.rs new file mode 100644 index 000000000..62e0b9fed --- /dev/null +++ b/src-tauri/core/src/database/models/tunnel.rs @@ -0,0 +1,776 @@ +use std::{fmt, time::SystemTime}; + +use chrono::{NaiveDateTime, Utc}; +use defguard_wireguard_rs::peer::Peer; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, NoneAsEmptyString}; +use sqlx::{query, query_as, query_scalar, SqliteExecutor}; + +use super::{connection::ActiveConnection, Id, NoId, PURGE_DURATION}; +use crate::{ + contains_default_route, CommonConnection, CommonConnectionInfo, CommonLocationStats, + ConnectionType, DateTimeAggregation, +}; + +#[serde_as] +#[derive(Clone, Deserialize, Serialize)] +pub struct Tunnel { + #[serde(default)] + pub id: I, + pub name: String, + // encryption keys + pub pubkey: String, // Remote + pub prvkey: String, // Local + // server config + pub address: String, + pub server_pubkey: String, + #[serde_as(as = "NoneAsEmptyString")] + pub preshared_key: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub allowed_ips: Option, + // server_address:port + pub endpoint: String, + #[serde_as(as = "NoneAsEmptyString")] + pub dns: Option, + pub persistent_keep_alive: i64, + pub route_all_traffic: bool, + // additional commands + #[serde_as(as = "NoneAsEmptyString")] + pub pre_up: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub post_up: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub pre_down: Option, + #[serde_as(as = "NoneAsEmptyString")] + pub post_down: Option, +} + +impl fmt::Display for Tunnel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}(ID: {})", self.name, self.id) + } +} + +impl fmt::Display for Tunnel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Tunnel { + #[must_use] + pub fn effective_route_all_traffic(&self, route_all_traffic: Option) -> bool { + route_all_traffic.unwrap_or(self.route_all_traffic) + } + + #[must_use] + pub fn holds_default_route(&self, route_all_traffic: Option) -> bool { + self.effective_route_all_traffic(route_all_traffic) + || self + .allowed_ips + .as_deref() + .is_some_and(contains_default_route) + } + + pub async fn save<'e, E>(&mut self, executor: E) -> sqlx::Result<()> + where + E: SqliteExecutor<'e>, + { + query!( + "UPDATE tunnel SET name = $1, pubkey = $2, prvkey = $3, address = $4, \ + server_pubkey = $5, preshared_key = $6, allowed_ips = $7, endpoint = $8, dns = $9, \ + persistent_keep_alive = $10, route_all_traffic = $11, pre_up = $12, post_up = $13, \ + pre_down = $14, post_down = $15 \ + WHERE id = $16;", + self.name, + self.pubkey, + self.prvkey, + self.address, + self.server_pubkey, + self.preshared_key, + self.allowed_ips, + self.endpoint, + self.dns, + self.persistent_keep_alive, + self.route_all_traffic, + self.pre_up, + self.post_up, + self.pre_down, + self.post_down, + self.id, + ) + .execute(executor) + .await?; + + Ok(()) + } + + pub async fn delete<'e, E>(&self, executor: E) -> sqlx::Result<()> + where + E: SqliteExecutor<'e>, + { + Tunnel::delete_by_id(executor, self.id).await?; + Ok(()) + } + + pub async fn find_by_id<'e, E>(executor: E, tunnel_id: Id) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + query_as!( + Self, + "SELECT id \"id: _\", name, pubkey, prvkey, address, server_pubkey, preshared_key, \ + allowed_ips, endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, \ + post_up, pre_down, post_down FROM tunnel WHERE id = $1;", + tunnel_id + ) + .fetch_optional(executor) + .await + } + + pub async fn all<'e, E>(executor: E) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let tunnels = query_as!( + Self, + "SELECT id \"id: _\", name, pubkey, prvkey, address, server_pubkey, preshared_key, \ + allowed_ips, endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, \ + post_up, pre_down, post_down \ + FROM tunnel ORDER BY name ASC;" + ) + .fetch_all(executor) + .await?; + Ok(tunnels) + } + + /// Returns `true` if there is at least one tunnel in the database. + pub async fn exists<'e, E>(executor: E) -> sqlx::Result + where + E: SqliteExecutor<'e>, + { + let result = query_scalar!("SELECT EXISTS (SELECT 1 FROM tunnel);") + .fetch_one(executor) + .await?; + Ok(result != 0) + } + + /// Find tunnels by name. + pub async fn find_by_name<'e, E>(executor: E, name: &str) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + query_as!( + Self, + "SELECT id \"id: _\", name, pubkey, prvkey, address, server_pubkey, preshared_key, \ + allowed_ips, endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, \ + post_up, pre_down, post_down \ + FROM tunnel WHERE name = $1 ORDER BY name ASC", + name, + ) + .fetch_all(executor) + .await + } + + pub async fn find_by_server_public_key<'e, E>(executor: E, pubkey: &str) -> sqlx::Result + where + E: SqliteExecutor<'e>, + { + query_as!( + Self, + "SELECT id \"id: _\", name, pubkey, prvkey, address, server_pubkey, preshared_key, \ + allowed_ips, endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, \ + post_up, pre_down, post_down \ + FROM tunnel WHERE server_pubkey = $1;", + pubkey + ) + .fetch_one(executor) + .await + } + + pub async fn delete_by_id<'e, E>(executor: E, id: Id) -> sqlx::Result<()> + where + E: SqliteExecutor<'e>, + { + // delete instance + query!("DELETE FROM tunnel WHERE id = $1", id) + .execute(executor) + .await?; + Ok(()) + } +} + +impl Tunnel { + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + name: String, + pubkey: String, + prvkey: String, + address: String, + server_pubkey: String, + preshared_key: Option, + allowed_ips: Option, + endpoint: String, + dns: Option, + persistent_keep_alive: i64, + route_all_traffic: bool, + pre_up: Option, + post_up: Option, + pre_down: Option, + post_down: Option, + ) -> Self { + Tunnel { + id: NoId, + name, + pubkey, + prvkey, + address, + server_pubkey, + preshared_key, + allowed_ips, + endpoint, + dns, + persistent_keep_alive, + route_all_traffic, + pre_up, + post_up, + pre_down, + post_down, + } + } + + pub async fn save<'e, E>(self, executor: E) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + // Insert a new record when there is no ID + let result = query!( + "INSERT INTO tunnel (name, pubkey, prvkey, address, server_pubkey, allowed_ips, preshared_key, \ + endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, post_up, pre_down, post_down) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id;", + self.name, + self.pubkey, + self.prvkey, + self.address, + self.server_pubkey, + self.allowed_ips, + self.preshared_key, + self.endpoint, + self.dns, + self.persistent_keep_alive, + self.route_all_traffic, + self.pre_up, + self.post_up, + self.pre_down, + self.post_down, + ) + .fetch_one(executor) + .await?; + + Ok(Tunnel:: { + id: result.id, + name: self.name, + pubkey: self.pubkey, + prvkey: self.prvkey, + address: self.address, + server_pubkey: self.server_pubkey, + allowed_ips: self.allowed_ips, + preshared_key: self.preshared_key, + endpoint: self.endpoint, + dns: self.dns, + persistent_keep_alive: self.persistent_keep_alive, + route_all_traffic: self.route_all_traffic, + pre_up: self.pre_up, + post_up: self.post_up, + pre_down: self.pre_down, + post_down: self.post_down, + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TunnelStats { + id: I, + pub tunnel_id: Id, + upload: i64, + download: i64, + upload_diff: i64, + download_diff: i64, + pub last_handshake: i64, + pub collected_at: NaiveDateTime, + listen_port: u32, + pub persistent_keepalive_interval: u16, +} + +impl TunnelStats { + pub async fn get_name<'e, E>(&self, executor: E) -> sqlx::Result + where + E: SqliteExecutor<'e>, + { + query_scalar!("SELECT name FROM tunnel WHERE id = $1;", self.tunnel_id) + .fetch_one(executor) + .await + } +} + +impl TunnelStats { + #[must_use] + pub fn new( + tunnel_id: Id, + upload: i64, + download: i64, + last_handshake: i64, + collected_at: NaiveDateTime, + listen_port: u32, + persistent_keepalive_interval: u16, + ) -> Self { + TunnelStats { + id: NoId, + tunnel_id, + upload, + download, + upload_diff: 0, + download_diff: 0, + last_handshake, + collected_at, + listen_port, + persistent_keepalive_interval, + } + } + + #[must_use] + pub fn with_diffs(mut self, upload_diff: i64, download_diff: i64) -> Self { + self.upload_diff = upload_diff; + self.download_diff = download_diff; + self + } + + pub async fn save<'e, E>(self, executor: E) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let id = query_scalar!( + "INSERT INTO tunnel_stats (tunnel_id, upload, download, upload_diff, download_diff, \ + last_handshake, collected_at, listen_port, persistent_keepalive_interval) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id \"id!\"", + self.tunnel_id, + self.upload, + self.download, + self.upload_diff, + self.download_diff, + self.last_handshake, + self.collected_at, + self.listen_port, + self.persistent_keepalive_interval, + ) + .fetch_one(executor) + .await?; + + Ok(TunnelStats:: { + id, + tunnel_id: self.tunnel_id, + upload: self.upload, + download: self.download, + upload_diff: self.upload_diff, + download_diff: self.download_diff, + last_handshake: self.last_handshake, + collected_at: self.collected_at, + listen_port: self.listen_port, + persistent_keepalive_interval: self.persistent_keepalive_interval, + }) + } +} + +impl TunnelStats { + pub async fn all_by_tunnel_id<'e, E>( + executor: E, + tunnel_id: Id, + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, + ) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let aggregation = aggregation.fstring(); + let stats = query_as!( + TunnelStats, + "SELECT id \"id!\", tunnel_id, \ + SUM(MAX(upload_diff, 0)) \"upload!: i64\", \ + SUM(MAX(download_diff, 0)) \"download!: i64\", \ + 0 \"upload_diff!: i64\", \ + 0 \"download_diff!: i64\", \ + last_handshake \"last_handshake!: i64\", \ + strftime($1, collected_at) \"collected_at!: NaiveDateTime\", \ + listen_port \"listen_port!: u32\", \ + persistent_keepalive_interval \"persistent_keepalive_interval!: u16\" \ + FROM tunnel_stats \ + WHERE tunnel_id = $2 AND collected_at >= datetime(strftime($1, $3)) \ + GROUP BY strftime($1, collected_at) ORDER BY collected_at", + aggregation, + tunnel_id, + from + ) + .fetch_all(executor) + .await?; + Ok(stats) + } + + pub async fn latest_by_download_change<'e, E>( + executor: E, + tunnel_id: Id, + ) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let res = query_as!( + TunnelStats::, + "WITH prev_download AS ( + SELECT download + FROM tunnel_stats + WHERE tunnel_id = $1 + ORDER BY collected_at DESC + LIMIT 1 OFFSET 1 + ) + SELECT ts.id \"id!: i64\", + ts.tunnel_id, + ts.upload \"upload!: i64\", + ts.download \"download!: i64\", + ts.upload_diff, + ts.download_diff, + ts.last_handshake, + ts.collected_at \"collected_at!: NaiveDateTime\", + ts.listen_port \"listen_port!: u32\", + ts.persistent_keepalive_interval \"persistent_keepalive_interval!: u16\" + FROM tunnel_stats ts + LEFT JOIN prev_download pd + WHERE ts.tunnel_id = $1 + AND (pd.download IS NULL OR ts.download != pd.download) + ORDER BY ts.collected_at DESC + LIMIT 1", + tunnel_id + ) + .fetch_optional(executor) + .await?; + Ok(res) + } + + /// Purge old statistics. + pub async fn purge<'e, E>(executor: E) -> sqlx::Result<()> + where + E: SqliteExecutor<'e>, + { + debug!("Purging tunnel statistics."); + + let past = (Utc::now() - PURGE_DURATION).naive_utc(); + query!("DELETE FROM tunnel_stats WHERE collected_at < $1", past) + .execute(executor) + .await?; + + Ok(()) + } +} + +pub async fn peer_to_tunnel_stats<'e, E>( + peer: &Peer, + listen_port: u32, + executor: E, +) -> sqlx::Result> +where + E: SqliteExecutor<'e>, +{ + let tunnel = Tunnel::find_by_server_public_key(executor, &peer.public_key.to_string()).await?; + Ok(TunnelStats { + id: NoId, + tunnel_id: tunnel.id, + upload: peer.tx_bytes.cast_signed(), + download: peer.rx_bytes.cast_signed(), + upload_diff: 0, + download_diff: 0, + last_handshake: peer.last_handshake.map_or(0, |ts| { + ts.duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs().cast_signed()) + }), + collected_at: Utc::now().naive_utc(), + listen_port, + persistent_keepalive_interval: peer.persistent_keepalive_interval.unwrap_or_default(), + }) +} + +#[derive(Debug, Serialize, Clone)] +pub struct TunnelConnection { + pub id: I, + pub tunnel_id: Id, + pub start: NaiveDateTime, + pub end: NaiveDateTime, +} + +impl From for CommonConnectionInfo { + fn from(val: TunnelConnectionInfo) -> Self { + CommonConnectionInfo { + id: val.id, + location_id: val.tunnel_id, + start: val.start, + end: val.end, + upload: val.upload, + download: val.download, + } + } +} + +impl TunnelConnection { + pub async fn all_by_tunnel_id<'e, E>( + executor: E, + tunnel_id: Id, + ) -> sqlx::Result>> + where + E: SqliteExecutor<'e>, + { + let connections = query_as!( + TunnelConnection, + "SELECT id, tunnel_id, start, end \ + FROM tunnel_connection WHERE tunnel_id = $1", + tunnel_id + ) + .fetch_all(executor) + .await?; + Ok(connections) + } + + pub async fn latest_by_tunnel_id<'e, E>( + executor: E, + tunnel_id: Id, + ) -> sqlx::Result>> + where + E: SqliteExecutor<'e>, + { + let connection = query_as!( + TunnelConnection, + "SELECT id, tunnel_id, start, end \ + FROM tunnel_connection WHERE tunnel_id = $1 \ + ORDER BY end DESC LIMIT 1", + tunnel_id + ) + .fetch_optional(executor) + .await?; + Ok(connection) + } +} + +impl TunnelConnection { + pub async fn save<'e, E>(self, executor: E) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + let id = query_scalar!( + "INSERT INTO tunnel_connection (tunnel_id, start, end) \ + VALUES ($1, $2, $3) RETURNING id \"id!\"", + self.tunnel_id, + self.start, + self.end, + ) + .fetch_one(executor) + .await?; + + Ok(TunnelConnection:: { + id, + tunnel_id: self.tunnel_id, + start: self.start, + end: self.end, + }) + } +} + +/// Historical connection +#[derive(Debug, Serialize)] +pub struct TunnelConnectionInfo { + pub id: Id, + pub tunnel_id: Id, + pub start: NaiveDateTime, + pub end: NaiveDateTime, + pub upload: Option, + pub download: Option, +} + +impl TunnelConnectionInfo { + pub async fn all_by_tunnel_id<'e, E>(executor: E, tunnel_id: Id) -> sqlx::Result> + where + E: SqliteExecutor<'e>, + { + // Because we store interface information for given timestamp, + // select last upload and download before connection ended. + // FIXME: Optimize query + let connections = query_as!( + TunnelConnectionInfo, + "SELECT c.id, c.tunnel_id, c.start, c.end, \ + COALESCE((\ + SELECT ls.upload \ + FROM tunnel_stats ls \ + WHERE ls.tunnel_id = c.tunnel_id \ + AND ls.collected_at BETWEEN c.start AND c.end \ + ORDER BY ls.collected_at DESC LIMIT 1 \ + ), 0) \"upload: _\", \ + COALESCE((\ + SELECT ls.download \ + FROM tunnel_stats ls \ + WHERE ls.tunnel_id = c.tunnel_id \ + AND ls.collected_at BETWEEN c.start AND c.end \ + ORDER BY ls.collected_at DESC LIMIT 1 \ + ), 0) \"download: _\" \ + FROM tunnel_connection c WHERE tunnel_id = $1 \ + ORDER BY start DESC", + tunnel_id + ) + .fetch_all(executor) + .await?; + + Ok(connections) + } +} + +impl From<&ActiveConnection> for TunnelConnection { + fn from(active_connection: &ActiveConnection) -> Self { + Self { + id: NoId, + tunnel_id: active_connection.location_id, + start: active_connection.start, + end: Utc::now().naive_utc(), + } + } +} + +impl From> for CommonConnection { + fn from(tunnel_connection: TunnelConnection) -> Self { + Self { + id: tunnel_connection.id, + location_id: tunnel_connection.tunnel_id, // Assuming you want to map tunnel_id to location_id + start: tunnel_connection.start, + end: tunnel_connection.end, + connection_type: ConnectionType::Tunnel, // You need to set the connection_type appropriately based on your logic, + } + } +} + +impl From> for CommonLocationStats { + fn from(tunnel_stats: TunnelStats) -> Self { + Self { + id: tunnel_stats.id, + location_id: tunnel_stats.tunnel_id, + upload: tunnel_stats.upload, + download: tunnel_stats.download, + last_handshake: tunnel_stats.last_handshake, + collected_at: tunnel_stats.collected_at, + listen_port: tunnel_stats.listen_port, + persistent_keepalive_interval: Some(tunnel_stats.persistent_keepalive_interval), // Set the appropriate value + connection_type: ConnectionType::Tunnel, + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use sqlx::SqlitePool; + + use super::*; + + impl TunnelStats { + async fn count<'e, E>(executor: E) -> sqlx::Result + where + E: SqliteExecutor<'e>, + { + let count = query_scalar!("SELECT count(*) FROM tunnel_stats") + .fetch_one(executor) + .await?; + Ok(count) + } + } + + #[sqlx::test(migrations = "../migrations")] + async fn purge_stats(pool: SqlitePool) { + let tunnel = Tunnel::new( + "test".into(), + String::new(), + String::new(), + String::new(), + String::new(), + None, + None, + String::new(), + None, + 0, + false, + None, + None, + None, + None, + ) + .save(&pool) + .await + .unwrap(); + + let delta = Duration::days(60); + assert!(delta > PURGE_DURATION); + + let now = Utc::now(); + TunnelStats::new(tunnel.id, 0, 0, 0, now.naive_utc(), 0, 0) + .save(&pool) + .await + .unwrap(); + TunnelStats::new(tunnel.id, 0, 0, 0, (now - delta).naive_utc(), 0, 0) + .save(&pool) + .await + .unwrap(); + TunnelStats::new(tunnel.id, 0, 0, 0, (now + delta).naive_utc(), 0, 0) + .save(&pool) + .await + .unwrap(); + + let count = TunnelStats::::count(&pool).await.unwrap(); + assert_eq!(count, 3); + + TunnelStats::purge(&pool).await.unwrap(); + + let count = TunnelStats::::count(&pool).await.unwrap(); + assert_eq!(count, 2); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_tunnel_crud_round_trip(pool: SqlitePool) { + let tunnel = Tunnel::new( + "test".into(), + String::new(), + String::new(), + String::new(), + String::new(), + None, + None, + String::new(), + None, + 0, + false, + None, + None, + None, + None, + ) + .save(&pool) + .await + .unwrap(); + + let found = Tunnel::find_by_id(&pool, tunnel.id) + .await + .unwrap() + .expect("tunnel should exist"); + assert_eq!(found.name, "test"); + + let all = Tunnel::all(&pool).await.unwrap(); + assert_eq!(all.len(), 1); + + tunnel.delete(&pool).await.unwrap(); + assert!(Tunnel::find_by_id(&pool, tunnel.id) + .await + .unwrap() + .is_none()); + } +} diff --git a/src-tauri/core/src/database/models/tunnel_configuration.rs b/src-tauri/core/src/database/models/tunnel_configuration.rs new file mode 100644 index 000000000..8f03590f3 --- /dev/null +++ b/src-tauri/core/src/database/models/tunnel_configuration.rs @@ -0,0 +1,552 @@ +use std::{ + hint::spin_loop, + net::IpAddr, + str::FromStr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +use block2::RcBlock; +use defguard_client_common::dns_owned; +use defguard_wireguard_rs::{key::Key, net::IpAddrMask, peer::Peer}; +use objc2::{rc::Retained, runtime::AnyObject}; +use objc2_foundation::{ + ns_string, NSDictionary, NSError, NSMutableArray, NSMutableDictionary, NSNumber, NSString, +}; +use objc2_network_extension::{NETunnelProviderManager, NETunnelProviderProtocol, NEVPNStatus}; + +use crate::{ + connection::apple::{ + manager_for_key_and_value, LOCATION_ID, OBSERVER_COMMS, PLUGIN_BUNDLE_ID, TUNNEL_ID, + }, + database::{ + models::{location::Location, tunnel::Tunnel, wireguard_keys::WireguardKeys, Id}, + DB_POOL, + }, + error::Error, + DEFAULT_ROUTE_IPV4, DEFAULT_ROUTE_IPV6, +}; + +/// Try to get `Id` out of manager. ID is embedded in configuration dictionary under `key`. +pub fn id_from_manager(manager: &NETunnelProviderManager, key: &NSString) -> Option { + let plugin_bundle_id = ns_string!(PLUGIN_BUNDLE_ID); + + let vpn_protocol = (unsafe { manager.protocolConfiguration() })?; + let Ok(tunnel_protocol) = vpn_protocol.downcast::() else { + error!("Failed to downcast to NETunnelProviderProtocol"); + return None; + }; + // Sometimes all managers from all apps come through, so filter by bundle ID. + if let Some(bundle_id) = unsafe { tunnel_protocol.providerBundleIdentifier() } { + if &*bundle_id != plugin_bundle_id { + return None; + } + } + + if let Some(config_dict) = unsafe { tunnel_protocol.providerConfiguration() } { + if let Some(any_object) = config_dict.objectForKey(key) { + let Ok(id) = any_object.downcast::() else { + warn!("Failed to downcast ID to NSNumber"); + return None; + }; + return Some(id.as_i64()); + } + } + + None +} + +/// Tunnel configuration shared with VPNExtension (written in Swift). +pub struct TunnelConfiguration { + location_id: Option, + tunnel_id: Option, + name: String, + private_key: String, + addresses: Vec, + listen_port: Option, + peers: Vec, + mtu: Option, + dns: Vec, + dns_search: Vec, +} + +impl TunnelConfiguration { + /// Convert to [`NSDictionary`]. + fn as_nsdict(&self) -> Retained> { + let dict = NSMutableDictionary::new(); + + if let Some(location_id) = self.location_id { + dict.insert( + ns_string!(LOCATION_ID), + NSNumber::new_i64(location_id).as_ref(), + ); + } + + if let Some(tunnel_id) = self.tunnel_id { + dict.insert(ns_string!(TUNNEL_ID), NSNumber::new_i64(tunnel_id).as_ref()); + } + + dict.insert(ns_string!("name"), NSString::from_str(&self.name).as_ref()); + + dict.insert( + ns_string!("privateKey"), + NSString::from_str(&self.private_key).as_ref(), + ); + + // IpAddrMask + let addresses = NSMutableArray::>::new(); + for addr in &self.addresses { + let addr_dict = NSMutableDictionary::::new(); + addr_dict.insert( + ns_string!("address"), + NSString::from_str(&addr.address.to_string()).as_ref(), + ); + addr_dict.insert(ns_string!("cidr"), NSNumber::new_u8(addr.cidr).as_ref()); + addresses.addObject(addr_dict.into_super().as_ref()); + } + dict.insert(ns_string!("addresses"), addresses.as_ref()); + + if let Some(listen_port) = self.listen_port { + dict.insert( + ns_string!("listenPort"), + NSNumber::new_u16(listen_port).as_ref(), + ); + } + + // Peer + let peers = NSMutableArray::>::new(); + for peer in &self.peers { + let peer_dict = NSMutableDictionary::::new(); + peer_dict.insert( + ns_string!("publicKey"), + NSString::from_str(&peer.public_key.to_string()).as_ref(), + ); + + if let Some(preshared_key) = &peer.preshared_key { + peer_dict.insert( + ns_string!("preSharedKey"), + NSString::from_str(&preshared_key.to_string()).as_ref(), + ); + } + + if let Some(endpoint) = &peer.endpoint { + peer_dict.insert( + ns_string!("endpoint"), + NSString::from_str(&endpoint.to_string()).as_ref(), + ); + } + + // Skipping: lastHandshake, txBytes, rxBytes. + + if let Some(persistent_keep_alive) = peer.persistent_keepalive_interval { + peer_dict.insert( + ns_string!("persistentKeepAlive"), + NSNumber::new_u16(persistent_keep_alive).as_ref(), + ); + } + + // IpAddrMask + let allowed_ips = NSMutableArray::>::new(); + for addr in &peer.allowed_ips { + let addr_dict = NSMutableDictionary::::new(); + addr_dict.insert( + ns_string!("address"), + NSString::from_str(&addr.address.to_string()).as_ref(), + ); + addr_dict.insert(ns_string!("cidr"), NSNumber::new_u8(addr.cidr).as_ref()); + allowed_ips.addObject(addr_dict.into_super().as_ref()); + } + peer_dict.insert(ns_string!("allowedIPs"), allowed_ips.as_ref()); + + peers.addObject(peer_dict.into_super().as_ref()); + } + dict.insert(ns_string!("peers"), peers.into_super().as_ref()); + + if let Some(mtu) = self.mtu { + dict.insert(ns_string!("mtu"), NSNumber::new_u32(mtu).as_ref()); + } + + let dns = NSMutableArray::::new(); + for entry in &self.dns { + dns.addObject(NSString::from_str(&entry.to_string()).as_ref()); + } + dict.insert(ns_string!("dns"), dns.as_ref()); + + let dns_search = NSMutableArray::::new(); + for entry in &self.dns_search { + dns_search.addObject(NSString::from_str(entry).as_ref()); + } + dict.insert(ns_string!("dnsSearch"), dns_search.as_ref()); + + dict.into_super() + } + + /// Try to find `NETunnelProviderManager` for this configuration, based on location ID or + /// tunnel ID. + #[must_use] + pub fn tunnel_provider_manager(&self) -> Option> { + let (key, value) = match (self.location_id, self.tunnel_id) { + (Some(location_id), None) => (LOCATION_ID, location_id), + (None, Some(tunnel_id)) => (TUNNEL_ID, tunnel_id), + _ => return None, + }; + + manager_for_key_and_value(key, value) + } + + /// Create or update system VPN settings with this configuration. + pub fn save(&self) { + let spinlock = Arc::new(AtomicBool::new(false)); + let spinlock_clone = Arc::clone(&spinlock); + let plugin_bundle_id = ns_string!(PLUGIN_BUNDLE_ID); + + let provider_manager = self + .tunnel_provider_manager() + .unwrap_or_else(|| unsafe { NETunnelProviderManager::new() }); + + unsafe { + let tunnel_protocol = NETunnelProviderProtocol::new(); + tunnel_protocol.setProviderBundleIdentifier(Some(plugin_bundle_id)); + let server_address = self.peers.first().map_or(String::new(), |peer| { + peer.endpoint.map_or(String::new(), |sa| sa.to_string()) + }); + let server_address = NSString::from_str(&server_address); + // `serverAddress` must have a non-nil string value for the protocol configuration to be + // valid. + tunnel_protocol.setServerAddress(Some(&server_address)); + + let provider_config = self.as_nsdict(); + tunnel_protocol.setProviderConfiguration(Some(&*provider_config)); + + provider_manager.setProtocolConfiguration(Some(&tunnel_protocol)); + let name = NSString::from_str(&self.name); + provider_manager.setLocalizedDescription(Some(&name)); + provider_manager.setEnabled(true); + + // Save to system settings. + let handler = RcBlock::new(move |error_ptr: *mut NSError| { + if error_ptr.is_null() { + debug!("Saved tunnel configuration for {name} to system settings"); + } else { + error!("Failed to save tunnel configuration for: {name} to system settings"); + } + spinlock_clone.store(true, Ordering::Release); + }); + provider_manager.saveToPreferencesWithCompletionHandler(Some(&*handler)); + } + + while !spinlock.load(Ordering::Acquire) { + spin_loop(); + } + } + + /// Start tunnel for this configuration. + pub fn start_tunnel(&self) { + if let Some(provider_manager) = self.tunnel_provider_manager() { + if let Err(err) = + unsafe { provider_manager.connection().startVPNTunnelAndReturnError() } + { + error!("Failed to start VPN: {err}"); + } else { + OBSERVER_COMMS + .0 + .lock() + .expect("Failed to lock observer sender") + .send(( + self.location_id + .map_or_else(|| TUNNEL_ID, |_location_id| LOCATION_ID), + self.location_id.or(self.tunnel_id).unwrap(), + )) + .expect("Failed to send to observer channel"); + info!("VPN started"); + } + } else { + debug!( + "Couldn't find configuration from system settings for {}", + self.name + ); + } + } +} + +impl Location { + /// Build [`TunnelConfiguration`] from [`Location`]. + pub async fn tunnel_configuration( + &self, + preshared_key: Option, + mtu: Option, + ) -> Result { + debug!("Looking for WireGuard keys for location {self} instance"); + let Some(keys) = WireguardKeys::find_by_instance_id(&*DB_POOL, self.instance_id).await? + else { + error!("No keys found for instance: {}", self.instance_id); + return Err(Error::InternalError( + "No keys found for instance".to_string(), + )); + }; + debug!("WireGuard keys found for location {self} instance"); + + // prepare peer config + debug!("Decoding location {self} public key: {}.", self.pubkey); + let peer_key = Key::from_str(&self.pubkey)?; + debug!("Location {self} public key decoded: {peer_key}"); + let mut peer = Peer::new(peer_key); + + debug!("Parsing location {self} endpoint: {}", self.endpoint); + peer.set_endpoint(&self.endpoint)?; + peer.persistent_keepalive_interval = Some(25); + debug!("Parsed location {self} endpoint: {}", self.endpoint); + + if let Some(psk) = preshared_key { + debug!("Decoding location {self} preshared key."); + let peer_psk = Key::from_str(&psk)?; + info!("Location {self} preshared key decoded."); + peer.preshared_key = Some(peer_psk); + } + + debug!("Parsing location {self} allowed IPs: {}", self.allowed_ips); + let route_all_traffic = self.effective_route_all_traffic(&DB_POOL, None).await?; + let allowed_ips = if route_all_traffic { + debug!("Using all traffic routing for location {self}"); + vec![DEFAULT_ROUTE_IPV4.into(), DEFAULT_ROUTE_IPV6.into()] + } else { + debug!( + "Using predefined location {self} traffic: {}", + self.allowed_ips + ); + self.allowed_ips.split(',').map(str::to_string).collect() + }; + for allowed_ip in &allowed_ips { + match IpAddrMask::from_str(allowed_ip) { + Ok(addr) => { + peer.allowed_ips.push(addr); + } + Err(err) => { + // Handle the error from IpAddrMask::from_str, if needed + error!( + "Error parsing IP address {allowed_ip} while setting up interface for \ + location {self}, error details: {err}" + ); + } + } + } + debug!( + "Parsed allowed IPs for location {self}: {:?}", + peer.allowed_ips + ); + + let addresses = self + .address + .split(',') + .map(str::trim) + .map(IpAddrMask::from_str) + .collect::>() + .map_err(|err| { + let msg = format!("Failed to parse IP addresses '{}': {err}", self.address); + error!("{msg}"); + Error::InternalError(msg) + })?; + let (dns, dns_search) = dns_owned(&self.dns); + Ok(TunnelConfiguration { + location_id: Some(self.id), + tunnel_id: None, + name: self.name.clone(), + private_key: keys.prvkey, + addresses, + listen_port: Some(0), + peers: vec![peer], + mtu, + dns, + dns_search, + }) + } + + /// Check whether VPN tunnel is running for [`Location`]. + #[must_use] + pub fn status(&self) -> Option { + manager_for_key_and_value(LOCATION_ID, self.id).map_or_else( + || { + debug!( + "Couldn't find configuration in system settings for location {}", + self.name + ); + None + }, + |provider_manager| unsafe { + let connection = provider_manager.connection(); + Some(connection.status()) + }, + ) + } + + /// Remove configuration from system settings for [`Location`]. + pub fn remove_config(&self) { + if let Some(provider_manager) = manager_for_key_and_value(LOCATION_ID, self.id) { + unsafe { + provider_manager.removeFromPreferencesWithCompletionHandler(None); + } + } else { + debug!( + "Couldn't find configuration in system settings for location {}", + self.name + ); + } + } + + /// Stop VPN tunnel for [`Location`]. + #[must_use] + pub fn stop_vpn_tunnel(&self) -> bool { + manager_for_key_and_value(LOCATION_ID, self.id).map_or_else( + || { + debug!( + "Couldn't find configuration in system settings for location {}", + self.name + ); + false + }, + |provider_manager| { + unsafe { + provider_manager.connection().stopVPNTunnel(); + } + info!("VPN stopped"); + true + }, + ) + } +} + +impl Tunnel { + /// Build [`TunnelConfiguration`] from [`Tunnel`]. + pub fn tunnel_configuration(&self, mtu: Option) -> Result { + // prepare peer config + debug!("Decoding tunnel {self} public key: {}.", self.server_pubkey); + let peer_key = Key::from_str(&self.server_pubkey)?; + debug!("Tunnel {self} public key decoded."); + let mut peer = Peer::new(peer_key); + + debug!("Parsing tunnel {self} endpoint: {}", self.endpoint); + peer.set_endpoint(&self.endpoint)?; + peer.persistent_keepalive_interval = Some( + self.persistent_keep_alive + .try_into() + .expect("Failed to parse persistent keep alive"), + ); + debug!("Parsed tunnel {self} endpoint: {}", self.endpoint); + + if let Some(psk) = &self.preshared_key { + debug!("Decoding tunnel {self} preshared key."); + let peer_psk = Key::from_str(psk)?; + debug!("Preshared key for tunnel {self} decoded."); + peer.preshared_key = Some(peer_psk); + } + + debug!("Parsing tunnel {self} allowed ips: {:?}", self.allowed_ips); + let allowed_ips = if self.route_all_traffic { + debug!("Using all traffic routing for tunnel {self}"); + vec![DEFAULT_ROUTE_IPV4.into(), DEFAULT_ROUTE_IPV6.into()] + } else { + let msg = self.allowed_ips.as_ref().map_or_else( + || "No allowed IP addresses found in tunnel {self} configuration".to_string(), + |ips| format!("Using predefined location traffic for tunnel {self}: {ips}"), + ); + debug!("{msg}"); + self.allowed_ips + .as_ref() + .map(|ips| ips.split(',').map(str::to_string).collect()) + .unwrap_or_default() + }; + for allowed_ip in &allowed_ips { + match IpAddrMask::from_str(allowed_ip.trim()) { + Ok(addr) => { + peer.allowed_ips.push(addr); + } + Err(err) => { + // Handle the error from IpAddrMask::from_str, if needed + error!("Error parsing IP address {allowed_ip}: {err}"); + // Continue to the next iteration of the loop + } + } + } + debug!("Parsed tunnel {self} allowed IPs: {:?}", peer.allowed_ips); + + let addresses = self + .address + .split(',') + .map(str::trim) + .map(IpAddrMask::from_str) + .collect::>() + .map_err(|err| { + let msg = format!("Failed to parse IP addresses '{}': {err}", self.address); + error!("{msg}"); + Error::InternalError(msg) + })?; + let (dns, dns_search) = dns_owned(&self.dns); + Ok(TunnelConfiguration { + location_id: None, + tunnel_id: Some(self.id), + name: self.name.clone(), + private_key: self.prvkey.clone(), + addresses, + listen_port: Some(0), + peers: vec![peer], + mtu, + dns, + dns_search, + }) + } + + /// Check whether VPN tunnel is running for [`Tunnel`]. + #[must_use] + pub fn status(&self) -> Option { + manager_for_key_and_value(TUNNEL_ID, self.id).map_or_else( + || { + debug!( + "Couldn't find configuration in system settings for tunnel {}", + self.name + ); + None + }, + |provider_manager| unsafe { + let connection = provider_manager.connection(); + Some(connection.status()) + }, + ) + } + + /// Remove configuration from system settings for [`Tunnel`]. + pub fn remove_config(&self) { + if let Some(provider_manager) = manager_for_key_and_value(TUNNEL_ID, self.id) { + unsafe { + provider_manager.removeFromPreferencesWithCompletionHandler(None); + } + } else { + debug!( + "Couldn't find configuration in system settings for tunnel {}", + self.name + ); + } + } + + /// Stop tunnel for [`Tunnel`]. + #[must_use] + pub fn stop_vpn_tunnel(&self) -> bool { + manager_for_key_and_value(TUNNEL_ID, self.id).map_or_else( + || { + debug!( + "Couldn't find configuration in system settings for location {}", + self.name + ); + false + }, + |provider_manager| { + unsafe { + provider_manager.connection().stopVPNTunnel(); + } + info!("VPN stopped"); + true + }, + ) + } +} diff --git a/src-tauri/src/database/models/wireguard_keys.rs b/src-tauri/core/src/database/models/wireguard_keys.rs similarity index 100% rename from src-tauri/src/database/models/wireguard_keys.rs rename to src-tauri/core/src/database/models/wireguard_keys.rs diff --git a/src-tauri/core/src/enrollment.rs b/src-tauri/core/src/enrollment.rs new file mode 100644 index 000000000..a37d78235 --- /dev/null +++ b/src-tauri/core/src/enrollment.rs @@ -0,0 +1,638 @@ +//! Enrollment flow for adding a new Defguard instance. +//! +//! Handles the client-side enrollment protocol against the Edge proxy: +//! starting enrollment, creating a device, activating the user, registering +//! MFA, and finishing the enrollment. + +use defguard_client_proto::defguard::client_types::{ + CodeMfaSetupFinishResponse, CodeMfaSetupStartResponse, DeviceConfigResponse, + EnrollmentStartResponse, +}; +use reqwest::{Client, Response, StatusCode, Url}; +use serde::Serialize; +use serde_json::json; +use thiserror::Error; + +use crate::{ + proxy::construct_platform_header, + version::{CLIENT_PLATFORM_HEADER, CLIENT_VERSION_HEADER, PKG_VERSION}, +}; + +/// Error type returned by enrollment operations. +/// +/// Serialized as a tagged JSON union so the TypeScript frontend can +/// match on the `type` field to show context-specific messages. +#[derive(Debug, Error, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EnrollmentError { + #[error("Enrollment token has expired or is invalid")] + TokenExpired, + + #[error("Session cookie not found in enrollment response")] + MissingCookie, + + #[error("{message}")] + NetworkError { message: String }, + + #[error("Proxy error (HTTP {status}): {message}")] + ProxyError { status: u16, message: String }, + + #[error("{message}")] + Other { message: String }, +} + +/// Holds the enrollment session state. +#[derive(Clone, Debug)] +pub struct EnrollmentSession { + pub cookie: String, + pub proxy_url: Url, + pub client: Client, +} + +/// Send a JSON POST request to an enrollment endpoint with the session +/// cookie and standard client headers. +async fn enrollment_post( + session: &EnrollmentSession, + path: &str, + body: serde_json::Value, +) -> Result { + let url = session + .proxy_url + .join(path) + .map_err(|e| EnrollmentError::Other { + message: format!("Failed to build URL '{path}': {e}"), + })?; + let response = session + .client + .post(url) + .json(&body) + .header("Cookie", &session.cookie) + .header(CLIENT_VERSION_HEADER, PKG_VERSION) + .header(CLIENT_PLATFORM_HEADER, construct_platform_header()) + .send() + .await + .map_err(|e| EnrollmentError::NetworkError { + message: format!("Failed to reach proxy: {e}"), + })?; + + check_enrollment_response(response).await +} + +/// Check an enrollment response status and map it to `EnrollmentError`. +async fn check_enrollment_response(response: Response) -> Result { + let status = response.status(); + if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { + return Err(EnrollmentError::TokenExpired); + } + if !status.is_success() { + let message = read_error_body(response).await; + return Err(EnrollmentError::ProxyError { + status: status.as_u16(), + message, + }); + } + Ok(response) +} + +/// Extract the `defguard_proxy` session cookie from a response's `Set-Cookie` +/// headers. +fn extract_defguard_cookie(response: &Response) -> Result { + for value in response.headers().get_all(reqwest::header::SET_COOKIE) { + let raw = value.to_str().unwrap_or_default(); + if raw.starts_with("defguard_proxy=") { + // Take everything up to the first `;`. + return Ok(raw.split(';').next().unwrap_or(raw).to_string()); + } + } + Err(EnrollmentError::MissingCookie) +} + +/// Read an error body from a non-2xx response. +async fn read_error_body(response: Response) -> String { + let status = response.status(); + response + .json::() + .await + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_else(|| format!("HTTP {status}")) +} + +/// Start the enrollment process. +/// +/// POSTs `{ "token": "" }` to `/api/v1/enrollment/start`, extracts +/// the `defguard_proxy` session cookie from the `Set-Cookie` response +/// header, and returns the session together with the full enrollment +/// start response (admin, user, settings, instance, deadline). +pub async fn enrollment_start( + proxy_url: Url, + token: String, +) -> Result<(EnrollmentSession, EnrollmentStartResponse), EnrollmentError> { + let client = Client::new(); + + let url = proxy_url + .join("api/v1/enrollment/start") + .map_err(|e| EnrollmentError::Other { + message: format!("Failed to build enrollment start URL: {e}"), + })?; + + let response = client + .post(url) + .json(&json!({ "token": token })) + .header(CLIENT_VERSION_HEADER, PKG_VERSION) + .header(CLIENT_PLATFORM_HEADER, construct_platform_header()) + .send() + .await + .map_err(|e| EnrollmentError::NetworkError { + message: format!("Failed to reach proxy: {e}"), + })?; + + let status = response.status(); + + if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { + return Err(EnrollmentError::TokenExpired); + } + + if !status.is_success() { + let message = read_error_body(response).await; + return Err(EnrollmentError::ProxyError { + status: status.as_u16(), + message, + }); + } + + let cookie = extract_defguard_cookie(&response)?; + + let body: EnrollmentStartResponse = + response.json().await.map_err(|e| EnrollmentError::Other { + message: format!("Failed to parse enrollment response: {e}"), + })?; + + let session = EnrollmentSession { + cookie, + proxy_url, + client, + }; + + Ok((session, body)) +} + +/// Create a device during enrollment. +/// +/// POSTs `{ "name": "...", "pubkey": "..." }` to +/// `/api/v1/enrollment/create_device` and returns the full device +/// configuration response as a JSON value. +pub async fn enrollment_create_device( + session: EnrollmentSession, + name: String, + pubkey: String, +) -> Result { + let response = enrollment_post( + &session, + "api/v1/enrollment/create_device", + json!({ "name": name, "pubkey": pubkey }), + ) + .await?; + + response.json().await.map_err(|e| EnrollmentError::Other { + message: format!("Failed to parse create_device response: {e}"), + }) +} + +/// Activate the user account during enrollment. +/// +/// POSTs `{ "password": "...", "phone_number": "..." }` to +/// `/api/v1/enrollment/activate_user`. Either field may be omitted when +/// the server does not require it (externally managed users skip password; +/// most users skip phone). +pub async fn enrollment_activate_user( + session: EnrollmentSession, + password: Option, + phone_number: Option, +) -> Result<(), EnrollmentError> { + enrollment_post( + &session, + "api/v1/enrollment/activate_user", + json!({ + "password": password, + "phone_number": phone_number, + }), + ) + .await?; + + Ok(()) +} + +/// Start MFA registration during enrollment. +/// +/// POSTs `{ "method": "..." }` to +/// `/api/v1/enrollment/register-mfa/code/start`. Returns the TOTP secret +/// (for TOTP method) or an empty response (for email method). +pub async fn enrollment_register_mfa_start( + session: EnrollmentSession, + method: String, +) -> Result { + let response = enrollment_post( + &session, + "api/v1/enrollment/register-mfa/code/start", + json!({ "method": method }), + ) + .await?; + + response.json().await.map_err(|e| EnrollmentError::Other { + message: format!("Failed to parse MFA start response: {e}"), + }) +} + +/// Finish MFA registration during enrollment. +/// +/// POSTs `{ "code": "...", "method": "..." }` to +/// `/api/v1/enrollment/register-mfa/code/finish`. Returns the recovery +/// codes that the user should save. +pub async fn enrollment_register_mfa_finish( + session: EnrollmentSession, + code: String, + method: String, +) -> Result { + let response = enrollment_post( + &session, + "api/v1/enrollment/register-mfa/code/finish", + json!({ "code": code, "method": method }), + ) + .await?; + + response.json().await.map_err(|e| EnrollmentError::Other { + message: format!("Failed to parse MFA finish response: {e}"), + }) +} + +/// Fetch network configuration for an existing device during enrollment. +/// +/// POSTs `{ "pubkey": "..." }` to `/api/v1/enrollment/network_info`. +/// This is the fast path for re-enrolling a device whose WireGuard keys +/// already exist on the server. +pub async fn enrollment_network_info( + session: EnrollmentSession, + pubkey: String, +) -> Result { + let response = enrollment_post( + &session, + "api/v1/enrollment/network_info", + json!({ "pubkey": pubkey }), + ) + .await?; + + response.json().await.map_err(|e| EnrollmentError::Other { + message: format!("Failed to parse network_info response: {e}"), + }) +} + +/// Mark the enrollment session as finished. +/// +/// There is no HTTP call for this step -- the session cookie is already +/// cleared by the `activate_user` call on the server side. This function +/// exists as an explicit anchor point for the Tauri command so the +/// implementation mirrors the UI flow. It simply drops the session. +pub fn enrollment_finish(_session: EnrollmentSession) { + // Session dropped; cookie is no longer needed. +} + +#[cfg(test)] +mod tests { + use reqwest::Url; + use serde_json::json; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + + fn mock_url(server: &MockServer) -> Url { + Url::parse(&server.uri()).expect("MockServer URI should be valid") + } + + fn user_json() -> serde_json::Value { + json!({ + "first_name": "John", + "last_name": "Doe", + "login": "jdoe", + "email": "john@example.com", + "phone_number": null, + "is_active": true, + "device_names": [], + "enrolled": false, + "is_admin": false, + "password_management_disabled": false, + }) + } + + fn full_start_json() -> serde_json::Value { + json!({ + "admin": { + "name": "Admin", + "phone_number": null, + "email": "admin@example.com", + }, + "user": user_json(), + "settings": { + "vpn_setup_optional": false, + "only_client_activation": false, + "admin_device_management": false, + "smtp_configured": true, + "mfa_required": true, + }, + "instance": { + "id": "inst-1", + "name": "Test Instance", + "url": "https://test.defguard.net", + "proxy_url": "https://proxy.defguard.net", + "username": "jdoe", + "enterprise_enabled": false, + "disable_all_traffic": false, + "openid_display_name": null, + }, + "deadline_timestamp": 1734567890, + "final_page_content": "Welcome!", + }) + } + + #[tokio::test] + async fn test_start_success() { + let server = MockServer::start().await; + let response_body = full_start_json(); + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/start")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(&response_body) + .insert_header( + "Set-Cookie", + "defguard_proxy=test-session-cookie; Path=/api/v1/enrollment", + ), + ) + .mount(&server) + .await; + + let url = mock_url(&server); + let (session, response) = enrollment_start(url, "valid-token".into()).await.unwrap(); + let user = response.user.expect("user must be present"); + let admin = response.admin.expect("admin must be present"); + let settings = response.settings.expect("settings must be present"); + let instance = response.instance.expect("instance must be present"); + + assert_eq!(session.cookie, "defguard_proxy=test-session-cookie"); + assert_eq!(user.first_name, "John"); + assert_eq!(user.login, "jdoe"); + assert_eq!(admin.name, "Admin"); + assert!(settings.mfa_required); + assert_eq!(instance.id, "inst-1"); + assert_eq!(response.deadline_timestamp, 1734567890); + assert_eq!(response.final_page_content, "Welcome!"); + } + + #[tokio::test] + async fn test_start_token_expired_401() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/start")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let url = mock_url(&server); + let err = enrollment_start(url, "bad-token".into()).await.unwrap_err(); + + assert!(matches!(err, EnrollmentError::TokenExpired)); + } + + #[tokio::test] + async fn test_start_token_expired_403() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/start")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + + let url = mock_url(&server); + let err = enrollment_start(url, "bad-token".into()).await.unwrap_err(); + + assert!(matches!(err, EnrollmentError::TokenExpired)); + } + + #[tokio::test] + async fn test_start_missing_cookie() { + let server = MockServer::start().await; + let response_body = json!({ "user": user_json() }); + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let url = mock_url(&server); + let err = enrollment_start(url, "token".into()).await.unwrap_err(); + + assert!(matches!(err, EnrollmentError::MissingCookie)); + } + + #[tokio::test] + async fn test_start_proxy_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/start")) + .respond_with( + ResponseTemplate::new(500).set_body_json(json!({ "error": "internal boom" })), + ) + .mount(&server) + .await; + + let url = mock_url(&server); + let err = enrollment_start(url, "token".into()).await.unwrap_err(); + + match err { + EnrollmentError::ProxyError { status, message } => { + assert_eq!(status, 500); + assert!(message.contains("internal boom")); + } + other => panic!("expected ProxyError, got {other:?}"), + } + } + + #[tokio::test] + async fn test_start_network_error() { + // Use an address where nothing is listening. + let url = "http://127.0.0.1:1".parse().unwrap(); + let err = enrollment_start(url, "token".into()).await.unwrap_err(); + + assert!(matches!(err, EnrollmentError::NetworkError { .. })); + } + + fn make_session(server: &MockServer) -> EnrollmentSession { + EnrollmentSession { + cookie: "defguard_proxy=test-session-cookie".into(), + proxy_url: mock_url(server), + client: Client::new(), + } + } + + fn device_config_json(assigned_ip: &str) -> serde_json::Value { + json!({ + "device": { + "id": 1, + "name": "my-device", + "pubkey": "pk", + "user_id": 1, + "created_at": 1734567890, + }, + "configs": [{ + "network_id": 1, + "network_name": "main", + "config": "wg0", + "endpoint": "1.2.3.4:51820", + "assigned_ip": assigned_ip, + "pubkey": "nw-pk", + "allowed_ips": "0.0.0.0/0", + "keepalive_interval": 25, + "mfa_enabled": false, + }], + "instance": { + "id": "inst-1", + "name": "Test Instance", + "url": "https://test.defguard.net", + "proxy_url": "https://proxy.defguard.net", + "username": "jdoe", + "enterprise_enabled": false, + "disable_all_traffic": false, + }, + }) + } + + #[tokio::test] + async fn test_create_device_success() { + let server = MockServer::start().await; + let response_body = device_config_json("10.0.0.1"); + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/create_device")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let session = make_session(&server); + let result = enrollment_create_device(session, "my-device".into(), "pk".into()) + .await + .unwrap(); + + assert_eq!(result.configs[0].config, "wg0"); + } + + #[tokio::test] + async fn test_activate_user_success() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/activate_user")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let session = make_session(&server); + enrollment_activate_user(session, Some("p4ssw0rd".into()), None) + .await + .unwrap(); + } + + // enrollment_register_mfa_start + + #[tokio::test] + async fn test_register_mfa_start_success() { + let server = MockServer::start().await; + let response_body = json!({ "totp_secret": "SECRET123" }); + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/register-mfa/code/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let session = make_session(&server); + let result = enrollment_register_mfa_start(session, "totp".into()) + .await + .unwrap(); + + assert_eq!(result.totp_secret.as_deref(), Some("SECRET123")); + } + + // enrollment_register_mfa_finish + + #[tokio::test] + async fn test_register_mfa_finish_success() { + let server = MockServer::start().await; + let response_body = json!({ "recovery_codes": ["rc1", "rc2", "rc3"] }); + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/register-mfa/code/finish")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let session = make_session(&server); + let result = enrollment_register_mfa_finish(session, "123456".into(), "totp".into()) + .await + .unwrap(); + + assert_eq!(result.recovery_codes, vec!["rc1", "rc2", "rc3"]); + } + + // enrollment_network_info + + #[tokio::test] + async fn test_network_info_success() { + let server = MockServer::start().await; + let response_body = device_config_json("10.0.0.2"); + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/network_info")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let session = make_session(&server); + let result = enrollment_network_info(session, "pk".into()).await.unwrap(); + + assert_eq!(result.configs[0].assigned_ip, "10.0.0.2"); + } + + #[tokio::test] + async fn test_network_info_not_found() { + // A 404 means the device was deleted server-side. The status must be + // preserved as ProxyError { status: 404 } so the client can detect it + // and re-enroll (see the addInstance recovery path in the frontend). + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/enrollment/network_info")) + .respond_with( + ResponseTemplate::new(404).set_body_json(json!({ "error": "device not found" })), + ) + .mount(&server) + .await; + + let session = make_session(&server); + let err = enrollment_network_info(session, "pk".into()) + .await + .unwrap_err(); + + assert!(matches!( + err, + EnrollmentError::ProxyError { status: 404, .. } + )); + } +} diff --git a/src-tauri/core/src/error.rs b/src-tauri/core/src/error.rs new file mode 100644 index 000000000..a3105c921 --- /dev/null +++ b/src-tauri/core/src/error.rs @@ -0,0 +1,73 @@ +use std::net::AddrParseError; + +use defguard_wireguard_rs::{error::WireguardInterfaceError, net::IpAddrParseError}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("Application config directory error: {0}")] + Config(String), + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + #[error("Migrate error: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), + #[error("Wireguard error: {0}")] + WireguardError(#[from] WireguardInterfaceError), + #[error("WireGuard key error: {0}")] + KeyDecode(#[from] base64::DecodeError), + #[error("IP address/mask error: {0}")] + IpAddrMask(#[from] IpAddrParseError), + #[error("IP address parse error: {0}")] + AddrParse(#[from] AddrParseError), + #[error("Internal error: {0}")] + InternalError(String), + #[error("Backend service unavailable: {0}")] + BackendUnavailable(String), + #[error("Invalid input: {0}")] + InvalidInput(String), + #[error("Failed to parse timestamp")] + Datetime, + #[error("Object not found")] + NotFound, + #[error("Tauri error: {0}")] + Tauri(String), + #[error("Failed to parse str to enum")] + StrumError(#[from] strum::ParseError), + #[error("Required resource not found {0}")] + ResourceNotFound(String), + #[error("Config parse error {0}")] + ConfigParseError(String), + #[error("Command failed: {0}")] + CommandError(String), + #[error("Core is not enterprise")] + CoreNotEnterprise, + #[error("Tunnels are disabled by the server administrator")] + TunnelsDisabled, + #[error("Instance has no config polling token")] + NoToken, + #[error("Failed to lock app state member.")] + StateLockFail, + #[error("Failed to convert value. {0}")] + ConversionError(String), + #[error("JSON error: {0}")] + JsonError(#[from] serde_json::Error), + #[error("HTTP request error: {0}")] + HttpError(String), + #[error("Posture check failed: {0}")] + PostureCheckFailed(String), + #[error("Service unavailable: {0}")] + ServiceUnavailable(String), + #[error("{0}")] + AllTrafficConflict(String), +} + +// we must manually implement serde::Serialize +impl serde::Serialize for Error { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/src-tauri/core/src/events.rs b/src-tauri/core/src/events.rs new file mode 100644 index 000000000..b09159472 --- /dev/null +++ b/src-tauri/core/src/events.rs @@ -0,0 +1,54 @@ +// Match src/pages/client/types.ts. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EventKey { + ConnectionChanged, + InstanceUpdate, + LocationUpdate, + AppVersionFetch, + ConfigChanged, + DeadConnectionDropped, + DeadConnectionReconnected, + ApplicationConfigChanged, + AddInstance, + MfaTrigger, + VersionMismatch, + UuidMismatch, + WindowSwapped, + SessionStateChanged, + InstanceUpdated, + MfaOpenIdComplete, + MfaOpenIdError, + MfaMobileComplete, + MfaMobileError, + TunnelsDisabled, + TunnelsEnabled, +} + +impl From for &'static str { + fn from(key: EventKey) -> &'static str { + match key { + EventKey::ConnectionChanged => "connection-changed", + EventKey::InstanceUpdate => "instance-update", + EventKey::LocationUpdate => "location-update", + EventKey::AppVersionFetch => "app-version-fetch", + EventKey::ConfigChanged => "config-changed", + EventKey::DeadConnectionDropped => "dead-connection-dropped", + EventKey::DeadConnectionReconnected => "dead-connection-reconnected", + EventKey::ApplicationConfigChanged => "application-config-changed", + EventKey::AddInstance => "add-instance", + EventKey::MfaTrigger => "mfa-trigger", + EventKey::VersionMismatch => "version-mismatch", + EventKey::UuidMismatch => "uuid-mismatch", + EventKey::WindowSwapped => "window-swapped", + EventKey::SessionStateChanged => "session-state-changed", + EventKey::InstanceUpdated => "instance-updated", + EventKey::MfaOpenIdComplete => "mfa-openid-complete", + EventKey::MfaOpenIdError => "mfa-openid-error", + EventKey::MfaMobileComplete => "mfa-mobile-complete", + EventKey::MfaMobileError => "mfa-mobile-error", + EventKey::TunnelsDisabled => "tunnel-disabled-by-policy", + EventKey::TunnelsEnabled => "tunnel-enabled-by-policy", + } + } +} diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs new file mode 100644 index 000000000..dcdef4e69 --- /dev/null +++ b/src-tauri/core/src/lib.rs @@ -0,0 +1,289 @@ +use std::{fmt, path::PathBuf, str::FromStr}; +#[cfg(unix)] +use std::{ + fs::{set_permissions, Permissions}, + os::unix::fs::PermissionsExt, +}; + +use chrono::{Duration, NaiveDateTime, Utc}; +use database::models::{ + location::{infer_mfa_method, Location, LocationMfaMode, ServiceLocationMode}, + Id, +}; +use defguard_client_proto::defguard::client_types::DeviceConfig; +use serde::{Deserialize, Serialize}; + +pub mod app_config; +pub mod connection; +pub mod database; +pub mod enrollment; +pub mod error; +pub mod events; +pub mod mfa; +pub mod proxy; +#[cfg(test)] +mod test_helpers; +pub mod version; +pub mod wg_config; + +// Re-export proto module for backward compatibility within core. +pub use defguard_client_proto::defguard as proto; + +use crate::database::models::NoId; + +#[macro_use] +extern crate log; + +const BUNDLE_IDENTIFIER: &str = "net.defguard"; + +/// Returns the path to the user's data directory. +#[must_use] +pub fn app_data_dir() -> Option { + dirs_next::data_dir().map(|dir| dir.join(BUNDLE_IDENTIFIER)) +} + +/// Ensures path has appropriate permissions set (dg25-28): +/// - 700 for directories +/// - 600 for files +#[cfg(unix)] +pub fn set_perms(path: &std::path::Path) { + let perms = if path.is_dir() { 0o700 } else { 0o600 }; + if let Err(err) = set_permissions(path, Permissions::from_mode(perms)) { + warn!( + "Failed to set permissions on path {}: {err}", + path.display() + ); + } +} + +/// Location type used in commands to check if we use tunnel or location +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] +pub enum ConnectionType { + Tunnel, + Location, +} + +impl fmt::Display for ConnectionType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ConnectionType::Tunnel => write!(f, "tunnel"), + ConnectionType::Location => write!(f, "location"), + } + } +} + +/// Common fields for Tunnel and Location +#[derive(Debug, Serialize, Deserialize)] +pub struct CommonWireguardFields { + pub instance_id: Id, + pub network_id: Id, + pub name: String, + pub address: String, + pub pubkey: String, + pub endpoint: String, + pub allowed_ips: String, + pub dns: Option, + pub route_all_traffic: bool, +} + +/// Common fields for Connection and TunnelConnection due to shared command +#[derive(Debug, Serialize, Deserialize)] +pub struct CommonConnection { + pub id: I, + pub location_id: Id, + pub start: NaiveDateTime, + pub end: NaiveDateTime, + pub connection_type: ConnectionType, +} + +/// Common fields for LocationStats and TunnelStats due to shared command +#[derive(Debug, Serialize, Deserialize)] +pub struct CommonLocationStats { + pub id: I, + pub location_id: Id, + pub upload: i64, + pub download: i64, + pub last_handshake: i64, + pub collected_at: NaiveDateTime, + pub listen_port: u32, + pub persistent_keepalive_interval: Option, + pub connection_type: ConnectionType, +} + +/// Common fields for ConnectionInfo and TunnelConnectionInfo due to shared command +#[derive(Debug, Serialize)] +pub struct CommonConnectionInfo { + pub id: Id, + pub location_id: Id, + pub start: NaiveDateTime, + pub end: NaiveDateTime, + pub upload: Option, + pub download: Option, +} + +pub const DEFAULT_ROUTE_IPV4: &str = "0.0.0.0/0"; +pub const DEFAULT_ROUTE_IPV6: &str = "::/0"; + +#[must_use] +pub fn contains_default_route(allowed_ips: &str) -> bool { + allowed_ips + .split(',') + .filter_map(|entry| defguard_wireguard_rs::net::IpAddrMask::from_str(entry.trim()).ok()) + .any(|addr| addr.address.is_unspecified() && addr.cidr == 0) +} + +pub enum DateTimeAggregation { + Hour, + Second, +} + +impl DateTimeAggregation { + #[must_use] + pub fn fstring(&self) -> &'static str { + match self { + Self::Hour => "%Y-%m-%d %H:00:00", + Self::Second => "%Y-%m-%d %H:%M:%S", + } + } +} + +pub fn get_aggregation(from: NaiveDateTime) -> Result { + let aggregation = match Utc::now().naive_utc() - from { + duration if duration >= Duration::hours(8) => Ok(DateTimeAggregation::Hour), + duration if duration < Duration::zero() => Err(error::Error::InternalError(format!( + "Negative duration between dates: now ({}) and {from}", + Utc::now().naive_utc(), + ))), + _ => Ok(DateTimeAggregation::Second), + }?; + Ok(aggregation) +} + +#[must_use] +pub fn into_location(dev_config: DeviceConfig, instance_id: Id) -> Location { + use LocationMfaMode as MfaMode; + use ServiceLocationMode as SLocationMode; + + let location_mfa_mode = match dev_config.location_mfa_mode { + Some(_location_mfa_mode) => dev_config.location_mfa_mode().into(), + None => + { + #[allow(deprecated)] + if dev_config.mfa_enabled { + MfaMode::Internal + } else { + MfaMode::Disabled + } + } + }; + + let service_location_mode = match dev_config.service_location_mode { + Some(_service_location_mode) => dev_config.service_location_mode().into(), + None => SLocationMode::Disabled, + }; + + Location { + id: NoId, + instance_id, + network_id: dev_config.network_id, + name: dev_config.network_name, + address: dev_config.assigned_ip, + pubkey: dev_config.pubkey, + endpoint: dev_config.endpoint, + allowed_ips: dev_config.allowed_ips, + dns: dev_config.dns, + route_all_traffic: false, + keepalive_interval: dev_config.keepalive_interval.into(), + location_mfa_mode, + service_location_mode, + mfa_method: infer_mfa_method(location_mfa_mode, None), + posture_check_required: dev_config.posture_check_required.unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use chrono::{Duration, Utc}; + use defguard_client_proto::defguard::client_types::DeviceConfig; + + use super::{get_aggregation, into_location, DateTimeAggregation}; + use crate::database::models::location::{LocationMfaMethod, LocationMfaMode}; + + #[test] + fn test_get_aggregation_hour() { + // 8 hours or older aggregates per hour. + let from = Utc::now().naive_utc() - Duration::hours(9); + assert!(matches!( + get_aggregation(from).unwrap(), + DateTimeAggregation::Hour + )); + } + + #[test] + fn test_get_aggregation_second() { + // Recent ranges aggregate per second. + let from = Utc::now().naive_utc() - Duration::minutes(1); + assert!(matches!( + get_aggregation(from).unwrap(), + DateTimeAggregation::Second + )); + } + + #[test] + fn test_get_aggregation_future_errors() { + // A timestamp in the future yields a negative duration and is rejected. + let from = Utc::now().naive_utc() + Duration::hours(1); + assert!(get_aggregation(from).is_err()); + } + + fn base_dev_config() -> DeviceConfig { + DeviceConfig { + network_id: 7, + network_name: "net".into(), + endpoint: "1.2.3.4:51820".into(), + assigned_ip: "10.6.0.2".into(), + pubkey: "pk".into(), + allowed_ips: "0.0.0.0/0".into(), + keepalive_interval: 25, + ..Default::default() + } + } + + #[test] + fn test_into_location_maps_fields() { + let location = into_location(base_dev_config(), 3); + assert_eq!(location.instance_id, 3); + assert_eq!(location.network_id, 7); + assert_eq!(location.name, "net"); + assert_eq!(location.address, "10.6.0.2"); + assert_eq!(location.endpoint, "1.2.3.4:51820"); + assert_eq!(location.allowed_ips, "0.0.0.0/0"); + assert_eq!(location.keepalive_interval, 25); + assert!(!location.route_all_traffic); + assert!(!location.posture_check_required); + } + + #[test] + fn test_into_location_mfa_mode_from_deprecated_flag() { + // With no explicit mode, the deprecated mfa_enabled flag drives the mode. + let mut cfg = base_dev_config(); + #[allow(deprecated)] + { + cfg.mfa_enabled = true; + } + let location = into_location(cfg, 1); + assert_eq!(location.location_mfa_mode, LocationMfaMode::Internal); + // Internal mode with no configured method resolves to Totp. + assert_eq!(location.mfa_method, Some(LocationMfaMethod::Totp)); + } + + #[test] + fn test_into_location_explicit_mfa_mode() { + let mut cfg = base_dev_config(); + cfg.location_mfa_mode = Some(crate::proto::client_types::LocationMfaMode::External as i32); + let location = into_location(cfg, 1); + assert_eq!(location.location_mfa_mode, LocationMfaMode::External); + // External mode always resolves to OIDC. + assert_eq!(location.mfa_method, Some(LocationMfaMethod::Oidc)); + } +} diff --git a/src-tauri/core/src/mfa.rs b/src-tauri/core/src/mfa.rs new file mode 100644 index 000000000..c10f02194 --- /dev/null +++ b/src-tauri/core/src/mfa.rs @@ -0,0 +1,836 @@ +//! Connect-time VPN MFA over HTTP. +//! +//! Synchronous (request/response) MFA functions for TOTP and email methods, +//! plus long-running flows for OpenID (poll loop) and mobile approve (WebSocket). + +use std::time::Duration; + +use defguard_client_proto::defguard::client_types::{ + ClientMfaFinishRequest, ClientMfaFinishResponse, ClientMfaStartRequest, ClientMfaStartResponse, + MfaMethod, +}; +use futures_util::StreamExt; +use reqwest::{Client, Response, StatusCode, Url}; +use serde::Serialize; +use thiserror::Error; +use tokio::{ + net::TcpStream, + select, + time::{sleep, Instant}, +}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{Error as WsError, Message}, + MaybeTlsStream, WebSocketStream, +}; +use tokio_util::sync::CancellationToken; + +use crate::{ + proxy::construct_platform_header, + version::{CLIENT_PLATFORM_HEADER, CLIENT_VERSION_HEADER, PKG_VERSION}, +}; + +/// Error type returned by MFA operations. +/// +/// Serialized as a tagged JSON union so the TypeScript frontend can +/// match on the `type` field to show context-specific messages. +#[derive(Debug, Error, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MfaError { + #[error("{message}")] + NetworkError { message: String }, + + #[error("Proxy error (HTTP {status}): {message}")] + ProxyError { status: u16, message: String }, + + #[error("MFA rejected: {message}")] + MfaRejected { message: String }, + + #[error("Posture check failed: {message}")] + PostureRejected { message: String }, + + #[error("MFA operation timed out")] + Timeout, + + #[error("MFA operation cancelled")] + Cancelled, + + #[error("{message}")] + Other { message: String }, +} + +fn build_client() -> Client { + Client::new() +} + +fn standard_headers() -> Vec<(&'static str, String)> { + vec![ + (CLIENT_VERSION_HEADER, PKG_VERSION.to_string()), + (CLIENT_PLATFORM_HEADER, construct_platform_header()), + ] +} + +/// Check an MFA response status and map it to `MfaError`. +async fn check_mfa_response(response: Response) -> Result { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + + let message = response + .json::() + .await + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_else(|| format!("HTTP {status}")); + + match status { + // The proxy returns 403 only for a failed device posture check + // (ApiError::PostureRejected); 401 and other 4xx are ordinary MFA + // rejections. Keeping them distinct lets the frontend route posture + // failures to the dedicated posture-check-failed view. + StatusCode::FORBIDDEN => Err(MfaError::PostureRejected { message }), + StatusCode::UNAUTHORIZED => Err(MfaError::MfaRejected { message }), + _ if status.is_client_error() => Err(MfaError::MfaRejected { message }), + _ => Err(MfaError::ProxyError { + status: status.as_u16(), + message, + }), + } +} + +/// Start an MFA handshake for a VPN location. +/// +/// POSTs a `ClientMfaStartRequest` (proto JSON) to +/// `/api/v1/client-mfa/start` and returns the session token (and +/// optionally the biometric challenge). +pub async fn mfa_start( + proxy_url: Url, + request: ClientMfaStartRequest, +) -> Result { + let client = build_client(); + + let url = proxy_url + .join("api/v1/client-mfa/start") + .map_err(|e| MfaError::Other { + message: format!("Failed to build MFA start URL: {e}"), + })?; + + let mut req = client.post(url).json(&request); + + for (k, v) in standard_headers() { + req = req.header(k, v); + } + + let response = req.send().await.map_err(|e| MfaError::NetworkError { + message: format!("Failed to reach proxy: {e}"), + })?; + + let response = match check_mfa_response(response).await { + Ok(response) => response, + Err(err) => return Err(rewrap_mobile_start_error(request.method, err)), + }; + response.json().await.map_err(|e| MfaError::Other { + message: format!("Invalid MFA start response: {e}"), + }) +} + +/// Turn the proxy's generic "selected MFA method is not available" rejection +/// into actionable guidance for mobile-approve MFA (the user has no registered +/// mobile authenticator). Restores the CLI behavior that was lost when this +/// logic moved into core; benefits the desktop client too. Non-mobile methods +/// keep the original message. +fn rewrap_mobile_start_error(method: i32, err: MfaError) -> MfaError { + if method == MfaMethod::MobileApprove as i32 { + if let MfaError::MfaRejected { message } = &err { + if message.contains("selected MFA method is not available") { + return MfaError::MfaRejected { + message: "No mobile authenticator is registered for your account. \ + Register one in the Defguard mobile app, then retry." + .into(), + }; + } + } + } + err +} + +/// Finish an MFA handshake using a one-time code (TOTP or email). +/// +/// POSTs a `ClientMfaFinishRequest` to `/api/v1/client-mfa/finish` +/// and returns the preshared key. +pub async fn mfa_finish_code( + proxy_url: Url, + request: ClientMfaFinishRequest, +) -> Result { + let client = build_client(); + + let url = proxy_url + .join("api/v1/client-mfa/finish") + .map_err(|e| MfaError::Other { + message: format!("Failed to build MFA finish URL: {e}"), + })?; + + let mut req = client.post(url).json(&request); + + for (k, v) in standard_headers() { + req = req.header(k, v); + } + + let response = req.send().await.map_err(|e| MfaError::NetworkError { + message: format!("Failed to reach proxy: {e}"), + })?; + + let response = check_mfa_response(response).await?; + response.json().await.map_err(|e| MfaError::Other { + message: format!("Invalid MFA finish response: {e}"), + }) +} + +#[cfg(not(test))] +const OIDC_POLL_INTERVAL: Duration = Duration::from_secs(5); +#[cfg(test)] +const OIDC_POLL_INTERVAL: Duration = Duration::from_millis(5); + +#[cfg(not(test))] +const OIDC_POLL_TIMEOUT: Duration = Duration::from_mins(5); +#[cfg(test)] +const OIDC_POLL_TIMEOUT: Duration = Duration::from_millis(200); + +#[cfg(not(test))] +const MOBILE_APPROVE_TIMEOUT: Duration = Duration::from_mins(2); +#[cfg(test)] +const MOBILE_APPROVE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Poll the proxy for OpenID MFA completion. +/// +/// The caller must already have opened the browser to the OIDC provider +/// URL (the token from `mfa_start` encodes the redirect). This function +/// POSTs a `ClientMfaFinishRequest` to `/api/v1/client-mfa/finish` every +/// [`OIDC_POLL_INTERVAL`] until the server returns a 200 (success), +/// the deadline expires, or the [`CancellationToken`] is fired. +pub async fn poll_openid_mfa( + proxy_url: Url, + token: String, + cancel: CancellationToken, +) -> Result { + let client = build_client(); + let url = proxy_url + .join("api/v1/client-mfa/finish") + .map_err(|e| MfaError::Other { + message: format!("Failed to build MFA finish URL: {e}"), + })?; + + let deadline = Instant::now() + OIDC_POLL_TIMEOUT; + + let request = ClientMfaFinishRequest { + token, + code: None, + auth_pub_key: None, + }; + + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_default(); + if remaining.is_zero() { + return Err(MfaError::Timeout); + } + + let mut req = client.post(url.clone()).json(&request); + for (k, v) in standard_headers() { + req = req.header(k, v); + } + + select! { + () = cancel.cancelled() => { + return Err(MfaError::Cancelled); + } + result = req.send() => { + let response = result.map_err(|e| MfaError::NetworkError { + message: format!("Failed to reach proxy: {e}"), + })?; + + let status = response.status(); + if status == StatusCode::OK { + return response.json().await.map_err(|e| MfaError::Other { + message: format!("Invalid MFA finish response: {e}"), + }); + } + if status != StatusCode::PRECONDITION_REQUIRED { + return Err(check_mfa_response(response).await.err().unwrap_or( + MfaError::Other { + message: format!("Unexpected status: {status}"), + }, + )); + } + // 428: not complete yet — fall through to sleep. + } + } + + select! { + () = cancel.cancelled() => { + return Err(MfaError::Cancelled); + } + () = sleep(OIDC_POLL_INTERVAL) => {} + } + } +} + +/// Connect to a WebSocket endpoint and wait for mobile-approve MFA +/// completion. +/// +/// The caller must have already displayed the QR code to the user +/// (the token from `mfa_start` encodes the challenge). This function +/// opens a WebSocket to `ws_url` and waits for a +/// `{"type":"mfa_success","preshared_key":"..."}` text frame. +/// Returns [`MfaError::Cancelled`] if the token fires or +/// [`MfaError::Timeout`] if the deadline expires. +pub async fn connect_mobile_approve( + ws_url: &str, + cancel: CancellationToken, +) -> Result { + let (ws_stream, _response) = + connect_async(ws_url) + .await + .map_err(|e| MfaError::NetworkError { + // Never interpolate the raw error: `ws_url` carries the MFA + // token as a query parameter and can appear in the error's + // Display, which is surfaced to the frontend and logs. + message: match &e { + WsError::Io(io_err) => { + format!("Failed to connect to proxy ({})", io_err.kind()) + } + _ => "Failed to connect to proxy".to_string(), + }, + })?; + + wait_for_mfa_success(ws_stream, cancel).await +} + +/// Derive the WebSocket URL from the proxy's base URL and MFA token. +pub fn derive_ws_url(proxy_base: &Url, token: &str) -> Result { + let mut ws_url = proxy_base + .join("api/v1/client-mfa/remote") + .map_err(|e| MfaError::Other { + message: format!("Failed to build WebSocket URL: {e}"), + })?; + + let ws_scheme = match proxy_base.scheme() { + "https" => "wss", + "http" => "ws", + other => { + return Err(MfaError::Other { + message: format!("Invalid proxy URL scheme '{other}'; expected http or https"), + }); + } + }; + + ws_url.set_scheme(ws_scheme).map_err(|()| MfaError::Other { + message: "Failed to set WebSocket URL scheme".into(), + })?; + ws_url.query_pairs_mut().append_pair("token", token); + + Ok(ws_url.to_string()) +} + +/// Wait on the WebSocket for an `mfa_success` frame. +async fn wait_for_mfa_success( + ws_stream: WebSocketStream>, + cancel: CancellationToken, +) -> Result { + let (_write, mut read) = ws_stream.split(); + let deadline = Instant::now() + MOBILE_APPROVE_TIMEOUT; + + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_default(); + if remaining.is_zero() { + return Err(MfaError::Timeout); + } + + let msg = select! { + () = sleep(remaining) => { + return Err(MfaError::Timeout); + } + () = cancel.cancelled() => { + return Err(MfaError::Cancelled); + } + msg = read.next() => { + match msg { + Some(Ok(msg)) => msg, + Some(Err(_)) | None => { + return Err(MfaError::MfaRejected { + message: "mobile approval failed: connection closed by proxy" + .into(), + }); + } + } + } + }; + + if let Message::Text(text) = msg { + if let Ok(parsed) = serde_json::from_str::(&text) { + if parsed.get("type").and_then(|v| v.as_str()) == Some("mfa_success") { + if let Some(key) = parsed["preshared_key"].as_str() { + return Ok(ClientMfaFinishResponse { + preshared_key: key.to_string(), + token: None, + }); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use reqwest::Url; + use serde_json::json; + use tokio_util::sync::CancellationToken; + use wiremock::{ + matchers::{body_partial_json, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + use crate::test_helpers::{start_ws_stub, WsStubCommand}; + + fn mock_url(server: &MockServer) -> Url { + Url::parse(&server.uri()).expect("MockServer URI should be valid") + } + + fn start_request() -> ClientMfaStartRequest { + ClientMfaStartRequest { + location_id: 1, + pubkey: "pk".into(), + method: 0, // TOTP + posture_data: None, + } + } + + fn start_response_json(token: &str) -> serde_json::Value { + json!({ + "token": token, + "challenge": null, + }) + } + + fn finish_response_json(key: &str) -> serde_json::Value { + json!({ + "preshared_key": key, + }) + } + + #[tokio::test] + async fn test_mfa_start_success() { + let server = MockServer::start().await; + let body = start_response_json("mfa-token-1"); + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(&body)) + .mount(&server) + .await; + + let url = mock_url(&server); + let info = mfa_start(url, start_request()).await.unwrap(); + assert_eq!(info.token, "mfa-token-1"); + assert!(info.challenge.is_none()); + } + + #[tokio::test] + async fn test_mfa_start_rejected() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/start")) + .respond_with( + ResponseTemplate::new(401).set_body_json(json!({ "error": "unauthorized" })), + ) + .mount(&server) + .await; + + let url = mock_url(&server); + let err = mfa_start(url, start_request()).await.unwrap_err(); + assert!(matches!(err, MfaError::MfaRejected { .. })); + } + + #[tokio::test] + async fn test_mfa_start_posture_rejected_on_403() { + // 403 is the proxy's posture-check-failure status; it must map to the + // dedicated PostureRejected variant, not the generic MfaRejected. + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/start")) + .respond_with( + ResponseTemplate::new(403).set_body_json(json!({ "error": "firewall enabled" })), + ) + .mount(&server) + .await; + + let url = mock_url(&server); + let err = mfa_start(url, start_request()).await.unwrap_err(); + assert!(matches!(err, MfaError::PostureRejected { .. })); + } + + #[tokio::test] + async fn test_mfa_start_sends_snake_case_numeric_body() { + // Guards the wire contract: the proxy expects snake_case fields and a + // *numeric* `method`. If serde ever serialized camelCase or a string + // enum, the body matcher fails, the mock returns nothing, and the call + // errors instead of silently sending a malformed request. + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/start")) + .and(body_partial_json( + json!({ "location_id": 1, "pubkey": "pk", "method": 0 }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(start_response_json("t"))) + .mount(&server) + .await; + + let url = mock_url(&server); + mfa_start(url, start_request()) + .await + .expect("request body did not match the expected wire contract"); + } + + #[tokio::test] + async fn test_mfa_start_network_error() { + // Nothing listening on this port. + let url = "http://127.0.0.1:1".parse().unwrap(); + let err = mfa_start(url, start_request()).await.unwrap_err(); + assert!(matches!(err, MfaError::NetworkError { .. })); + } + + #[tokio::test] + async fn test_mfa_start_proxy_error_on_5xx() { + // 5xx is a server fault (ProxyError), distinct from a 4xx rejection. + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/start")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ "error": "boom" }))) + .mount(&server) + .await; + + let url = mock_url(&server); + let err = mfa_start(url, start_request()).await.unwrap_err(); + assert!(matches!(err, MfaError::ProxyError { status: 500, .. })); + } + + #[tokio::test] + async fn test_mfa_start_mobile_no_authenticator_guidance() { + // Mobile-approve start rejected because no authenticator is registered: + // the generic proxy message becomes actionable guidance. + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/start")) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(json!({ "error": "selected MFA method is not available" })), + ) + .mount(&server) + .await; + + let url = mock_url(&server); + let request = ClientMfaStartRequest { + location_id: 1, + pubkey: "pk".into(), + method: MfaMethod::MobileApprove as i32, + posture_data: None, + }; + match mfa_start(url, request).await.unwrap_err() { + MfaError::MfaRejected { message } => { + assert!( + message.contains("mobile app"), + "unexpected message: {message}" + ); + } + other => panic!("expected MfaRejected, got {other:?}"), + } + } + + #[tokio::test] + async fn test_mfa_start_non_mobile_not_rewrapped() { + // The mobile guidance must not leak into other methods. + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/start")) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(json!({ "error": "selected MFA method is not available" })), + ) + .mount(&server) + .await; + + let url = mock_url(&server); + // start_request() uses method 0 (TOTP). + match mfa_start(url, start_request()).await.unwrap_err() { + MfaError::MfaRejected { message } => { + assert!( + !message.contains("mobile app"), + "TOTP got mobile guidance: {message}" + ); + } + other => panic!("expected MfaRejected, got {other:?}"), + } + } + + #[tokio::test] + async fn test_mfa_finish_code_success() { + let server = MockServer::start().await; + let body = finish_response_json("psk-123"); + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/finish")) + .respond_with(ResponseTemplate::new(200).set_body_json(&body)) + .mount(&server) + .await; + + let url = mock_url(&server); + let psk = mfa_finish_code( + url, + ClientMfaFinishRequest { + token: "token".into(), + code: Some("123456".into()), + auth_pub_key: None, + }, + ) + .await + .unwrap(); + assert_eq!(psk.preshared_key, "psk-123"); + } + + #[tokio::test] + async fn test_mfa_finish_code_rejected() { + // A wrong code is a 4xx rejection. + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/finish")) + .respond_with( + ResponseTemplate::new(401).set_body_json(json!({ "error": "Unauthorized" })), + ) + .mount(&server) + .await; + + let url = mock_url(&server); + let err = mfa_finish_code( + url, + ClientMfaFinishRequest { + token: "token".into(), + code: Some("000000".into()), + auth_pub_key: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, MfaError::MfaRejected { .. })); + } + + #[tokio::test] + async fn test_poll_openid_success() { + let server = MockServer::start().await; + let body = finish_response_json("oidc-psk"); + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/finish")) + .respond_with(ResponseTemplate::new(200).set_body_json(&body)) + .mount(&server) + .await; + + let url = mock_url(&server); + let cancel = CancellationToken::new(); + let psk = poll_openid_mfa(url, "token".into(), cancel).await.unwrap(); + assert_eq!(psk.preshared_key, "oidc-psk"); + } + + #[tokio::test] + async fn test_poll_openid_428_then_success() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/finish")) + .respond_with(ResponseTemplate::new(428)) + .up_to_n_times(2) + .mount(&server) + .await; + + let success_body = finish_response_json("oidc-psk"); + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/finish")) + .respond_with(ResponseTemplate::new(200).set_body_json(&success_body)) + .mount(&server) + .await; + + let url = mock_url(&server); + let cancel = CancellationToken::new(); + let psk = poll_openid_mfa(url, "token".into(), cancel).await.unwrap(); + assert_eq!(psk.preshared_key, "oidc-psk"); + } + + #[tokio::test] + async fn test_poll_openid_stops_on_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/finish")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ "error": "boom" }))) + .mount(&server) + .await; + + let url = mock_url(&server); + let cancel = CancellationToken::new(); + let err = poll_openid_mfa(url, "token".into(), cancel) + .await + .unwrap_err(); + match err { + MfaError::ProxyError { status, message } => { + assert_eq!(status, 500); + assert!(message.contains("boom")); + } + other => panic!("expected ProxyError, got {other:?}"), + } + } + + #[tokio::test] + async fn test_poll_openid_timeout() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/finish")) + .respond_with(ResponseTemplate::new(428)) + .mount(&server) + .await; + + let url = mock_url(&server); + let cancel = CancellationToken::new(); + let err = poll_openid_mfa(url, "token".into(), cancel) + .await + .unwrap_err(); + assert!(matches!(err, MfaError::Timeout)); + } + + #[tokio::test] + async fn test_poll_openid_cancelled() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/client-mfa/finish")) + .respond_with(ResponseTemplate::new(428)) + .mount(&server) + .await; + + let url = mock_url(&server); + let cancel = CancellationToken::new(); + cancel.cancel(); + let err = poll_openid_mfa(url, "token".into(), cancel) + .await + .unwrap_err(); + assert!(matches!(err, MfaError::Cancelled)); + } + + #[tokio::test] + async fn test_mobile_approve_success() { + let stub = start_ws_stub().await; + let addr = stub.addr; + let tx = stub.tx; + let ws_url = format!("ws://{addr}/test"); + + let cancel = CancellationToken::new(); + let handle = tokio::spawn(async move { connect_mobile_approve(&ws_url, cancel).await }); + + tx.send(WsStubCommand::SendMessage( + r#"{"type":"mfa_success","preshared_key":"mobile-psk"}"#.into(), + )) + .unwrap(); + tx.send(WsStubCommand::Close).unwrap(); + + let psk = handle.await.unwrap().unwrap(); + assert_eq!(psk.preshared_key, "mobile-psk"); + } + + #[tokio::test] + async fn test_mobile_approve_close_without_success() { + let stub = start_ws_stub().await; + let addr = stub.addr; + let tx = stub.tx; + let ws_url = format!("ws://{addr}/test"); + + let cancel = CancellationToken::new(); + let handle = tokio::spawn(async move { connect_mobile_approve(&ws_url, cancel).await }); + + tx.send(WsStubCommand::Close).unwrap(); + + let err = handle.await.unwrap().unwrap_err(); + assert!(matches!(err, MfaError::MfaRejected { .. })); + } + + #[tokio::test] + async fn test_mobile_approve_cancelled() { + let stub = start_ws_stub().await; + let addr = stub.addr; + let ws_url = format!("ws://{addr}/test"); + + let cancel = CancellationToken::new(); + cancel.cancel(); + let err = connect_mobile_approve(&ws_url, cancel).await.unwrap_err(); + assert!(matches!(err, MfaError::Cancelled)); + } + + #[tokio::test] + async fn test_mobile_approve_connect_error_does_not_leak_token() { + // Nothing is listening, so the WebSocket connect fails. The error must + // be a NetworkError whose message never contains the MFA token (the + // token rides in the ws_url query string). + let base: Url = "http://127.0.0.1:1".parse().unwrap(); + let token = "super-secret-mfa-token"; + let ws_url = derive_ws_url(&base, token).unwrap(); + + let cancel = CancellationToken::new(); + let err = connect_mobile_approve(&ws_url, cancel).await.unwrap_err(); + + assert!(matches!(err, MfaError::NetworkError { .. })); + assert!( + !err.to_string().contains(token), + "error leaked the MFA token: {err}" + ); + } + + #[test] + fn test_derive_ws_url_http_to_ws() { + let base = Url::parse("http://proxy.example.com/").unwrap(); + let ws = derive_ws_url(&base, "tok").unwrap(); + assert!(ws.starts_with("ws://proxy.example.com/api/v1/client-mfa/remote")); + assert!(ws.contains("token=tok")); + } + + #[test] + fn test_derive_ws_url_https_to_wss() { + let base = Url::parse("https://proxy.example.com/").unwrap(); + let ws = derive_ws_url(&base, "tok").unwrap(); + assert!(ws.starts_with("wss://proxy.example.com/api/v1/client-mfa/remote")); + } + + #[test] + fn test_derive_ws_url_preserves_path_prefix() { + let base = Url::parse("https://proxy.example.com/defguard/").unwrap(); + let ws = derive_ws_url(&base, "tok").unwrap(); + assert!(ws.starts_with("wss://proxy.example.com/defguard/api/v1/client-mfa/remote")); + } + + #[test] + fn test_derive_ws_url_rejects_non_http_scheme() { + let base = Url::parse("ftp://proxy.example.com/").unwrap(); + let err = derive_ws_url(&base, "tok").unwrap_err(); + assert!(matches!(err, MfaError::Other { .. })); + } +} diff --git a/src-tauri/core/src/proxy.rs b/src-tauri/core/src/proxy.rs new file mode 100644 index 000000000..e24507e64 --- /dev/null +++ b/src-tauri/core/src/proxy.rs @@ -0,0 +1,46 @@ +use std::{env, time::Duration}; + +use base64::{prelude::BASE64_STANDARD, Engine}; +use defguard_client_proto::defguard::client_types::ClientPlatformInfo; +use prost::Message; +use reqwest::{Client, Response, Url}; +use serde::Serialize; + +use crate::version::{CLIENT_PLATFORM_HEADER, CLIENT_VERSION_HEADER, PKG_VERSION}; + +const HTTP_REQ_TIMEOUT: Duration = Duration::from_secs(5); + +/// Build a base64-encoded `ClientPlatformInfo` header value. +#[must_use] +pub fn construct_platform_header() -> String { + let os = os_info::get(); + + let platform_info = ClientPlatformInfo { + os_family: env::consts::FAMILY.to_string(), + os_type: env::consts::OS.to_string(), + version: os.version().to_string(), + edition: os.edition().map(str::to_string), + codename: os.codename().map(str::to_string), + bitness: Some(os.bitness().to_string()), + architecture: Some(env::consts::ARCH.to_string()), + }; + + debug!("Constructed platform info header: {platform_info:?}"); + + BASE64_STANDARD.encode(platform_info.encode_to_vec()) +} + +/// Send a JSON POST request with the standard client version/platform headers and a short timeout. +pub async fn post_with_headers( + url: Url, + data: &T, +) -> Result { + Client::new() + .post(url) + .json(data) + .header(CLIENT_VERSION_HEADER, PKG_VERSION) + .header(CLIENT_PLATFORM_HEADER, construct_platform_header()) + .timeout(HTTP_REQ_TIMEOUT) + .send() + .await +} diff --git a/src-tauri/core/src/test_helpers.rs b/src-tauri/core/src/test_helpers.rs new file mode 100644 index 000000000..bf7061dd8 --- /dev/null +++ b/src-tauri/core/src/test_helpers.rs @@ -0,0 +1,77 @@ +//! Shared test helpers for defguard-client-core tests. +//! +//! Provides a controllable WebSocket stub for MFA mobile-approve tests, +//! eliminating the need for Docker or external services. + +use std::net::SocketAddr; + +use futures_util::{SinkExt, StreamExt}; +use tokio::{ + net::TcpListener, + sync::mpsc::{unbounded_channel, UnboundedSender}, +}; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +/// Command to control the WebSocket stub's behavior after a client connects. +pub enum WsStubCommand { + /// Send a text frame to the connected client. + SendMessage(String), + /// Close the WebSocket connection gracefully. + Close, +} + +/// A controllable WebSocket stub for testing MFA mobile-approve flows. +/// +/// Binds to a random port on localhost. The test connects to [`WebSocketStub::addr`], +/// then sends [`WsStubCommand`] values through [`WebSocketStub::tx`] to control +/// what frames the stub emits. +pub struct WebSocketStub { + pub addr: SocketAddr, + pub tx: UnboundedSender, +} + +/// Start a controllable WebSocket stub on a random localhost port. +/// +/// The returned [`WebSocketStub`] spawns a Tokio task that accepts exactly one +/// TCP connection and upgrades it to a WebSocket. After the upgrade, the task +/// waits for commands on the returned `tx` sender. +/// +/// # Panics +/// +/// Panics if the Tokio runtime is not available. +pub async fn start_ws_stub() -> WebSocketStub { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("Failed to bind WebSocket stub"); + let addr = listener.local_addr().expect("Failed to get local address"); + let (tx, mut rx) = unbounded_channel::(); + + tokio::spawn(async move { + // Accept a single connection. + let (stream, _peer) = match listener.accept().await { + Ok(conn) => conn, + Err(_) => return, + }; + + let ws_stream = match accept_async(stream).await { + Ok(ws) => ws, + Err(_) => return, + }; + + let (mut write, mut _read) = ws_stream.split(); + + while let Some(cmd) = rx.recv().await { + match cmd { + WsStubCommand::SendMessage(text) => { + let _ = write.send(Message::Text(text.into())).await; + } + WsStubCommand::Close => { + let _ = write.close().await; + return; + } + } + } + }); + + WebSocketStub { addr, tx } +} diff --git a/src-tauri/core/src/version.rs b/src-tauri/core/src/version.rs new file mode 100644 index 000000000..9ea0625ee --- /dev/null +++ b/src-tauri/core/src/version.rs @@ -0,0 +1,397 @@ +use std::{ + cmp::Ordering, + env, + fs::{create_dir_all, File, OpenOptions}, + path::{Path, PathBuf}, +}; + +pub use semver::Version; +use serde::{Deserialize, Serialize}; + +#[cfg(unix)] +use crate::set_perms; + +pub const MIN_CORE_VERSION: Version = Version::new(1, 6, 0); +pub const MIN_PROXY_VERSION: Version = Version::new(1, 6, 0); +pub const CLIENT_VERSION_HEADER: &str = "defguard-client-version"; +pub const CLIENT_PLATFORM_HEADER: &str = "defguard-client-platform"; +pub const LOG_FILENAME: &str = "defguard-client"; +pub const WELCOME_FORCE_ENV_VAR: &str = "DEFGUARD_CLIENT_WELCOME_FORCE"; +pub const WELCOME_SKIP_ENV_VAR: &str = "DEFGUARD_CLIENT_WELCOME_SKIP"; +pub const WELCOME_CONTENT_VERSION: Version = Version::new(2, 1, 0); +pub use defguard_client_common::VERSION as PKG_VERSION; + +/// Selects the version string the client should report: the build-version override when present +/// and non-blank, otherwise the package version. +#[must_use] +pub fn select_reported_app_version( + package_version: &str, + build_version_override: Option<&str>, +) -> String { + build_version_override + .filter(|version| !version.trim().is_empty()) + .map_or_else(|| package_version.to_owned(), str::to_owned) +} + +static VERSION_STATE_FILE_NAME: &str = "version.json"; + +fn get_version_state_file_path(config_dir: &Path) -> PathBuf { + let mut path = config_dir.to_path_buf(); + if !path.exists() { + create_dir_all(&path).expect("Failed to create missing app data dir"); + } + #[cfg(unix)] + set_perms(&path); + path.push(VERSION_STATE_FILE_NAME); + #[cfg(unix)] + set_perms(&path); + path +} + +fn get_version_state_file(config_dir: &Path, for_write: bool) -> File { + let path = get_version_state_file_path(config_dir); + OpenOptions::new() + .create(true) + .read(true) + .truncate(for_write) + .write(true) + .open(path) + .expect("Failed to create and open version state file.") +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct VersionState { + version: Version, + #[serde(default)] + welcome_shown: Option, +} + +impl VersionState { + fn save(&self, config_dir: &Path) { + let file = get_version_state_file(config_dir, true); + match serde_json::to_writer(file, &self) { + Ok(()) => debug!("Version state file has been saved."), + Err(err) => error!("Version state file couldn't be saved. Failed to serialize: {err}"), + } + } +} + +/// Result of comparing the last known app version (persisted on disk) against the currently +/// running version. +#[derive(Clone, Debug, PartialEq)] +pub enum VersionCheckResult { + /// No version state file existed on disk yet (fresh install, or first run of this check). + Init, + /// Stored version matches the current version. Also returned for a downgrade (current + /// version lower than the stored one) — the file is left untouched in that case so the + /// highest version ever seen isn't lost. + Unchanged, + /// Stored version is lower than the current version. + Upgraded { previous: Version, current: Version }, +} + +fn welcome_force_enabled() -> bool { + env::var(WELCOME_FORCE_ENV_VAR).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) +} + +fn welcome_skip_enabled() -> bool { + env::var(WELCOME_SKIP_ENV_VAR).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) +} + +/// Checks the last known app version (persisted in `config_dir`) against `current_version`, +/// updating the on-disk state as needed. +/// +/// Meant to be called exactly once, synchronously, during app setup. +#[must_use] +pub fn check_app_version(config_dir: &Path, current_version: &Version) -> VersionCheckResult { + if welcome_skip_enabled() { + return VersionCheckResult::Unchanged; + } + + if welcome_force_enabled() { + return VersionCheckResult::Upgraded { + previous: current_version.clone(), + current: current_version.clone(), + }; + } + + let path = get_version_state_file_path(config_dir); + if !path.exists() { + VersionState { + version: current_version.clone(), + welcome_shown: None, + } + .save(config_dir); + return VersionCheckResult::Init; + } + + let file = get_version_state_file(config_dir, false); + match serde_json::from_reader::<_, VersionState>(file) { + Ok(state) => match state.version.cmp(current_version) { + Ordering::Equal | Ordering::Greater => VersionCheckResult::Unchanged, + Ordering::Less => { + let previous = state.version; + VersionState { + version: current_version.clone(), + welcome_shown: state.welcome_shown, + } + .save(config_dir); + VersionCheckResult::Upgraded { + previous, + current: current_version.clone(), + } + } + }, + Err(err) => { + error!("Failed to deserialize version state file: {err}. Treating as first run."); + VersionState { + version: current_version.clone(), + welcome_shown: None, + } + .save(config_dir); + VersionCheckResult::Init + } + } +} + +fn read_version_state(config_dir: &Path) -> Option { + let path = get_version_state_file_path(config_dir); + if !path.exists() { + return None; + } + let file = get_version_state_file(config_dir, false); + serde_json::from_reader::<_, VersionState>(file).ok() +} + +#[must_use] +pub fn should_show_welcome(config_dir: &Path) -> bool { + if welcome_skip_enabled() { + return false; + } + + if welcome_force_enabled() { + return true; + } + + read_version_state(config_dir) + .and_then(|state| state.welcome_shown) + .is_none_or(|shown| shown < WELCOME_CONTENT_VERSION) +} + +pub fn mark_welcome_shown(config_dir: &Path, current_version: &Version) { + let mut state = read_version_state(config_dir).unwrap_or(VersionState { + version: current_version.clone(), + welcome_shown: None, + }); + state.welcome_shown = Some(WELCOME_CONTENT_VERSION); + state.save(config_dir); +} + +#[cfg(test)] +mod tests { + use std::{env, fs}; + + use tempfile::tempdir; + + use super::{ + check_app_version, mark_welcome_shown, select_reported_app_version, should_show_welcome, + Version, VersionCheckResult, VERSION_STATE_FILE_NAME, WELCOME_FORCE_ENV_VAR, + WELCOME_SKIP_ENV_VAR, + }; + + #[test] + fn test_should_show_welcome_when_state_file_missing() { + let dir = tempdir().unwrap(); + + assert!(should_show_welcome(dir.path())); + } + + #[test] + fn test_should_show_welcome_when_never_marked() { + let dir = tempdir().unwrap(); + let _ = check_app_version(dir.path(), &Version::new(2, 1, 0)); + + assert!(should_show_welcome(dir.path())); + } + + #[test] + fn test_should_not_show_welcome_after_marking() { + let dir = tempdir().unwrap(); + let current = Version::new(2, 1, 0); + let _ = check_app_version(dir.path(), ¤t); + + mark_welcome_shown(dir.path(), ¤t); + + assert!(!should_show_welcome(dir.path())); + } + + #[test] + fn test_should_show_welcome_when_marked_below_content_version() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join(VERSION_STATE_FILE_NAME), + br#"{"version":"2.1.0","welcome_shown":"2.0.0"}"#, + ) + .unwrap(); + + assert!(should_show_welcome(dir.path())); + } + + #[test] + fn test_check_app_version_preserves_welcome_shown_on_upgrade() { + let dir = tempdir().unwrap(); + let previous = Version::new(2, 1, 0); + let _ = check_app_version(dir.path(), &previous); + mark_welcome_shown(dir.path(), &previous); + + let current = Version::new(2, 1, 1); + let result = check_app_version(dir.path(), ¤t); + + assert_eq!(result, VersionCheckResult::Upgraded { previous, current }); + assert!(!should_show_welcome(dir.path())); + } + + #[test] + fn test_reported_app_version_uses_override_when_present() { + assert_eq!( + select_reported_app_version("1.6.8", Some("1.6.8-beta1")), + "1.6.8-beta1" + ); + } + + #[test] + fn test_reported_app_version_falls_back_to_package_version_without_override() { + assert_eq!(select_reported_app_version("1.6.8", None), "1.6.8"); + } + + #[test] + fn test_reported_app_version_ignores_empty_override() { + assert_eq!(select_reported_app_version("1.6.8", Some(" ")), "1.6.8"); + } + + #[test] + fn test_check_app_version_init_when_missing() { + let dir = tempdir().unwrap(); + let current = Version::new(1, 2, 0); + + let result = check_app_version(dir.path(), ¤t); + + assert_eq!(result, VersionCheckResult::Init); + assert!(dir.path().join(VERSION_STATE_FILE_NAME).exists()); + } + + #[test] + fn test_check_app_version_unchanged_when_same() { + let dir = tempdir().unwrap(); + let current = Version::new(1, 2, 0); + let _ = check_app_version(dir.path(), ¤t); + + let result = check_app_version(dir.path(), ¤t); + + assert_eq!(result, VersionCheckResult::Unchanged); + } + + #[test] + fn test_check_app_version_upgraded_when_current_is_newer() { + let dir = tempdir().unwrap(); + let previous = Version::new(1, 2, 0); + let _ = check_app_version(dir.path(), &previous); + + let current = Version::new(1, 3, 0); + let result = check_app_version(dir.path(), ¤t); + + assert_eq!( + result, + VersionCheckResult::Upgraded { + previous: previous.clone(), + current: current.clone(), + } + ); + + // File should now reflect the new version. + let result = check_app_version(dir.path(), ¤t); + assert_eq!(result, VersionCheckResult::Unchanged); + } + + #[test] + fn test_check_app_version_unchanged_on_downgrade() { + let dir = tempdir().unwrap(); + let previous = Version::new(1, 3, 0); + let _ = check_app_version(dir.path(), &previous); + + let older = Version::new(1, 2, 0); + let result = check_app_version(dir.path(), &older); + + assert_eq!(result, VersionCheckResult::Unchanged); + + // File should still hold the higher version, not the downgrade. + let contents = fs::read_to_string(dir.path().join(VERSION_STATE_FILE_NAME)).unwrap(); + assert!(contents.contains("1.3.0")); + } + + #[test] + fn test_check_app_version_corrupt_file_falls_back_to_init() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join(VERSION_STATE_FILE_NAME), + b"{ not valid json", + ) + .unwrap(); + + let current = Version::new(1, 2, 0); + let result = check_app_version(dir.path(), ¤t); + + assert_eq!(result, VersionCheckResult::Init); + } + + #[test] + fn test_check_app_version_force_upgraded_via_env_var() { + let dir = tempdir().unwrap(); + let current = Version::new(1, 2, 0); + let _ = check_app_version(dir.path(), ¤t); + + for value in ["1", "true", "TRUE"] { + env::set_var(WELCOME_FORCE_ENV_VAR, value); + let result = check_app_version(dir.path(), ¤t); + env::remove_var(WELCOME_FORCE_ENV_VAR); + + assert_eq!( + result, + VersionCheckResult::Upgraded { + previous: current.clone(), + current: current.clone(), + } + ); + } + + // Flag unset: normal behavior resumes. + let result = check_app_version(dir.path(), ¤t); + assert_eq!(result, VersionCheckResult::Unchanged); + } + + #[test] + fn test_check_app_version_skip_via_env_var() { + let dir = tempdir().unwrap(); + let previous = Version::new(1, 2, 0); + let _ = check_app_version(dir.path(), &previous); + + let current = Version::new(1, 3, 0); + for value in ["1", "true", "TRUE"] { + env::set_var(WELCOME_SKIP_ENV_VAR, value); + let result = check_app_version(dir.path(), ¤t); + env::remove_var(WELCOME_SKIP_ENV_VAR); + + assert_eq!(result, VersionCheckResult::Unchanged); + } + + // Flag unset: normal behavior resumes, upgrade is detected. + let result = check_app_version(dir.path(), ¤t); + assert_eq!( + result, + VersionCheckResult::Upgraded { + previous: previous.clone(), + current: current.clone(), + } + ); + } +} diff --git a/src-tauri/src/wg_config.rs b/src-tauri/core/src/wg_config.rs similarity index 100% rename from src-tauri/src/wg_config.rs rename to src-tauri/core/src/wg_config.rs diff --git a/src-tauri/daemon/Cargo.toml b/src-tauri/daemon/Cargo.toml new file mode 100644 index 000000000..20bd6f5d7 --- /dev/null +++ b/src-tauri/daemon/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "defguard-client-service" +description = "Defguard client daemon service" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license-file.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +anyhow = "1.0" +clap.workspace = true +defguard-client-common = { path = "../common" } +defguard-client-proto = { path = "../client-proto" } +defguard-client-posture = { path = "../enterprise/posture" } +defguard-client-service-locations = { path = "../enterprise/service-locations" } +defguard_wireguard_rs.workspace = true +log.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio = { version = "1", features = ["net", "rt-multi-thread", "signal", "sync", "time"] } +tokio-stream = { version = "0.1", features = ["net"] } +tonic.workspace = true +tracing.workspace = true +tracing-appender = "0.2" +tracing-subscriber = { workspace = true } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31", features = ["fs", "user"] } + +[target.'cfg(windows)'.dependencies] +async-stream = "0.3" +futures-core = "0.3" +tokio = { version = "1", features = ["net", "rt-multi-thread", "signal", "sync", "time"] } +windows-core = "0.62" +windows-service = "0.8" +windows-sys = "0.61" + +[[bin]] +name = "defguard-service" +path = "src/bin/defguard-service.rs" diff --git a/src-tauri/daemon/src/bin/defguard-service.rs b/src-tauri/daemon/src/bin/defguard-service.rs new file mode 100644 index 000000000..8980d63c0 --- /dev/null +++ b/src-tauri/daemon/src/bin/defguard-service.rs @@ -0,0 +1,32 @@ +//! Defguard interface management daemon +//! +//! This binary is meant to run as a daemon with root privileges +//! and communicate with the desktop client over HTTP. + +#[cfg(not(windows))] +#[tokio::main] +async fn main() -> anyhow::Result<()> { + use clap::Parser; + use defguard_client_service::{config::Config, daemon::run_server, utils::logging_setup}; + + // Handle --version / -V before clap parsing. + defguard_client_service::check_version_flag("defguard-service"); + + // parse config + let config: Config = Config::parse(); + let _guard = logging_setup(&config.log_dir, &config.log_level, config.log_max_files)?; + + // run gRPC server + run_server(config).await?; + + Ok(()) +} + +#[cfg(windows)] +fn main() -> windows_service::Result<()> { + // clap's Config::parse() runs inside service_main which only fires under SCM. + // Handle --version / -V directly when invoked from a terminal. + defguard_client_service::check_version_flag("defguard-service"); + + defguard_client_service::windows::run() +} diff --git a/src-tauri/daemon/src/config.rs b/src-tauri/daemon/src/config.rs new file mode 100644 index 000000000..427eb7f8f --- /dev/null +++ b/src-tauri/daemon/src/config.rs @@ -0,0 +1,27 @@ +use clap::Parser; + +#[cfg(windows)] +pub const DEFAULT_LOG_DIR: &str = "/Logs/defguard-service"; +#[cfg(not(windows))] +pub const DEFAULT_LOG_DIR: &str = "/var/log/defguard-service"; + +#[derive(Debug, Parser, Clone)] +#[clap(about = "Defguard VPN client interface management service")] +#[command(name = "defguard-service", version)] +pub struct Config { + /// Configures log level of defguard service logs + #[arg(long, env = "DEFGUARD_LOG_LEVEL", default_value = "info")] + pub log_level: String, + + /// Configures logging directory; it is meant for debugging only, so hide it. + #[arg(long, env = "DEFGUARD_LOG_DIR", default_value = DEFAULT_LOG_DIR, hide = true)] + pub log_dir: String, + + /// Configures maximum number of service log files to keep. Set to 0 to disable cleanup. + #[arg(long, env = "DEFGUARD_LOG_MAX_FILES", default_value_t = 8)] + pub log_max_files: usize, + + /// Defines how often (in seconds) interface statistics are sent to defguard client + #[arg(long, short = 'p', env = "DEFGUARD_STATS_PERIOD", default_value = "10")] + pub stats_period: u64, +} diff --git a/src-tauri/daemon/src/daemon.rs b/src-tauri/daemon/src/daemon.rs new file mode 100644 index 000000000..3b9622758 --- /dev/null +++ b/src-tauri/daemon/src/daemon.rs @@ -0,0 +1,685 @@ +#[cfg(all(unix, not(target_os = "macos")))] +use std::os::unix::fs::PermissionsExt; +use std::{ + collections::HashMap, + pin::Pin, + sync::{Arc, Mutex, RwLock}, + time::{Duration, SystemTime}, +}; +#[cfg(unix)] +use std::{fs, path::Path}; + +use defguard_client_common::dns_borrow; +#[cfg(windows)] +use defguard_client_posture::inspector::{device_posture_data, DiskEncryptionTarget}; +use defguard_client_proto::{ + conversions::normalize_allowed_ips, + defguard::{ + client::v1::{ + desktop_daemon_service_server::{DesktopDaemonService, DesktopDaemonServiceServer}, + CreateInterfaceRequest, DeleteServiceLocationsRequest, InterfaceData, + ListInterfacesResponse, ManagedInterfaceData, ReadInterfaceDataRequest, + RemoveInterfaceRequest, SaveServiceLocationsRequest, + }, + enterprise::posture::v2::DevicePostureData, + }, +}; +#[cfg(target_os = "linux")] +use defguard_client_service_locations::reconciler::{run_reconciler, ReconcileSignal}; +use defguard_client_service_locations::ServiceLocationError; +#[cfg(any(windows, target_os = "linux"))] +use defguard_client_service_locations::{validate_instance_id, ServiceLocationManager}; +#[cfg(not(target_os = "macos"))] +use defguard_wireguard_rs::Kernel; +#[cfg(target_os = "macos")] +use defguard_wireguard_rs::Userspace; +use defguard_wireguard_rs::{ + error::WireguardInterfaceError, InterfaceConfiguration, WGApi, WireguardInterfaceApi, +}; +#[cfg(target_os = "linux")] +use nix::unistd::{chown, Group}; +#[cfg(unix)] +use tokio::net::UnixListener; +use tokio::{sync::mpsc, task::JoinHandle, time::interval}; +#[cfg(unix)] +use tokio_stream::wrappers::UnixListenerStream; +use tonic::{ + codegen::tokio_stream::{wrappers::ReceiverStream, Stream}, + transport::Server, + Code, Response, Status, +}; +#[cfg(not(windows))] +use tracing::warn; +use tracing::{debug, error, info, info_span, Instrument}; + +#[cfg(windows)] +use crate::named_pipe::{get_named_pipe_server_stream, PIPE_NAME}; +use crate::{config::Config, VERSION}; + +#[cfg(unix)] +pub(super) const DAEMON_SOCKET_PATH: &str = "/var/run/defguard.socket"; + +#[cfg(target_os = "linux")] +pub(super) const DAEMON_SOCKET_GROUP: &str = "defguard"; + +/// How often the reconciler brings running tunnels back in line with what is on disk. +/// +/// On Windows this is a backstop, since the watchers wake it on network, logon and resume events. +/// On Linux nothing wakes it, so this is the only trigger and sets the worst-case recovery time. +#[cfg(any(windows, target_os = "linux"))] +pub(crate) const SERVICE_LOCATION_RECONCILE_INTERVAL: Duration = Duration::from_secs(30); + +#[derive(Debug, thiserror::Error)] +pub enum DaemonError { + #[error(transparent)] + WireguardError(#[from] WireguardInterfaceError), + #[error("Unexpected error: {0}")] + Unexpected(String), + #[error(transparent)] + TransportError(#[from] tonic::transport::Error), + #[error(transparent)] + ServiceLocationError(#[from] ServiceLocationError), + #[cfg(windows)] + #[error(transparent)] + WindowsServiceError(#[from] windows_service::Error), + #[cfg(windows)] + #[error(transparent)] + LogSetupError(#[from] crate::utils::LoggingSetupError), +} + +type IfName = String; +#[cfg(not(target_os = "macos"))] +type WG = WGApi; +#[cfg(target_os = "macos")] +type WG = WGApi; + +#[derive(Default)] +pub(crate) struct DaemonService { + // Map of running `WGApi`s; key is interface name. + wgapis: Arc>>, + stats_period: Duration, + stat_tasks: Arc>>>, + #[cfg(any(windows, target_os = "linux"))] + service_location_manager: Arc>, +} + +impl DaemonService { + #[must_use] + pub fn new( + config: &Config, + #[cfg(any(windows, target_os = "linux"))] service_location_manager: Arc< + RwLock, + >, + ) -> Self { + Self { + wgapis: Arc::new(RwLock::new(HashMap::new())), + stats_period: Duration::from_secs(config.stats_period), + stat_tasks: Arc::new(Mutex::new(HashMap::new())), + #[cfg(any(windows, target_os = "linux"))] + service_location_manager, + } + } +} + +/// Helper function used to perform required configuration steps for a new interface. +/// +/// This allows us to roll back interface creation if some configuration step fails. +fn configure_new_interface( + ifname: &str, + request: &CreateInterfaceRequest, + wgapi: &mut WG, + interface_config: &mut InterfaceConfiguration, +) -> Result<(), Status> { + normalize_allowed_ips(interface_config); + + // The WireGuard DNS config value can be a list of IP addresses and domain names, which will + // be used as DNS servers and search domains respectively. + debug!("Preparing DNS configuration for interface {ifname}"); + let (dns, search_domains) = dns_borrow(&request.dns); + debug!( + "DNS configuration for interface {ifname}: DNS: {dns:?}, Search domains: \ + {search_domains:?}" + ); + + let configure_interface_result = wgapi.configure_interface(interface_config); + + configure_interface_result.map_err(|err| { + let msg = format!("Failed to configure WireGuard interface {ifname}: {err}"); + error!("{msg}"); + Status::new(Code::Internal, msg) + })?; + + #[cfg(not(windows))] + { + debug!("Configuring interface {ifname} routing"); + wgapi + .configure_peer_routing(&interface_config.peers) + .map_err(|err| { + let msg = + format!("Failed to configure routing for WireGuard interface {ifname}: {err}"); + error!("{msg}"); + Status::new(Code::Internal, msg) + })?; + } + if dns.is_empty() { + debug!( + "No DNS configuration provided for interface {ifname}, skipping DNS \ + configuration" + ); + } else { + debug!( + "The following DNS servers will be set: {dns:?}, search domains: \ + {search_domains:?}" + ); + wgapi.configure_dns(&dns, &search_domains).map_err(|err| { + let msg = format!("Failed to configure DNS for WireGuard interface {ifname}: {err}"); + error!("{msg}"); + Status::new(Code::Internal, msg) + })?; + } + + Ok(()) +} + +type InterfaceDataStream = Pin> + Send>>; + +pub(crate) fn setup_wgapi(ifname: &str) -> Result { + let wgapi = WG::new(ifname).map_err(|err| { + let msg = format!("Failed to setup WireGuard API for interface {ifname}: {err}"); + error!("{msg}"); + Status::new(Code::Internal, msg) + })?; + + Ok(wgapi) +} + +#[tonic::async_trait] +impl DesktopDaemonService for DaemonService { + type ReadInterfaceDataStream = InterfaceDataStream; + + #[cfg(not(any(windows, target_os = "linux")))] + async fn save_service_locations( + &self, + _request: tonic::Request, + ) -> Result, Status> { + debug!( + "Save service location request received, this is currently not supported on Unix \ + systems" + ); + Ok(Response::new(())) + } + + #[cfg(not(any(windows, target_os = "linux")))] + async fn delete_service_locations( + &self, + _request: tonic::Request, + ) -> Result, Status> { + debug!( + "Delete service location request received, this is currently not supported on Unix \ + systems" + ); + Ok(Response::new(())) + } + + #[cfg(any(windows, target_os = "linux"))] + async fn save_service_locations( + &self, + request: tonic::Request, + ) -> Result, Status> { + debug!("Received a request to save service locations"); + let mut service_location = request.into_inner(); + service_location.instance_id = validate_instance_id(&service_location.instance_id) + .map_err(|err| { + let msg = format!("Failed to save service locations: {err}"); + error!("{msg}"); + Status::invalid_argument(msg) + })?; + + self.service_location_manager + .write() + .unwrap() + .save_service_locations(&service_location) + .map_err(|err| { + let msg = format!("Failed to save service locations: {err}"); + error!(msg); + Status::internal(msg) + })?; + + debug!("Service locations saved successfully"); + Ok(Response::new(())) + } + + #[cfg(not(windows))] + async fn get_posture_data( + &self, + _request: tonic::Request<()>, + ) -> Result, Status> { + warn!( + "Daemon service received a get_posture_data request. Daemon posture requests are only \ + supported on windows systems. Unix systems perform client-side posture checks." + ); + Err(Status::unimplemented( + "Service-side posture checks are not supported on this platform", + )) + } + + #[cfg(any(windows, target_os = "linux"))] + async fn delete_service_locations( + &self, + request: tonic::Request, + ) -> Result, Status> { + debug!("Received a request to delete service locations"); + let instance_id = request.into_inner().instance_id; + let instance_id = validate_instance_id(&instance_id).map_err(|err| { + let msg = format!("Failed to delete service locations: {err}"); + error!("{msg}"); + Status::invalid_argument(msg) + })?; + + let mut manager = self.service_location_manager.write().unwrap(); + manager + .disconnect_service_locations_by_instance(&instance_id) + .map_err(|err| { + let msg = format!("Failed to disconnect service locations: {err}"); + error!(msg); + Status::internal(msg) + })?; + + manager + .delete_all_service_locations_for_instance(&instance_id) + .map_err(|err| { + let msg = format!("Failed to delete service locations: {err}"); + error!(msg); + Status::internal(msg) + })?; + + debug!("Service locations deleted successfully"); + Ok(Response::new(())) + } + + async fn create_interface( + &self, + request: tonic::Request, + ) -> Result, Status> { + debug!("Received a request to create a new interface"); + let request = request.into_inner(); + let mut config: InterfaceConfiguration = request + .config + .clone() + .ok_or(Status::new( + Code::InvalidArgument, + "Missing interface config in request", + ))? + .try_into() + .inspect_err(|err| error!("Invalid interface config in request: {err}"))?; + let ifname = config.name.clone(); + let _span = info_span!("create_interface", interface_name = &ifname).entered(); + // Setup WireGuard API. + let Ok(mut wgapis_map) = self.wgapis.write() else { + error!("Failed to acquire read-write lock for WGApis"); + return Err(Status::new(Code::Internal, "read-write lock error")); + }; + let wgapi = wgapis_map + .entry(ifname.clone()) + .or_insert(setup_wgapi(&ifname)?); + + // create new interface + debug!("Creating new interface {ifname}"); + wgapi.create_interface().map_err(|err| { + let msg = format!("Failed to create WireGuard interface {ifname}: {err}"); + error!("{msg}"); + Status::new(Code::Internal, msg) + })?; + info!("Done creating a new interface {ifname}"); + + // attempt to configure new interface + // remove interface if configuration fails to avoid duplicate interfaces + match configure_new_interface(&ifname, &request, wgapi, &mut config) { + Ok(()) => info!("Finished configuring new interface {ifname}"), + Err(err) => { + error!("Failed to configure interface {ifname}. Error: {err}"); + + debug!("Removing newly created interface {ifname} due to configuration failure"); + wgapi.remove_interface().map_err(|err| { + let msg = format!("Failed to remove WireGuard interface {ifname}: {err}"); + error!("{msg}"); + Status::new(Code::Internal, msg) + })?; + + return Err(err); + } + } + + debug!("Finished creating a new interface {ifname}"); + Ok(Response::new(())) + } + + async fn remove_interface( + &self, + request: tonic::Request, + ) -> Result, Status> { + debug!("Received a request to remove an interface"); + let request = request.into_inner(); + let ifname = request.interface_name; + let _span = info_span!("remove_interface", interface_name = &ifname).entered(); + debug!("Removing interface {ifname}"); + + // Stop stats task. + if let Ok(mut tasks) = self.stat_tasks.lock() { + if let Some(handle) = tasks.remove(&ifname) { + info!("Stopping statistics collector task for interface {ifname}"); + handle.abort(); + } + } + + // `WGApi::remove_interface`` takes `&mut self` under Windows. + #[allow(unused_mut)] + let mut wgapi = { + let Ok(mut wgapis_map) = self.wgapis.write() else { + error!("Failed to acquire read-write lock for WGApis"); + return Err(Status::new(Code::Internal, "read-write lock error")); + }; + let Some(wgapi) = wgapis_map.remove(&ifname) else { + error!("Unknown interface {ifname}"); + return Err(Status::new(Code::Internal, "unknown interface")); + }; + wgapi + }; + + #[cfg(not(windows))] + { + debug!("Cleaning up interface {ifname} routing"); + // Ignore error as this should not be considered fatal, + // e.g. endpoint might fail to resolve DNS name. + if let Err(err) = wgapi.remove_endpoint_routing(&request.endpoint) { + error!( + "Failed to remove routing for endpoint {}: {err}", + request.endpoint + ); + } + } + + wgapi.remove_interface().map_err(|err| { + let msg = format!("Failed to remove WireGuard interface {ifname}: {err}"); + error!("{msg}"); + Status::new(Code::Internal, msg) + })?; + + debug!("Finished removing interface {ifname}"); + Ok(Response::new(())) + } + + async fn read_interface_data( + &self, + request: tonic::Request, + ) -> Result, Status> { + let request = request.into_inner(); + let ifname = request.interface_name.clone(); + debug!( + "Received a request to start a new network usage stats data stream for interface \ + {ifname}" + ); + let span = info_span!("read_interface_data", interface_name = &ifname); + + let wgapis = Arc::clone(&self.wgapis); + let mut interval = interval(self.stats_period); + let (tx, rx) = mpsc::channel(64); + + span.in_scope(|| { + info!("Spawning statistics collector task for interface {ifname}"); + }); + let handle = tokio::spawn( + async move { + // Helper map to track if peer data is actually changing to avoid sending duplicate + // stats. + let mut peer_map = HashMap::new(); + + loop { + // Loop delay + interval.tick().await; + debug!( + "Gathering network usage statistics for client's network activity on {ifname}"); + let result = { + let Ok(wgapis_map) = wgapis.read() else { + error!("Failed to acquire read-write lock for WGApis"); + break; + }; + let Some(wgapi) = wgapis_map.get(&ifname) else { + error!("Unknown interface {ifname}"); + break; + }; + wgapi.read_interface_data() + }; + match result { + Ok(mut host) => { + let peers = &mut host.peers; + debug!( + "Found {} peers configured on WireGuard interface", + peers.len() + ); + // Filter out never connected peers. + peers.retain(|_, peer| { + // Last handshake time-stamp must exist. + if let Some(last_hs) = peer.last_handshake { + // ...and not be UNIX epoch. + if last_hs != SystemTime::UNIX_EPOCH + && match peer_map.get(&peer.public_key) { + Some(last_peer) => last_peer != peer, + None => true, + } + { + debug!( + "Peer {} statistics changed; keeping it.", + peer.public_key + ); + peer_map.insert(peer.public_key.clone(), peer.clone()); + return true; + } + } + debug!( + "Peer {} statistics didn't change; ignoring it.", + peer.public_key + ); + false + }); + if let Err(err) = tx.send(Ok(host.into())).await { + error!( + "Couldn't send network usage stats update for {ifname}: {err}" + ); + break; + } + } + Err(err) => { + error!( + "Failed to retrieve network usage stats for interface {ifname}: \ + {err}" + ); + break; + } + } + debug!("Network activity statistics for interface {ifname} sent to the client"); + } + debug!( + "The client has disconnected from the network usage statistics data stream \ + for interface {ifname}, stopping the statistics data collection task." + ); + } + .instrument(span), + ); + if let Ok(mut tasks) = self.stat_tasks.lock() { + tasks.insert(request.interface_name, handle); + } + + let output_stream = ReceiverStream::new(rx); + Ok(Response::new( + Box::pin(output_stream) as Self::ReadInterfaceDataStream + )) + } + + async fn list_interfaces( + &self, + _request: tonic::Request<()>, + ) -> Result, Status> { + debug!("Received ListInterfaces request"); + + // Collect interface names under a brief lock. + let ifnames = { + let Ok(wgapis_map) = self.wgapis.read() else { + error!("Failed to acquire read lock for WGApis"); + return Err(Status::new(Code::Internal, "read lock error")); + }; + wgapis_map.keys().cloned().collect::>() + }; + + // Read each interface's data, acquiring and releasing the lock per interface + // so that write operations (create/remove) can interleave. + let mut interfaces = Vec::with_capacity(ifnames.len()); + for ifname in &ifnames { + let data = { + let Ok(wgapis_map) = self.wgapis.read() else { + error!("Failed to acquire read lock for WGApis"); + return Err(Status::new(Code::Internal, "read lock error")); + }; + if let Some(wgapi) = wgapis_map.get(ifname) { + match wgapi.read_interface_data() { + Ok(host) => { + debug!("ListInterfaces: returning data for {ifname}"); + Some(host.into()) + } + Err(err) => { + error!("ListInterfaces: failed to read data for {ifname}: {err}"); + None + } + } + } else { + debug!("ListInterfaces: interface {ifname} removed since snapshot"); + None + } + }; + interfaces.push(ManagedInterfaceData { + interface_name: ifname.clone(), + data, + }); + } + debug!( + "ListInterfaces: returning {} managed interface(s)", + interfaces.len() + ); + Ok(Response::new(ListInterfacesResponse { interfaces })) + } + + /// Collects this device's posture data on the app's behalf. + #[cfg(windows)] + async fn get_posture_data( + &self, + _request: tonic::Request<()>, + ) -> Result, Status> { + debug!("Get posture data request received"); + Ok(Response::new(device_posture_data( + DiskEncryptionTarget::ClientDatabase, + ))) + } +} + +#[cfg(unix)] +pub async fn run_server(config: Config) -> anyhow::Result<()> { + debug!("Starting Defguard interface management daemon"); + + #[cfg(target_os = "linux")] + let service_location_manager = Arc::new(RwLock::new(ServiceLocationManager::init()?)); + // Nothing wakes the reconciler on Linux - there are no network, logon or resume watchers - so + // the tick is its only trigger. + #[cfg(target_os = "linux")] + let reconciler_handle = tokio::spawn(run_reconciler( + service_location_manager.clone(), + ReconcileSignal::default(), + SERVICE_LOCATION_RECONCILE_INTERVAL, + )); + + let daemon_service = DaemonService::new( + &config, + #[cfg(target_os = "linux")] + service_location_manager, + ); + + // Remove existing socket if it exists + if Path::new(DAEMON_SOCKET_PATH).exists() { + debug!("Removing existing socket file at {DAEMON_SOCKET_PATH}"); + fs::remove_file(DAEMON_SOCKET_PATH)?; + } + + debug!("Binding socket file at {DAEMON_SOCKET_PATH}"); + let uds = UnixListener::bind(DAEMON_SOCKET_PATH)?; + + #[cfg(target_os = "linux")] + { + // change owner group for socket file + // get the group ID by name + let group = Group::from_name(DAEMON_SOCKET_GROUP)?.ok_or_else(|| { + error!("Group '{DAEMON_SOCKET_GROUP}' not found"); + crate::Error::Internal(format!("Group '{DAEMON_SOCKET_GROUP}' not found")) + })?; + + // change ownership - keep current user, change group + debug!( + "Changing owner group of socket file at {DAEMON_SOCKET_PATH} to group \ + {DAEMON_SOCKET_GROUP}" + ); + chown(DAEMON_SOCKET_PATH, None, Some(group.gid))?; + + // Set socket permissions to allow client access + // 0o660 allows read/write for owner and group only + debug!("Setting permissions for socket file at {DAEMON_SOCKET_PATH} to 0x660"); + fs::set_permissions(DAEMON_SOCKET_PATH, fs::Permissions::from_mode(0o660))?; + } + + let uds_stream = UnixListenerStream::new(uds); + + info!("Defguard daemon version {VERSION} started, listening on socket {DAEMON_SOCKET_PATH}",); + debug!("Defguard daemon configuration: {config:?}"); + + let server = Server::builder() + .trace_fn(|_| tracing::info_span!("defguard_client_service")) + .add_service(DesktopDaemonServiceServer::new(daemon_service)) + .serve_with_incoming(uds_stream); + + #[cfg(target_os = "linux")] + tokio::select! { + result = server => result?, + result = reconciler_handle => { + let message = match result { + Ok(()) => "Service location reconciler ended unexpectedly".to_string(), + Err(err) => format!("Service location reconciler task failed: {err}"), + }; + error!("{message}"); + return Err(anyhow::anyhow!(message)); + } + } + + #[cfg(not(target_os = "linux"))] + server.await?; + + Ok(()) +} + +#[cfg(windows)] +pub(crate) async fn run_server( + config: Config, + service_location_manager: Arc>, +) -> anyhow::Result<()> { + debug!("Starting Defguard interface management daemon"); + + let stream = get_named_pipe_server_stream(); + let daemon_service = DaemonService::new(&config, service_location_manager); + + info!("Defguard daemon version {VERSION} started, listening on named pipe {PIPE_NAME}"); + debug!("Defguard daemon configuration: {config:?}"); + + Server::builder() + .trace_fn(|_| tracing::info_span!("defguard_client_service")) + .add_service(DesktopDaemonServiceServer::new(daemon_service)) + .serve_with_incoming(stream) + .await?; + + Ok(()) +} diff --git a/src-tauri/daemon/src/error.rs b/src-tauri/daemon/src/error.rs new file mode 100644 index 000000000..d1a1f11b6 --- /dev/null +++ b/src-tauri/daemon/src/error.rs @@ -0,0 +1,13 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("internal daemon error: {0}")] + Internal(String), + #[error("wireguard interface error: {0}")] + WireGuard(#[from] defguard_wireguard_rs::error::WireguardInterfaceError), + #[error("service location error: {0}")] + ServiceLocation(#[from] defguard_client_service_locations::ServiceLocationError), + #[error("conversion error: {0}")] + Conversion(String), + #[error("not found: {0}")] + NotFound(String), +} diff --git a/src-tauri/daemon/src/lib.rs b/src-tauri/daemon/src/lib.rs new file mode 100644 index 000000000..fca6123ae --- /dev/null +++ b/src-tauri/daemon/src/lib.rs @@ -0,0 +1,12 @@ +pub mod config; +pub mod daemon; +pub mod error; +pub mod utils; + +#[cfg(windows)] +pub mod named_pipe; +#[cfg(windows)] +pub mod windows; + +pub use defguard_client_common::{check_version_flag, version_string, VERSION}; +pub use error::Error; diff --git a/src-tauri/src/service/named_pipe.rs b/src-tauri/daemon/src/named_pipe.rs similarity index 96% rename from src-tauri/src/service/named_pipe.rs rename to src-tauri/daemon/src/named_pipe.rs index b3d9df738..90c2bd20e 100644 --- a/src-tauri/src/service/named_pipe.rs +++ b/src-tauri/daemon/src/named_pipe.rs @@ -11,6 +11,7 @@ use tokio::{ net::windows::named_pipe::NamedPipeServer, }; use tonic::transport::server::Connected; +use tracing::{debug, error, info}; use windows_sys::Win32::{ Foundation::{LocalFree, HANDLE, INVALID_HANDLE_VALUE}, Security::{ @@ -18,7 +19,7 @@ use windows_sys::Win32::{ PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, }, Storage::FileSystem::{FILE_FLAG_OVERLAPPED, PIPE_ACCESS_DUPLEX}, - System::Pipes::{CreateNamedPipeW, PIPE_TYPE_BYTE}, + System::Pipes::{CreateNamedPipeW, PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES}, }; // Named-pipe name used for IPC between defguard client and windows service. @@ -90,8 +91,8 @@ fn str_to_wide_null_terminated(s: &str) -> Vec { } /// Create a secure Windows named pipe handle with appropriate ACL. -/// Uses `FILE_FLAG_OVERLAPPED` for Tokio compatibility and sets `nMaxInstances = 2` -/// (one client + one service instance). +/// Uses `FILE_FLAG_OVERLAPPED` for Tokio compatibility and sets +/// `nMaxInstances = PIPE_UNLIMITED_INSTANCES` (255). fn create_secure_pipe() -> Result { debug!("Creating secure named pipe {PIPE_NAME}"); @@ -127,8 +128,7 @@ fn create_secure_pipe() -> Result { name_wide.as_ptr(), PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, PIPE_TYPE_BYTE, - // 1 client + 1 service - 2, + PIPE_UNLIMITED_INSTANCES, 65536, 65536, 0, diff --git a/src-tauri/daemon/src/utils.rs b/src-tauri/daemon/src/utils.rs new file mode 100644 index 000000000..4e8d8075e --- /dev/null +++ b/src-tauri/daemon/src/utils.rs @@ -0,0 +1,119 @@ +use std::{ + fs, + io::{self, stdout}, + path::Path, +}; + +use tracing::Level; +use tracing_appender::{ + non_blocking::WorkerGuard, + rolling::{InitError, RollingFileAppender, Rotation}, +}; +use tracing_subscriber::{ + fmt, fmt::writer::MakeWriterExt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, + Layer, +}; + +const SERVICE_LOG_PREFIX: &str = "defguard-service"; +const OLD_SERVICE_LOG_PREFIX: &str = "defguard-service.log."; + +#[derive(Debug, thiserror::Error)] +pub enum LoggingSetupError { + #[error("failed to migrate service log files: {0}")] + Migration(#[source] io::Error), + #[error("failed to initialize service log appender: {0}")] + Appender(#[from] InitError), +} + +pub fn logging_setup( + log_dir: &str, + log_level: &str, + log_max_files: usize, +) -> Result { + migrate_service_log_files(Path::new(log_dir)).map_err(LoggingSetupError::Migration)?; + + // prepare log file appender + let mut appender_builder = RollingFileAppender::builder() + .rotation(Rotation::DAILY) + .filename_prefix(SERVICE_LOG_PREFIX) + .filename_suffix("log"); + if log_max_files > 0 { + appender_builder = appender_builder.max_log_files(log_max_files); + } + let file_appender = appender_builder.build(log_dir)?; + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + // prepare log level filter for stdout + let stdout_filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| format!("{log_level},hyper=info,h2=info").into()); + + // prepare log level filter for JSON file + let json_filter = EnvFilter::new("DEBUG,hyper=info,h2=info"); + + // prepare tracing layers + let stdout_layer = fmt::layer() + .pretty() + .with_writer(stdout.with_max_level(Level::DEBUG)) + .with_filter(stdout_filter); + let json_file_layer = fmt::layer() + .json() + .with_writer(non_blocking.with_max_level(Level::DEBUG)) + .with_filter(json_filter); + + // initialize tracing subscriber + tracing_subscriber::registry() + .with(stdout_layer) + .with(json_file_layer) + .init(); + + Ok(guard) +} + +fn migrate_service_log_files(log_dir: &Path) -> io::Result<()> { + let entries = match fs::read_dir(log_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + + let filename = entry.file_name(); + let Some(filename) = filename.to_str() else { + continue; + }; + let Some(date) = filename.strip_prefix(OLD_SERVICE_LOG_PREFIX) else { + continue; + }; + if !is_log_date(date) { + continue; + } + + let new_path = log_dir.join(format!("{SERVICE_LOG_PREFIX}.{date}.log")); + if new_path.exists() { + eprintln!( + "Skipping service log migration because destination already exists: {}", + new_path.display() + ); + continue; + } + + fs::rename(entry.path(), new_path)?; + } + + Ok(()) +} + +fn is_log_date(value: &str) -> bool { + value.len() == 10 + && value.as_bytes()[4] == b'-' + && value.as_bytes()[7] == b'-' + && value + .bytes() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) +} diff --git a/src-tauri/daemon/src/windows.rs b/src-tauri/daemon/src/windows.rs new file mode 100644 index 000000000..020c275c0 --- /dev/null +++ b/src-tauri/daemon/src/windows.rs @@ -0,0 +1,218 @@ +use std::{ + ffi::OsString, + result::Result, + sync::{mpsc, Arc, RwLock}, + time::Duration, +}; + +use clap::Parser; +use defguard_client_service_locations::{ + reconciler::{run_reconciler, ReconcileSignal}, + windows::{watch_for_login_logoff, watch_for_network_change}, + ServiceLocationError, ServiceLocationManager, +}; +use tokio::runtime::Runtime; +use tracing::{debug, error, info, warn}; +use windows_service::{ + define_windows_service, + service::{ + PowerEventParam, ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, + ServiceStatus, ServiceType, + }, + service_control_handler::{register, ServiceControlHandlerResult}, + service_dispatcher, +}; + +use crate::{ + config::Config, + daemon::{run_server, DaemonError, SERVICE_LOCATION_RECONCILE_INTERVAL}, + utils::logging_setup, +}; + +static SERVICE_NAME: &str = "DefguardService"; +const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; + +pub fn run() -> Result<(), windows_service::Error> { + // Register generated `ffi_service_main` with the system and start the service, blocking + // this thread until the service is stopped. + service_dispatcher::start(SERVICE_NAME, ffi_service_main) +} + +define_windows_service!(ffi_service_main, service_main); + +pub fn service_main(_arguments: Vec) { + if let Err(err) = run_service() { + error!("Error while running the service. {err}"); + panic!("{err}"); + } +} + +fn run_service() -> Result<(), DaemonError> { + // Create a channel to be able to poll a stop event from the service worker loop. + let (shutdown_tx, shutdown_rx) = mpsc::channel::(); + let shutdown_tx_server = shutdown_tx.clone(); + let shutdown_tx_reconciler = shutdown_tx.clone(); + + // One signal, shared by everything that can notice wake/suspend etc. events. + let wake_reconciler = ReconcileSignal::default(); + let wake_on_power_event = wake_reconciler.clone(); + + // Define system service event handler that will be receiving service events. + let event_handler = move |control_event| -> ServiceControlHandlerResult { + match control_event { + // Notifies a service to report its current status information to the service + // control manager. Always return NoError even if not implemented. + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + + // Handle stop + ServiceControl::Stop => { + let _ = shutdown_tx.send(1); + ServiceControlHandlerResult::NoError + } + + // Resuming from sleep leaves tunnels that were established before the suspend looking + // alive but no longer passing traffic, so wake the reconciler rather than waiting up + // to a full tick. + ServiceControl::PowerEvent(param) => { + debug!("Received power event: {param:?}"); + if matches!( + param, + PowerEventParam::ResumeAutomatic | PowerEventParam::ResumeSuspend + ) { + info!("Resumed from sleep, waking the service location reconciler"); + wake_on_power_event.notify_one(); + } + ServiceControlHandlerResult::NoError + } + + _ => ServiceControlHandlerResult::NotImplemented, + } + }; + + // Register system service event handler. + // The returned status handle should be used to report service status changes to the system. + let status_handle = register(SERVICE_NAME, event_handler)?; + + let rt = Runtime::new(); + + if let Ok(runtime) = rt { + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::POWER_EVENT, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + let config: Config = Config::parse(); + let _guard = logging_setup(&config.log_dir, &config.log_level, config.log_max_files)?; + + let default_panic = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + default_panic(info); + std::process::exit(1); + })); + + let service_location_manager = match ServiceLocationManager::init() { + Ok(api) => { + info!("Service locations storage initialized successfully"); + Ok(api) + } + Err(err) => { + error!( + "Failed to initialize service locations storage: {err}. Shutting down service \ + location thread" + ); + Err(ServiceLocationError::InitError(err.to_string())) + } + }?; + + let service_location_manager = Arc::new(RwLock::new(service_location_manager)); + + // Spawn network change monitoring on a dedicated OS thread so the blocking + // NotifyAddrChange syscall does not stall Tokio's async worker threads. + // Register it first so no network event can be missed before the watcher is listening; + // the retry loop below is the backstop for any event that slips through the startup window. + let wake = wake_reconciler.clone(); + std::thread::Builder::new() + .name("network-change-monitor".to_string()) + .spawn(move || { + info!("Starting network change monitoring"); + watch_for_network_change(wake); + error!("Network change monitoring ended unexpectedly."); + }) + .expect("Failed to spawn network change monitor thread"); + + // Spawn the reconciler. Each pass leaves already-correct locations alone, so waking it is + // always safe. Its tick covers startup before the network is ready - typically DNS not yet + // resolving - and backstops any event the watchers miss. + let reconciler_handle = runtime.spawn(run_reconciler( + service_location_manager.clone(), + wake_reconciler.clone(), + SERVICE_LOCATION_RECONCILE_INTERVAL, + )); + runtime.spawn(async move { + match reconciler_handle.await { + Ok(()) => error!("Service location reconciler ended unexpectedly"), + Err(err) => error!("Service location reconciler task failed: {err}"), + } + let _ = shutdown_tx_reconciler.send(2); + }); + + // Spawn login/logoff monitoring on a dedicated OS thread so the blocking + // WTSWaitSystemEvent syscall does not stall Tokio's async worker threads. + let wake = wake_reconciler.clone(); + std::thread::Builder::new() + .name("login-logoff-monitor".to_string()) + .spawn(move || { + info!("Starting login/logoff event monitoring"); + watch_for_login_logoff(&wake); + }) + .expect("Failed to spawn login/logoff monitor thread"); + + // Spawn the main gRPC server task + let service_location_manager_clone = service_location_manager.clone(); + runtime.spawn(async move { + let result = run_server(config, service_location_manager_clone).await; + + let signal = if result.is_err() { + error!("Server task ended with error: {:?}", result.err()); + 2 + } else { + warn!("Server task ended without an error."); + 1 + }; + + let _ = shutdown_tx_server.send(signal); + }); + + loop { + // Poll shutdown event. + match shutdown_rx.recv_timeout(Duration::from_secs(1)) { + // Break the loop either upon stop or channel disconnect + Ok(1) | Err(mpsc::RecvTimeoutError::Disconnected) => break, + Ok(2) => { + panic!("Server has stopped working.") + } + Ok(_) => break, + + // Continue work if no events were received within the timeout + Err(mpsc::RecvTimeoutError::Timeout) => (), + } + } + + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + } + + Ok(()) +} diff --git a/src-tauri/deny.toml b/src-tauri/deny.toml index fa04523fd..4dea52818 100644 --- a/src-tauri/deny.toml +++ b/src-tauri/deny.toml @@ -70,8 +70,9 @@ feature-depth = 1 # A list of advisory IDs to ignore. Note that ignored advisories will still # output a note when they are encountered. ignore = [ - { id = "RUSTSEC-2024-0429", reason = "https://github.com/tauri-apps/tauri/issues/12048" }, { id = "RUSTSEC-2024-0436", reason = "Unmaintained netlink dependency" }, + { id = "RUSTSEC-2026-0194", reason = "quick-xml <0.41 pulled by Tauri/plist; no compatible upstream update yet" }, + { id = "RUSTSEC-2026-0195", reason = "quick-xml <0.41 pulled by Tauri/plist; no compatible upstream update yet" }, # The "unmaintained" advisories below stem from our use of Tauri v2, which # depends on GTK3 bindings that are no longer maintained. { id = "RUSTSEC-2024-0370", reason = "Tauri v2 GTK3 dependency (unmaintained)" }, @@ -86,12 +87,13 @@ ignore = [ { id = "RUSTSEC-2024-0419", reason = "Tauri v2 GTK3 dependency (unmaintained)" }, { id = "RUSTSEC-2024-0420", reason = "Tauri v2 GTK3 dependency (unmaintained)" }, { id = "RUSTSEC-2025-0052", reason = "Discontinued, but dark-light v2.0.0 needs it" }, - { id = "RUSTSEC-2025-0057", reason = "Tauri needs it" }, { id = "RUSTSEC-2025-0075", reason = "Tauri v2 dependency (unmaintained)" }, { id = "RUSTSEC-2025-0080", reason = "Tauri v2 dependency (unmaintained)" }, { id = "RUSTSEC-2025-0081", reason = "Tauri v2 dependency (unmaintained)" }, { id = "RUSTSEC-2025-0098", reason = "Tauri v2 dependency (unmaintained)" }, { id = "RUSTSEC-2025-0100", reason = "Tauri v2 dependency (unmaintained)" }, + { id = "RUSTSEC-2026-0194", reason = "Tauri v2 dependency (unmaintained)" }, + { id = "RUSTSEC-2026-0195", reason = "Tauri v2 dependency (unmaintained)" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. @@ -107,21 +109,22 @@ ignore = [ # See https://spdx.org/licenses/ for list of possible licenses # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. allow = [ - "MIT", - "Apache-2.0", + "0BSD", "Apache-2.0 WITH LLVM-exception", - "MPL-2.0", + "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", - "Unicode-3.0", - "Unicode-DFS-2016", # unicode-ident - "Zlib", - "ISC", "BSL-1.0", - "0BSD", "CC0-1.0", - "OpenSSL", "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "NCSA", + "OpenSSL", + "Unicode-3.0", + "Unicode-DFS-2016", # unicode-ident + "Zlib", ] # The confidence threshold for detecting a license from license text. # The higher the value, the more closely the license text must be to the @@ -133,10 +136,37 @@ confidence-threshold = 0.8 exceptions = [ { allow = [ "AGPL-3.0-or-later", - ], crate = "common" }, + ], crate = "defguard-cli" }, + { allow = [ + "AGPL-3.0-or-later", + ], crate = "defguard-client-common" }, { allow = [ "AGPL-3.0-or-later", ], crate = "defguard-client" }, + { allow = [ + "AGPL-3.0-or-later", + ], crate = "defguard-client-core" }, + { allow = [ + "AGPL-3.0-or-later", + ], crate = "defguard-client-service" }, + { allow = [ + "AGPL-3.0-or-later", + ], crate = "defguard-client-proto" }, + { allow = [ + "AGPL-3.0-or-later", + ], crate = "defguard-dg" }, + { allow = [ + "LicenseRef-Proprietary", + ], crate = "defguard-client-posture" }, + { allow = [ + "LicenseRef-Proprietary", + ], crate = "defguard-client-provisioning" }, + { allow = [ + "LicenseRef-Proprietary", + ], crate = "defguard-client-config-sync" }, + { allow = [ + "LicenseRef-Proprietary", + ], crate = "defguard-client-service-locations" }, ] # Some crates don't have (easily) machine readable licensing information, diff --git a/src-tauri/src/enterprise/LICENSE.md b/src-tauri/enterprise/LICENSE.md similarity index 100% rename from src-tauri/src/enterprise/LICENSE.md rename to src-tauri/enterprise/LICENSE.md diff --git a/src-tauri/enterprise/config-sync/Cargo.toml b/src-tauri/enterprise/config-sync/Cargo.toml new file mode 100644 index 000000000..4892b40a2 --- /dev/null +++ b/src-tauri/enterprise/config-sync/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "defguard-client-config-sync" +description = "Real-time configuration sync for the Defguard desktop client" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license = "LicenseRef-Proprietary" +rust-version.workspace = true +version.workspace = true + +[dependencies] +defguard-client-core = { path = "../../core" } +defguard-client-proto = { path = "../../client-proto" } +defguard-client-service-locations = { path = "../service-locations" } +log.workspace = true +reqwest.workspace = true +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +tokio = { version = "1", features = ["time"] } + +[dev-dependencies] +http.workspace = true + +[target.'cfg(not(target_os = "macos"))'.dependencies] +tonic.workspace = true diff --git a/src-tauri/enterprise/config-sync/src/commands.rs b/src-tauri/enterprise/config-sync/src/commands.rs new file mode 100644 index 000000000..9f78f72cb --- /dev/null +++ b/src-tauri/enterprise/config-sync/src/commands.rs @@ -0,0 +1,297 @@ +use std::collections::HashSet; + +#[cfg(not(target_os = "macos"))] +use defguard_client_core::{ + connection::daemon_client::DAEMON_CLIENT, database::models::wireguard_keys::WireguardKeys, +}; +use defguard_client_core::{ + database::{ + models::{ + instance::{ClientTrafficPolicy, Instance}, + location::{infer_mfa_method, Location}, + Id, NoId, + }, + DbPool, + }, + error::Error, + into_location, +}; +#[cfg(not(target_os = "macos"))] +use defguard_client_proto::defguard::client::v1::{ + DeleteServiceLocationsRequest, SaveServiceLocationsRequest, +}; +use defguard_client_proto::defguard::client_types::DeviceConfigResponse; +use defguard_client_service_locations::to_service_location; +use sqlx::{Sqlite, SqliteExecutor, Transaction}; + +pub async fn locations_changed( + transaction: &mut Transaction<'_, Sqlite>, + instance: &Instance, + device_config: &DeviceConfigResponse, +) -> Result { + let db_locations = Location::find_by_instance_id(transaction.as_mut(), instance.id, true) + .await? + .into_iter() + .map(|location| { + let mut new_location = Location::::from(location); + new_location.route_all_traffic = false; + new_location.mfa_method = infer_mfa_method(new_location.location_mfa_mode, None); + new_location + }) + .collect::>(); + let core_locations: HashSet = device_config + .configs + .iter() + .map(|config| into_location(config.clone(), instance.id)) + .collect::>(); + + Ok(db_locations != core_locations) +} + +/// Applies a fetched configuration to the local database. Returns whether the location set changed. +pub async fn do_update_instance( + transaction: &mut Transaction<'_, Sqlite>, + instance: &mut Instance, + response: DeviceConfigResponse, +) -> Result { + debug!("Updating instance {instance}"); + let locations_changed_val = locations_changed(transaction, instance, &response).await?; + let instance_info = response + .instance + .expect("Missing instance info in device config response"); + instance.name = instance_info.name; + instance.url = instance_info.url; + instance.proxy_url = instance_info.proxy_url; + instance.username = instance_info.username; + let policy = instance_info.client_traffic_policy.into(); + if instance.client_traffic_policy != policy && policy == ClientTrafficPolicy::DisableAllTraffic + { + debug!("Disabling all traffic for all locations of instance {instance}"); + Location::disable_all_traffic_for_all(transaction.as_mut(), instance.id).await?; + debug!("Disabled all traffic for all locations of instance {instance}"); + } + instance.client_traffic_policy = instance_info.client_traffic_policy.into(); + instance.openid_display_name = instance_info.openid_display_name; + instance.disable_tunnels = instance_info.disable_tunnels.unwrap_or(false); + instance.uuid = instance_info.id; + if response.token.is_some() { + instance.token = response.token; + debug!("Set polling token for instance {}", instance.name); + } else { + debug!( + "No polling token received for instance {}, not updating", + instance.name + ); + } + instance.save(transaction.as_mut()).await?; + debug!( + "A new base configuration has been applied to instance {instance}, even if nothing changed" + ); + + if locations_changed_val { + debug!( + "Updating locations for instance {}({}).", + instance.name, instance.id + ); + let mut current_locations = + Location::find_by_instance_id(transaction.as_mut(), instance.id, true).await?; + for dev_config in response.configs { + let new_location = into_location(dev_config, instance.id); + + let saved_location = if let Some(position) = current_locations + .iter() + .position(|loc| loc.network_id == new_location.network_id) + { + let mut current_location = current_locations.remove(position); + debug!( + "Updating existing location {}({}) for instance {}({}).", + current_location.name, current_location.id, instance.name, instance.id, + ); + current_location.name = new_location.name; + current_location.address = new_location.address; + current_location.pubkey = new_location.pubkey; + current_location.endpoint = new_location.endpoint; + current_location.allowed_ips = new_location.allowed_ips; + current_location.keepalive_interval = new_location.keepalive_interval; + current_location.dns = new_location.dns; + current_location.location_mfa_mode = new_location.location_mfa_mode; + current_location.service_location_mode = new_location.service_location_mode; + current_location.mfa_method = infer_mfa_method( + current_location.location_mfa_mode, + current_location.mfa_method, + ); + current_location.posture_check_required = new_location.posture_check_required; + current_location.save(transaction.as_mut()).await?; + info!("Location {current_location} configuration updated for instance {instance}"); + current_location + } else { + debug!("Creating new location {new_location} for instance {instance}"); + let new_location = new_location.save(transaction.as_mut()).await?; + info!("New location {new_location} created for instance {instance}"); + new_location + }; + + if saved_location.is_service_location() { + debug!( + "Location {}({}) for instance {}({}) is a service location.", + saved_location.name, saved_location.id, instance.name, instance.id, + ); + } + } + + debug!("Removing locations for instance {instance}"); + for removed_location in current_locations { + removed_location.delete(transaction.as_mut()).await?; + info!( + "Removed location {removed_location} for instance {instance} during instance update" + ); + } + debug!("Finished updating locations for instance {instance}"); + } else { + info!("Locations for instance {instance} didn't change. Not updating them."); + } + + Ok(locations_changed_val) +} + +/// Synchronizes the daemon's persisted service-location state from the current database state. +/// +/// Sends all currently persisted service locations for the instance to the daemon, or asks the +/// daemon to delete its service-location state when none remain. +/// +/// Takes a pool rather than a transaction deliberately: this performs gRPC calls that can each take +/// seconds, and holding a SQLite write transaction open across them would block every other writer. +/// **Call it after the surrounding transaction has committed**, so what is pushed is committed state +/// and a slow or unavailable daemon cannot roll back the database. +pub async fn sync_service_locations(pool: &DbPool, instance: &Instance) -> Result<(), Error> { + let mut service_locations = Vec::new(); + let current_locations = Location::find_by_instance_id(pool, instance.id, true).await?; + for location in current_locations { + if location.is_service_location() { + debug!( + "Adding service location {}({}) for instance {}({}) to be saved to the daemon.", + location.name, location.id, instance.name, instance.id, + ); + service_locations.push(to_service_location(&location)?); + } + } + + if service_locations.is_empty() { + debug!( + "No service locations for instance {}({}), removing all existing service locations.", + instance.name, instance.id + ); + + #[cfg(not(target_os = "macos"))] + { + let delete_request = DeleteServiceLocationsRequest { + instance_id: instance.uuid.clone(), + }; + DAEMON_CLIENT + .clone() + .delete_service_locations(delete_request) + .await + .map_err(|err| { + error!( + "Error while deleting service locations from the daemon for instance {}({ \ + }): {err}", + instance.name, instance.id, + ); + Error::InternalError(err.to_string()) + })?; + debug!( + "Successfully removed all service locations from daemon for instance {}({})", + instance.name, instance.id + ); + } + } else { + debug!( + "Processing {} service location(s) for instance {}({})", + service_locations.len(), + instance.name, + instance.id + ); + + #[cfg(not(target_os = "macos"))] + { + let keys = WireguardKeys::find_by_instance_id(pool, instance.id) + .await? + .ok_or(Error::NotFound)?; + + let save_request = SaveServiceLocationsRequest { + service_locations: service_locations.clone(), + instance_id: instance.uuid.clone(), + private_key: keys.prvkey, + proxy_url: instance.proxy_url.clone(), + // The device's own public key, not a remote peer's key. + device_pubkey: keys.pubkey, + token: instance.token.clone(), + }; + + debug!( + "Sending request to daemon to save {} service location(s) for instance {}({})", + save_request.service_locations.len(), + instance.name, + instance.id + ); + + DAEMON_CLIENT + .clone() + .save_service_locations(save_request) + .await + .map_err(|err| { + error!( + "Error while saving service locations to the daemon for instance {}({}): \ + {err}", + instance.name, instance.id, + ); + Error::InternalError(err.to_string()) + })?; + + info!( + "Successfully saved {} service location(s) to daemon for instance {}({})", + service_locations.len(), + instance.name, + instance.id + ); + + debug!( + "Completed processing all service locations for instance {}({})", + instance.name, instance.id + ); + } + } + + Ok(()) +} + +/// Synchronizes service locations without failing the operation that committed their configuration. +/// A later polling cycle retries any failed daemon update. +pub async fn sync_service_locations_best_effort(pool: &DbPool, instance: &Instance) { + if let Err(err) = sync_service_locations(pool, instance).await { + error!( + "Failed to push service locations to the daemon for instance {instance}: {err}. The \ + daemon keeps its previous service-location state until the next successful sync." + ); + } +} + +pub async fn disable_enterprise_features<'e, E>( + instance: &mut Instance, + executor: E, +) -> Result<(), Error> +where + E: SqliteExecutor<'e>, +{ + debug!( + "Disabling enterprise features for instance {}({})", + instance.name, instance.id + ); + instance.client_traffic_policy = ClientTrafficPolicy::None; + instance.save(executor).await?; + debug!( + "Disabled enterprise features for instance {}({})", + instance.name, instance.id + ); + Ok(()) +} diff --git a/src-tauri/enterprise/config-sync/src/lib.rs b/src-tauri/enterprise/config-sync/src/lib.rs new file mode 100644 index 000000000..d4475944a --- /dev/null +++ b/src-tauri/enterprise/config-sync/src/lib.rs @@ -0,0 +1,834 @@ +#[macro_use] +extern crate log; + +use std::{cmp::Ordering, collections::HashSet, str::FromStr}; + +pub mod commands; + +use defguard_client_core::{ + database::{ + models::{instance::Instance, Id}, + DbPool, + }, + error::Error, + proxy::post_with_headers, + version::{MIN_CORE_VERSION, MIN_PROXY_VERSION}, +}; +use defguard_client_proto::defguard::client_types::{InstanceInfoRequest, InstanceInfoResponse}; +use reqwest::{StatusCode, Url}; +use semver::Version; +use serde::Serialize; +use sqlx::{Sqlite, Transaction}; + +use crate::commands::{ + disable_enterprise_features, do_update_instance, sync_service_locations_best_effort, +}; + +static POLLING_ENDPOINT: &str = "/api/v1/poll"; + +const CORE_VERSION_HEADER: &str = "defguard-core-version"; +const CORE_CONNECTED_HEADER: &str = "defguard-core-connected"; +const PROXY_VERSION_HEADER: &str = "defguard-component-version"; + +/// Result of a successful config fetch from the proxy. +#[derive(Debug)] +pub struct FetchedConfig { + pub response: InstanceInfoResponse, + pub version_mismatch: Option, +} + +/// Result of polling a single instance once. +#[derive(Debug)] +pub enum PollInstanceResult { + Unchanged { + version_mismatch: Option, + }, + Updated { + locations_changed: bool, + version_mismatch: Option, + }, + ChangedWhileActive { + version_mismatch: Option, + }, +} + +/// Outcome of polling a single instance in a batch. +#[derive(Debug)] +pub struct PollInstanceOutcome { + pub instance_id: Id, + pub instance_name: String, + pub result: Result, +} + +/// Payload emitted when a version mismatch is detected. +#[derive(Clone, Debug, Serialize)] +pub struct VersionMismatchPayload { + pub instance_name: String, + pub instance_id: Id, + pub core_version: String, + pub proxy_version: String, + pub core_required_version: String, + pub proxy_required_version: String, + pub core_compatible: bool, + pub proxy_compatible: bool, +} + +/// Talks to the proxy for a single instance: builds the request, POSTs it, +/// handles 402 PAYMENT_REQUIRED by disabling enterprise features, parses the +/// response, and checks the version headers. +/// +/// Does **not** apply config changes or emit events - those are the caller's +/// responsibility. +pub async fn fetch_instance_config( + transaction: &mut Transaction<'_, Sqlite>, + instance: &mut Instance, +) -> Result { + debug!("Getting config from core for instance {}", instance.name); + + let request = build_request(instance)?; + let url = Url::from_str(&instance.proxy_url) + .and_then(|url| url.join(POLLING_ENDPOINT)) + .map_err(|_| { + Error::InternalError(format!( + "Can't build polling url: {}/{POLLING_ENDPOINT}", + instance.proxy_url + )) + })?; + let response = post_with_headers(url, &request).await.map_err(|err| { + Error::InternalError(format!( + "HTTP request failed for instance {}({}), url: {}, {err}", + instance.name, instance.id, instance.proxy_url + )) + })?; + debug!( + "Got the following config response for instance {} from core: {response:?}", + instance.name + ); + + // Return early if the enterprise features are disabled in the core + if response.status() == StatusCode::PAYMENT_REQUIRED { + debug!( + "Instance {}({}) has enterprise features disabled, checking if this state is reflected \ + on our end.", + instance.name, instance.id + ); + if instance.enterprise_enabled { + info!( + "Instance {}({}) has enterprise features disabled, but we have them enabled, \ + disabling.", + instance.name, instance.id + ); + disable_enterprise_features(instance, transaction.as_mut()).await?; + } else { + debug!( + "Instance {}({}) has enterprise features disabled, and we have them disabled as \ + well, no action needed", + instance.name, instance.id + ); + } + return Err(Error::CoreNotEnterprise); + } + + if !response.status().is_success() { + return Err(Error::InternalError(format!( + "Config polling failed for instance {}({}) with status {}", + instance.name, + instance.id, + response.status(), + ))); + } + + let version_mismatch = check_min_version(&response, instance); + + // Parse the response + debug!( + "Parsing the config response for instance {}.", + instance.name + ); + let response: InstanceInfoResponse = response.json().await.map_err(|err| { + Error::InternalError(format!( + "Failed to parse InstanceInfoResponse for instance {}({}): {err}", + instance.name, instance.id, + )) + })?; + + if response.device_config.is_none() { + return Err(Error::InternalError( + "Device config not present in response".to_string(), + )); + } + + debug!("Parsed the config for instance {}", instance.name); + trace!("Parsed config: {:?}", response.device_config); + + Ok(FetchedConfig { + response, + version_mismatch, + }) +} + +/// Polls one instance once and applies changed configuration only when safe. +/// +/// The caller owns scheduling, active-connection detection, and user-facing notifications. +pub async fn poll_instance( + transaction: &mut Transaction<'_, Sqlite>, + instance: &mut Instance, + has_active_connections: bool, +) -> Result { + let fetched = fetch_instance_config(transaction, instance).await?; + let version_mismatch = fetched.version_mismatch; + + let device_config = + fetched.response.device_config.as_ref().ok_or_else(|| { + Error::InternalError("Device config not present in response".to_string()) + })?; + if !config_changed(transaction, instance, device_config).await? { + debug!( + "Config for instance {}({}) didn't change", + instance.name, instance.id + ); + return Ok(PollInstanceResult::Unchanged { version_mismatch }); + } + + debug!( + "Config for instance {}({}) changed", + instance.name, instance.id + ); + + if has_active_connections { + // add dedicated override to disable tunnels without waiting for a disconnect + if let Some(ref info) = device_config.instance { + let new_tunnels_disabled = info.disable_tunnels.unwrap_or(false); + if new_tunnels_disabled && !instance.disable_tunnels { + debug!( + "Tunnels were disabled for instance {}({}) while a connection is active, \ + persisting the flag immediately.", + instance.name, instance.id + ); + instance.disable_tunnels = true; + instance.save(transaction.as_mut()).await?; + } + } + return Ok(PollInstanceResult::ChangedWhileActive { version_mismatch }); + } + + debug!( + "Updating instance {}({}) configuration: {device_config:?}", + instance.name, instance.id, + ); + let locations_changed = + do_update_instance(transaction, instance, device_config.clone()).await?; + info!( + "Updated instance {}({}) configuration based on core's response", + instance.name, instance.id + ); + + Ok(PollInstanceResult::Updated { + locations_changed, + version_mismatch, + }) +} + +/// Polls all instances that have a polling token and commits any safe configuration updates. +/// +/// The caller owns active-connection detection and all user-facing side effects. +pub async fn poll_instances( + pool: &DbPool, + active_instance_ids: &HashSet, +) -> Result, Error> { + let mut transaction = pool.begin().await?; + let mut instances = Instance::all_with_token(&mut *transaction).await?; + let mut outcomes = Vec::with_capacity(instances.len()); + + for instance in &mut instances { + let has_active_connections = active_instance_ids.contains(&instance.id); + let instance_id = instance.id; + let result = poll_instance(&mut transaction, instance, has_active_connections).await; + outcomes.push(PollInstanceOutcome { + instance_id, + instance_name: instance.name.clone(), + result, + }); + } + + transaction.commit().await?; + + // Push to the daemon only after committing to avoid hanging transactions across grpc calls. + for instance in &instances { + sync_service_locations_best_effort(pool, instance).await; + } + + Ok(outcomes) +} + +/// Checks if config has changed compared to what's in the database. +pub async fn config_changed( + transaction: &mut Transaction<'_, Sqlite>, + instance: &Instance, + device_config: &defguard_client_proto::defguard::client_types::DeviceConfigResponse, +) -> Result { + debug!( + "Checking if config and any of the locations changed for instance {}({})", + instance.name, instance.id + ); + let locations_changed = + commands::locations_changed(transaction, instance, device_config).await?; + let info_changed = match &device_config.instance { + Some(info) => instance != info, + None => false, + }; + debug!( + "Did the locations change?: {locations_changed}. Did the instance information change?: \ + {info_changed}" + ); + Ok(locations_changed || info_changed) +} + +/// Retrieves token to build InstanceInfoRequest +fn build_request(instance: &Instance) -> Result { + let token = instance.token.as_ref().ok_or_else(|| Error::NoToken)?; + + Ok(InstanceInfoRequest { + token: (*token).clone(), + }) +} + +/// Checks response headers for version compatibility. +/// Returns `Some(payload)` when versions are incompatible, `None` when +/// everything is compatible or headers are missing. +fn check_min_version( + response: &reqwest::Response, + instance: &Instance, +) -> Option { + let detected_core_version: String; + let detected_proxy_version: String; + + let defguard_core_connected: Option = response + .headers() + .get(CORE_CONNECTED_HEADER) + .and_then(|v| { + debug!( + "Defguard core connection status header for instance {}({}): {v:?}", + instance.name, instance.id + ); + v.to_str().ok() + }) + .and_then(|s| s.parse().ok()); + + let core_compatible = if let Some(core_version) = response.headers().get(CORE_VERSION_HEADER) { + if let Ok(core_version) = core_version.to_str() { + if let Ok(core_version) = Version::from_str(core_version) { + detected_core_version = core_version.to_string(); + core_version.cmp_precedence(&MIN_CORE_VERSION) != Ordering::Less + } else { + warn!( + "Core version header: invalid semver string in response for instance {}({}): \ + '{core_version}'", + instance.name, instance.id + ); + detected_core_version = core_version.to_string(); + false + } + } else { + warn!( + "Core version header: invalid string in response for instance {}({}): \ + '{core_version:?}'", + instance.name, instance.id + ); + detected_core_version = "unknown".to_string(); + false + } + } else { + warn!( + "Core version header not present in response for instance {}({})", + instance.name, instance.id + ); + detected_core_version = "unknown".to_string(); + false + }; + + let proxy_compatible = if let Some(proxy_version) = response.headers().get(PROXY_VERSION_HEADER) + { + if let Ok(proxy_version) = proxy_version.to_str() { + if let Ok(proxy_version) = Version::from_str(proxy_version) { + detected_proxy_version = proxy_version.to_string(); + proxy_version.cmp_precedence(&MIN_PROXY_VERSION) != Ordering::Less + } else { + warn!( + "Proxy version header not a valid semver string in response for instance \ + {}({}): '{proxy_version}'", + instance.name, instance.id + ); + detected_proxy_version = proxy_version.to_string(); + false + } + } else { + warn!( + "Proxy version header not a valid string in response for instance {}({}): \ + '{proxy_version:?}'", + instance.name, instance.id + ); + detected_proxy_version = "unknown".to_string(); + false + } + } else { + warn!( + "Proxy version header not present in response for instance {}({})", + instance.name, instance.id + ); + detected_proxy_version = "unknown".to_string(); + false + }; + + let should_inform = match defguard_core_connected { + Some(true) => { + debug!( + "Defguard core is connected for instance {}({})", + instance.name, instance.id + ); + true + } + Some(false) => { + info!( + "Defguard core is not connected for instance {}({})", + instance.name, instance.id + ); + false + } + None => { + debug!( + "Defguard core connection status unknown for instance {}({})", + instance.name, instance.id + ); + true + } + }; + + if should_inform && (!core_compatible || !proxy_compatible) { + warn!( + "Instance {} is running incompatible versions: core {detected_core_version}, proxy \ + {detected_proxy_version}. Required versions: core >= {MIN_CORE_VERSION}, proxy >= \ + {MIN_PROXY_VERSION}", + instance.name, + ); + + Some(VersionMismatchPayload { + instance_name: instance.name.clone(), + instance_id: instance.id, + core_version: detected_core_version, + proxy_version: detected_proxy_version, + core_required_version: MIN_CORE_VERSION.to_string(), + proxy_required_version: MIN_PROXY_VERSION.to_string(), + core_compatible, + proxy_compatible, + }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use std::{ + collections::HashSet, + io::{ErrorKind, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread::{sleep, spawn, JoinHandle}, + time::Duration, + }; + + use defguard_client_core::database::models::{ + instance::ClientTrafficPolicy, + location::{Location, LocationMfaMode, ServiceLocationMode}, + NoId, + }; + use defguard_client_proto::defguard::client_types::{ + DeviceConfig, DeviceConfigResponse, InstanceInfo, + }; + use sqlx::SqlitePool; + + use super::*; + + const READ_TIMEOUT: Duration = Duration::from_secs(5); + const CONNECT_TIMEOUT: Duration = Duration::from_millis(50); + const WAIT_TIMEOUT: Duration = Duration::from_millis(10); + + struct MockResponse { + status: u16, + body: String, + } + + struct MockPollServer { + addr: SocketAddr, + handle: Option>, + } + + impl MockPollServer { + fn new(responses: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + + let handle = spawn(move || { + for response in responses { + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(ref err) if err.kind() == ErrorKind::WouldBlock => { + sleep(WAIT_TIMEOUT); + } + Err(_) => return, + } + }; + stream.set_nonblocking(false).ok(); + stream.set_read_timeout(Some(READ_TIMEOUT)).ok(); + let mut data = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + data.extend_from_slice(&buf[..n]); + if data.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + Err(_) => break, + } + } + + let body = format!( + "HTTP/1.1 {} OK\r\nContent-Type: application/json\r\n{}: 1.6.0\r\n{}: 1.6.0\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.status, + CORE_VERSION_HEADER, + PROXY_VERSION_HEADER, + response.body.len(), + response.body, + ); + let _ = stream.write_all(body.as_bytes()); + } + }); + + Self { + addr, + handle: Some(handle), + } + } + + fn url(&self) -> String { + format!("http://{}/", self.addr) + } + } + + impl Drop for MockPollServer { + fn drop(&mut self) { + // Unblock accept if the test did not consume all prepared responses. + let _ = TcpStream::connect_timeout(&self.addr, CONNECT_TIMEOUT); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + } + + fn instance_with_token(token: Option<&str>) -> Instance { + Instance { + id: 1, + name: "inst".into(), + uuid: "uuid".into(), + url: "https://core".into(), + proxy_url: "https://proxy".into(), + username: "alice".into(), + token: token.map(str::to_string), + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: false, + disable_tunnels: false, + openid_display_name: None, + } + } + + fn response_with_headers(headers: &[(&str, &str)]) -> reqwest::Response { + let mut builder = http::Response::builder(); + for (key, value) in headers { + builder = builder.header(*key, *value); + } + reqwest::Response::from(builder.body(String::new()).unwrap()) + } + + fn instance_info(name: &str, proxy_url: &str) -> InstanceInfo { + InstanceInfo { + id: format!("uuid-{name}"), + name: name.into(), + url: format!("https://{name}.example"), + proxy_url: proxy_url.into(), + username: "alice".into(), + enterprise_enabled: true, + ..Default::default() + } + } + + fn device_config(network_id: Id, name: &str, endpoint: &str) -> DeviceConfig { + DeviceConfig { + network_id, + network_name: name.into(), + endpoint: endpoint.into(), + assigned_ip: "10.6.0.2".into(), + pubkey: format!("pk-{network_id}"), + allowed_ips: "0.0.0.0/0".into(), + keepalive_interval: 25, + ..Default::default() + } + } + + fn device_config_response( + instance: &Instance, + config: DeviceConfig, + ) -> DeviceConfigResponse { + DeviceConfigResponse { + instance: Some(instance_info(&instance.name, &instance.proxy_url)), + configs: vec![config], + token: instance.token.clone(), + ..Default::default() + } + } + + fn poll_response(response: DeviceConfigResponse) -> MockResponse { + let body = serde_json::to_string(&InstanceInfoResponse { + device_config: Some(response), + }) + .unwrap(); + MockResponse { status: 200, body } + } + + fn error_response() -> MockResponse { + MockResponse { + status: 500, + body: "not-json".into(), + } + } + + async fn seed_instance( + pool: &SqlitePool, + name: &str, + proxy_url: &str, + token: Option<&str>, + ) -> Instance { + Instance { + id: NoId, + name: name.into(), + uuid: format!("uuid-{name}"), + url: format!("https://{name}.example"), + proxy_url: proxy_url.into(), + username: "alice".into(), + token: token.map(str::to_string), + client_traffic_policy: ClientTrafficPolicy::None, + enterprise_enabled: true, + disable_tunnels: false, + openid_display_name: None, + } + .save(pool) + .await + .unwrap() + } + + async fn seed_location( + pool: &SqlitePool, + instance_id: Id, + network_id: Id, + name: &str, + endpoint: &str, + ) -> Location { + Location { + id: NoId, + instance_id, + network_id, + name: name.into(), + address: "10.6.0.2".into(), + pubkey: format!("pk-{network_id}"), + endpoint: endpoint.into(), + allowed_ips: "0.0.0.0/0".into(), + dns: None, + route_all_traffic: false, + keepalive_interval: 25, + location_mfa_mode: LocationMfaMode::Disabled, + service_location_mode: ServiceLocationMode::Disabled, + mfa_method: None, + posture_check_required: false, + } + .save(pool) + .await + .unwrap() + } + + #[test] + fn test_build_request_no_token_errors() { + let instance = instance_with_token(None); + assert!(matches!(build_request(&instance), Err(Error::NoToken))); + } + + #[test] + fn test_build_request_includes_token() { + let instance = instance_with_token(Some("tok")); + let request = build_request(&instance).unwrap(); + assert_eq!(request.token, "tok"); + } + + #[test] + fn test_check_min_version_compatible_returns_none() { + let response = response_with_headers(&[ + (CORE_VERSION_HEADER, "1.6.0"), + (PROXY_VERSION_HEADER, "1.6.0"), + ]); + let instance = instance_with_token(Some("tok")); + assert!(check_min_version(&response, &instance).is_none()); + } + + #[test] + fn test_check_min_version_incompatible_core() { + let response = response_with_headers(&[ + (CORE_VERSION_HEADER, "1.0.0"), + (PROXY_VERSION_HEADER, "1.6.0"), + ]); + let instance = instance_with_token(Some("tok")); + let payload = check_min_version(&response, &instance).expect("mismatch expected"); + assert!(!payload.core_compatible); + assert!(payload.proxy_compatible); + assert_eq!(payload.core_version, "1.0.0"); + } + + #[test] + fn test_check_min_version_missing_headers_returns_mismatch() { + let response = response_with_headers(&[]); + let instance = instance_with_token(Some("tok")); + let payload = check_min_version(&response, &instance).expect("mismatch expected"); + assert!(!payload.core_compatible); + assert!(!payload.proxy_compatible); + assert_eq!(payload.core_version, "unknown"); + assert_eq!(payload.proxy_version, "unknown"); + } + + #[test] + fn test_check_min_version_core_not_connected_suppresses() { + // Core reports it is not connected, so an incompatible version is not flagged. + let response = response_with_headers(&[ + (CORE_CONNECTED_HEADER, "false"), + (CORE_VERSION_HEADER, "1.0.0"), + (PROXY_VERSION_HEADER, "1.6.0"), + ]); + let instance = instance_with_token(Some("tok")); + assert!(check_min_version(&response, &instance).is_none()); + } + + #[sqlx::test(migrations = "../../migrations")] + async fn test_config_changed_false_when_instance_and_locations_match(pool: SqlitePool) { + let instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_location(&pool, instance.id, 1, "office", "1.2.3.4:51820").await; + let response = + device_config_response(&instance, device_config(1, "office", "1.2.3.4:51820")); + + let mut transaction = pool.begin().await.unwrap(); + let changed = config_changed(&mut transaction, &instance, &response) + .await + .unwrap(); + + assert!(!changed); + } + + #[sqlx::test(migrations = "../../migrations")] + async fn test_config_changed_true_when_instance_metadata_changes(pool: SqlitePool) { + let instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_location(&pool, instance.id, 1, "office", "1.2.3.4:51820").await; + let mut response = + device_config_response(&instance, device_config(1, "office", "1.2.3.4:51820")); + response.instance.as_mut().unwrap().name = "renamed".into(); + + let mut transaction = pool.begin().await.unwrap(); + let changed = config_changed(&mut transaction, &instance, &response) + .await + .unwrap(); + + assert!(changed); + } + + #[sqlx::test(migrations = "../../migrations")] + async fn test_config_changed_true_when_location_changes(pool: SqlitePool) { + let instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_location(&pool, instance.id, 1, "office", "1.2.3.4:51820").await; + let response = + device_config_response(&instance, device_config(1, "office", "5.6.7.8:51820")); + + let mut transaction = pool.begin().await.unwrap(); + let changed = config_changed(&mut transaction, &instance, &response) + .await + .unwrap(); + + assert!(changed); + } + + #[sqlx::test(migrations = "../../migrations")] + async fn test_poll_instance_changed_while_active_does_not_update_db(pool: SqlitePool) { + let mut instance = seed_instance(&pool, "acme", "https://proxy.example", Some("tok")).await; + seed_location(&pool, instance.id, 1, "office", "1.2.3.4:51820").await; + + let response = + device_config_response(&instance, device_config(1, "office", "5.6.7.8:51820")); + let server = MockPollServer::new(vec![poll_response(response)]); + instance.proxy_url = server.url(); + instance.save(&pool).await.unwrap(); + + let mut transaction = pool.begin().await.unwrap(); + let result = poll_instance(&mut transaction, &mut instance, true) + .await + .unwrap(); + transaction.commit().await.unwrap(); + + assert!(matches!( + result, + PollInstanceResult::ChangedWhileActive { .. } + )); + let location = Location::find_by_instance_id(&pool, instance.id, true) + .await + .unwrap() + .pop() + .unwrap(); + assert_eq!(location.endpoint, "1.2.3.4:51820"); + } + + #[sqlx::test(migrations = "../../migrations")] + async fn test_poll_instances_returns_success_and_error_outcomes(pool: SqlitePool) { + let error_server = MockPollServer::new(vec![error_response()]); + + let instance_active = + seed_instance(&pool, "active", "https://proxy.example", Some("tok-1")).await; + seed_location(&pool, instance_active.id, 1, "office", "1.2.3.4:51820").await; + let response = device_config_response( + &instance_active, + device_config(1, "office", "5.6.7.8:51820"), + ); + let success_server = MockPollServer::new(vec![poll_response(response)]); + let mut instance_active = instance_active; + instance_active.proxy_url = success_server.url(); + instance_active.save(&pool).await.unwrap(); + + let instance_error = + seed_instance(&pool, "error", &error_server.url(), Some("tok-2")).await; + + let outcomes = poll_instances(&pool, &HashSet::from([instance_active.id])) + .await + .unwrap(); + + assert_eq!(outcomes.len(), 2); + let active_outcome = outcomes + .iter() + .find(|outcome| outcome.instance_id == instance_active.id) + .unwrap(); + assert!(matches!( + active_outcome.result, + Ok(PollInstanceResult::ChangedWhileActive { .. }) + )); + let error_outcome = outcomes + .iter() + .find(|outcome| outcome.instance_id == instance_error.id) + .unwrap(); + assert!(error_outcome.result.is_err()); + } +} diff --git a/src-tauri/enterprise/posture/Cargo.toml b/src-tauri/enterprise/posture/Cargo.toml new file mode 100644 index 000000000..d27def6d1 --- /dev/null +++ b/src-tauri/enterprise/posture/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "defguard-client-posture" +description = "Device posture checks for the Defguard desktop client" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license = "LicenseRef-Proprietary" +rust-version.workspace = true +version.workspace = true + +[dependencies] +defguard-client-core = { path = "../../core" } +defguard-client-proto = { path = "../../client-proto" } +log.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tonic.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +sysinfo = { version = "0.39", default-features = false, features = ["system"] } + +[target.'cfg(target_os = "macos")'.dependencies] +sysinfo = { version = "0.39", default-features = false, features = ["system"] } + +[target.'cfg(windows)'.dependencies] +sysinfo = { version = "0.39", default-features = false, features = ["system"] } +time = { version = "0.3", features = ["formatting", "macros", "serde"] } +wmi = { version = "0.18", default-features = false } + +[dev-dependencies] +tokio.workspace = true +wiremock.workspace = true diff --git a/src-tauri/enterprise/posture/src/inspector/linux.rs b/src-tauri/enterprise/posture/src/inspector/linux.rs new file mode 100644 index 000000000..86e903352 --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/linux.rs @@ -0,0 +1,386 @@ +use std::{ + collections::HashSet, + fs::read_to_string, + path::{Path, PathBuf}, + process::Command, +}; + +use super::UnavailableReason; + +/// Path to the kernel's mount table for the current process. +const MOUNTINFO_PATH: &str = "/proc/self/mountinfo"; + +/// sysfs directory exposing every block device by kernel name. +const SYS_BLOCK: &str = "/sys/class/block"; + +/// A single mount table entry: its mount point, filesystem type and backing +/// device source. +struct MountEntry { + mount_point: String, + /// Filesystem type, e.g. `ext4`, `btrfs`, `zfs`. + fstype: String, + /// The mount source as reported by the kernel, e.g. `/dev/mapper/cryptroot` + /// for a block device or a dataset name like `rpool/USERDATA/x` for ZFS. + source: String, +} + +/// Reports whether the partition that stores the client's database file is +/// encrypted. +/// +/// It resolves the specific device backing the database file and inspects only +/// that device's stack, so unrelated encrypted loop/removable/test volumes do +/// not produce a false positive. Two encryption mechanisms are recognized: +/// - **LUKS/dm-crypt** for block-backed filesystems (ext4/xfs/btrfs/LVM/…), via +/// the sysfs device dependency chain (`/sys/class/block//slaves`); +/// - **native ZFS encryption**, via the dataset's `encryption` property. +/// +/// Other filesystem-internal encryption schemes that leave no block-layer trace +/// (bcachefs native encryption, fscrypt on ext4/f2fs, eCryptfs) are not detected +/// and resolve to `DetectionFailed` - fail-safe: a required posture rule fails +/// rather than falsely passing. +/// Reports whether the device stack backing `path` includes an encryption layer. +/// +/// `path` is supplied by the caller rather than derived here, because who is asking changes the +/// answer: a user-initiated check means the partition holding the client database, while the service +/// means `/`. Deriving it internally would silently answer for whichever process happened to call. +pub(super) fn disk_encryption_status(path: &Path) -> Result { + // Resolve the target and the mount that backs it. + let db_path = canonicalize_on_disk(path).ok_or(UnavailableReason::DetectionFailed)?; + + let mountinfo = + read_to_string(MOUNTINFO_PATH).map_err(|_| UnavailableReason::DetectionFailed)?; + let mounts = parse_mountinfo(&mountinfo); + let backing = + find_backing_mount(&mounts, &db_path).ok_or(UnavailableReason::DetectionFailed)?; + + // ZFS encryption is a dataset property, not a block-layer device; the mount + // source is the dataset name rather than a `/dev` path. + if backing.fstype == "zfs" { + return zfs_dataset_encrypted(&backing.source); + } + + // Otherwise map the mount source to its kernel device name and inspect only + // that device's stack for a LUKS/dm-crypt layer. + let kname = source_kname(&backing.source).ok_or(UnavailableReason::DetectionFailed)?; + device_is_encrypted(&kname).ok_or(UnavailableReason::DetectionFailed) +} + +/// Un-escapes the octal sequences (`\040` space, `\011` tab, `\012` newline, +/// `\134` backslash) that the kernel uses for special characters in +/// `mountinfo` fields. +fn unescape_mountinfo(field: &str) -> String { + let mut out = String::with_capacity(field.len()); + let mut chars = field.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + let octal: String = chars.clone().take(3).collect(); + if octal.len() == 3 { + if let Ok(code) = u8::from_str_radix(&octal, 8) { + out.push(code as char); + // Consume the three octal digits we just parsed. + chars.nth(2); + continue; + } + } + } + out.push(c); + } + out +} + +/// Parses `/proc/self/mountinfo` content into a list of mount entries. +/// +/// Each line has the form +/// ` - `. +/// The mount point is field index 4 (before the `-` separator); the filesystem +/// type and backing device source are the first two fields after ` - `. +fn parse_mountinfo(content: &str) -> Vec { + content + .lines() + .filter_map(|line| { + let (fields, rest) = line.split_once(" - ")?; + let mount_point = fields.split(' ').nth(4)?; + let mut post = rest.split(' '); + let fstype = post.next()?; + let source = post.next()?; + Some(MountEntry { + mount_point: unescape_mountinfo(mount_point), + fstype: fstype.to_owned(), + source: unescape_mountinfo(source), + }) + }) + .collect() +} + +/// Returns the mount entry that backs `path`: the one whose mount point is the +/// longest path-prefix of `path`. Matching is component-aware, so `/var` does +/// not match `/vart`. Among entries sharing the longest mount point (overmounts) +/// the last one wins, matching the kernel's effective mount. +fn find_backing_mount<'a>(mounts: &'a [MountEntry], path: &Path) -> Option<&'a MountEntry> { + mounts + .iter() + .filter(|entry| path.starts_with(&entry.mount_point)) + .max_by_key(|entry| entry.mount_point.len()) +} + +/// Whether the device `kname` is an opened dm-crypt mapping, per its sysfs +/// `dm/uuid` (dm-crypt devices carry a `CRYPT-` prefix, e.g. `CRYPT-LUKS2-…`). +fn dm_uuid_is_crypt(kname: &str) -> bool { + read_to_string(Path::new(SYS_BLOCK).join(kname).join("dm/uuid")) + .is_ok_and(|uuid| uuid.trim_start().starts_with("CRYPT-")) +} + +/// Kernel names of the devices `kname` is stacked on top of (its sysfs +/// `slaves/`): e.g. an LVM LV's slave is its dm-crypt device, whose slave is the +/// LUKS partition. Empty when the device has no lower devices (e.g. a plain +/// partition) or the directory is absent. +fn slaves_of(kname: &str) -> Vec { + let slaves_dir = Path::new(SYS_BLOCK).join(kname).join("slaves"); + let Ok(entries) = std::fs::read_dir(slaves_dir) else { + return Vec::new(); + }; + entries + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect() +} + +/// Returns whether `kname`, or any device it stacks on, is a dm-crypt mapping, +/// walking the dependency chain via `slaves`. The `is_crypt` and `slaves` +/// readers are injected so the traversal is testable without real sysfs. +fn stack_has_crypt( + kname: &str, + is_crypt: &impl Fn(&str) -> bool, + slaves: &impl Fn(&str) -> Vec, + visited: &mut HashSet, +) -> bool { + if !visited.insert(kname.to_owned()) { + return false; + } + is_crypt(kname) + || slaves(kname) + .iter() + .any(|slave| stack_has_crypt(slave, is_crypt, slaves, visited)) +} + +/// Returns whether the block device with kernel name `kname` is encrypted +/// (backed by dm-crypt/LUKS anywhere in its stack), or `None` if the device is +/// not present in sysfs (e.g. a non-block-backed filesystem such as +/// tmpfs/overlay/zfs). +fn device_is_encrypted(kname: &str) -> Option { + if !Path::new(SYS_BLOCK).join(kname).exists() { + return None; + } + Some(stack_has_crypt( + kname, + &dm_uuid_is_crypt, + &slaves_of, + &mut HashSet::new(), + )) +} + +/// Canonicalizes `path`, or its nearest existing ancestor when the file/dirs do +/// not exist yet. The nearest existing ancestor lives on the same partition the +/// database will be created on (intermediate dirs are created there; mount +/// points must already exist), so it identifies the correct backing device. +fn canonicalize_on_disk(path: &Path) -> Option { + let mut current = Some(path); + while let Some(p) = current { + if let Ok(canonical) = p.canonicalize() { + return Some(canonical); + } + current = p.parent(); + } + None +} + +/// Resolves a mount source to the kernel device name of its block device, e.g. +/// `/dev/mapper/cryptroot` -> `dm-0`. Returns `None` when the source is not a +/// resolvable block device path (e.g. `tmpfs`, a ZFS dataset name). +fn source_kname(source: &str) -> Option { + Path::new(source) + .canonicalize() + .ok()? + .file_name() + .map(|name| name.to_string_lossy().into_owned()) +} + +/// Interprets the value of a ZFS `encryption` property. +/// +/// `off` -> not encrypted; a cipher name (e.g. `aes-256-gcm`) or `on` -> +/// encrypted; an empty or `-` value (unknown/unsupported) -> `None`. +fn parse_zfs_encryption(value: &str) -> Option { + match value.trim() { + "" | "-" => None, + "off" => Some(false), + _ => Some(true), + } +} + +/// Reports whether a ZFS dataset uses native encryption. +/// +/// ZFS encryption is a per-dataset filesystem property with no block-layer +/// (dm-crypt) representation, so it is queried directly via the `zfs` CLI rather +/// than through sysfs. A mounted dataset implies its key is loaded, so the +/// `encryption` property alone is sufficient (no separate `keystatus` check). +fn zfs_dataset_encrypted(dataset: &str) -> Result { + let output = Command::new("zfs") + .args(["get", "-H", "-o", "value", "encryption", dataset]) + .output() + .map_err(|_| UnavailableReason::DetectionFailed)?; + if !output.status.success() { + return Err(UnavailableReason::DetectionFailed); + } + let value = String::from_utf8_lossy(&output.stdout); + parse_zfs_encryption(&value).ok_or(UnavailableReason::DetectionFailed) +} + +#[cfg(test)] +mod unit_tests { + use std::collections::HashMap; + + use super::*; + + #[test] + fn unescape_handles_octal_sequences() { + assert_eq!(unescape_mountinfo("/mnt/my\\040disk"), "/mnt/my disk"); + assert_eq!(unescape_mountinfo("/plain/path"), "/plain/path"); + // A lone backslash that is not a valid escape is preserved. + assert_eq!(unescape_mountinfo("/a\\b"), "/a\\b"); + } + + #[test] + fn parse_mountinfo_extracts_mount_point_fstype_and_source() { + let content = "\ +36 35 0:30 / / rw,noatime shared:1 - btrfs /dev/mapper/cryptroot rw,subvol=/ +38 36 0:32 / /mnt/my\\040disk rw shared:3 - ext4 /dev/sdb1 rw +39 36 0:33 / /data rw shared:4 - zfs rpool/USERDATA/x rw"; + let mounts = parse_mountinfo(content); + let rows: Vec<(&str, &str, &str)> = mounts + .iter() + .map(|m| (m.mount_point.as_str(), m.fstype.as_str(), m.source.as_str())) + .collect(); + assert_eq!( + rows, + vec![ + ("/", "btrfs", "/dev/mapper/cryptroot"), + ("/mnt/my disk", "ext4", "/dev/sdb1"), + ("/data", "zfs", "rpool/USERDATA/x"), + ] + ); + } + + fn mount(mount_point: &str, source: &str) -> MountEntry { + MountEntry { + mount_point: mount_point.to_owned(), + fstype: "ext4".to_owned(), + source: source.to_owned(), + } + } + + #[test] + fn parse_zfs_encryption_interprets_property() { + assert_eq!(parse_zfs_encryption("off"), Some(false)); + assert_eq!(parse_zfs_encryption("on"), Some(true)); + assert_eq!(parse_zfs_encryption("aes-256-gcm"), Some(true)); + assert_eq!(parse_zfs_encryption("aes-256-gcm\n"), Some(true)); + assert_eq!(parse_zfs_encryption("-"), None); + assert_eq!(parse_zfs_encryption(""), None); + } + + #[test] + fn find_backing_mount_picks_longest_prefix() { + let mounts = vec![ + mount("/", "/dev/sda1"), + mount("/var", "/dev/sda2"), + mount("/var/lib", "/dev/sda3"), + ]; + assert_eq!( + find_backing_mount(&mounts, Path::new("/var/lib/defguard/db")) + .map(|m| m.source.as_str()), + Some("/dev/sda3") + ); + assert_eq!( + find_backing_mount(&mounts, Path::new("/var/log/x")).map(|m| m.source.as_str()), + Some("/dev/sda2") + ); + assert_eq!( + find_backing_mount(&mounts, Path::new("/home/x")).map(|m| m.source.as_str()), + Some("/dev/sda1") + ); + } + + #[test] + fn find_backing_mount_is_component_aware() { + let mounts = vec![mount("/", "/dev/sda1"), mount("/var", "/dev/sda2")]; + // "/vart" must not match the "/var" mount. + assert_eq!( + find_backing_mount(&mounts, Path::new("/vart/x")).map(|m| m.source.as_str()), + Some("/dev/sda1") + ); + } + + /// Runs `stack_has_crypt` over an in-memory device graph: `crypt` is the set + /// of dm-crypt kernel names, `slaves` maps each device to the devices it + /// stacks on (its lower devices). + fn stack_encrypted(start: &str, crypt: &[&str], slaves: &[(&str, &[&str])]) -> bool { + let crypt: HashSet<&str> = crypt.iter().copied().collect(); + let slaves: HashMap<&str, Vec> = slaves + .iter() + .map(|(k, v)| (*k, v.iter().map(|s| (*s).to_owned()).collect())) + .collect(); + let is_crypt = |k: &str| crypt.contains(k); + let slaves_of = |k: &str| slaves.get(k).cloned().unwrap_or_default(); + stack_has_crypt(start, &is_crypt, &slaves_of, &mut HashSet::new()) + } + + #[test] + fn plain_luks_device_is_encrypted() { + // Mounted device is the opened crypt mapping itself. + let slaves = [("dm-0", &["sda2"][..]), ("sda2", &["sda"][..])]; + assert!(stack_encrypted("dm-0", &["dm-0"], &slaves)); + } + + #[test] + fn luks_under_lvm_device_is_encrypted() { + // Mounted LVM logical volume stacks on top of a crypt ancestor (dm-0). + // This is the layered case the previous flat-lsblk walk missed. + let slaves = [ + ("dm-1", &["dm-0"][..]), + ("dm-0", &["vda4"][..]), + ("vda4", &["vda"][..]), + ]; + assert!(stack_encrypted("dm-1", &["dm-0"], &slaves)); + } + + #[test] + fn plaintext_device_is_not_encrypted() { + let slaves = [("sda2", &["sda"][..])]; + assert!(!stack_encrypted("sda2", &[], &slaves)); + } + + #[test] + fn unrelated_encrypted_device_does_not_leak() { + // Only the target device's own stack is inspected; an encrypted device in + // a separate stack must not leak (the regression this hardening targets). + let slaves = [("sda2", &["sda"][..]), ("dm-9", &["loop0"][..])]; + assert!(!stack_encrypted("sda2", &["dm-9"], &slaves)); + assert!(stack_encrypted("dm-9", &["dm-9"], &slaves)); + } + + #[test] + fn stack_walk_terminates_on_cycles() { + // A pathological slaves cycle must not loop forever. + let slaves = [("a", &["b"][..]), ("b", &["a"][..])]; + assert!(!stack_encrypted("a", &[], &slaves)); + } + + #[test] + fn canonicalize_ascends_to_nearest_existing_ancestor() { + // A deep non-existent DB path resolves to its nearest existing ancestor + // (the default app dir does not exist before first run). + let base = std::env::temp_dir(); + let deep = base.join("defguard-posture-nonexistent-xyz/a/b/defguard.db"); + assert_eq!(canonicalize_on_disk(&deep), base.canonicalize().ok()); + } +} diff --git a/src-tauri/enterprise/posture/src/inspector/macos.rs b/src-tauri/enterprise/posture/src/inspector/macos.rs new file mode 100644 index 000000000..779aaaf24 --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/macos.rs @@ -0,0 +1,25 @@ +use std::process::Command; + +use super::UnavailableReason; + +/// Check if FileVault has been enabled. +pub(super) fn disk_encryption_status() -> Result { + let output = Command::new("fdesetup") + .arg("isactive") + .output() + .map_err(|_| UnavailableReason::DetectionFailed)?; + let stdout = String::from_utf8_lossy(&output.stdout); + + Ok(stdout.trim_end() == "true") +} + +/// Check if System Integrity Protection has been enabled. +pub(super) fn system_integrity_status() -> Result { + let output = Command::new("csrutil") + .arg("status") + .output() + .map_err(|_| UnavailableReason::DetectionFailed)?; + let stdout = String::from_utf8_lossy(&output.stdout); + + Ok(stdout.trim_end() == "System Integrity Protection status: enabled.") +} diff --git a/src-tauri/enterprise/posture/src/inspector/mod.rs b/src-tauri/enterprise/posture/src/inspector/mod.rs new file mode 100644 index 000000000..015fcd48d --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/mod.rs @@ -0,0 +1,155 @@ +#[cfg(target_os = "linux")] +pub(crate) mod linux; +#[cfg(target_os = "macos")] +pub(crate) mod macos; +#[cfg(test)] +mod tests; +#[cfg(windows)] +pub(crate) mod windows; + +use std::env::consts::OS; + +use defguard_client_core::version::PKG_VERSION; +use defguard_client_proto::defguard::enterprise::posture::v2::{ + BoolCheck, DevicePostureData, Int32Check, StringCheck, UnavailableReason, +}; +use sysinfo::System; + +/// Which filesystem the disk-encryption check should be evaluated against. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DiskEncryptionTarget { + /// The partition backing the client's own database, i.e. the logged-in user's data. What a + /// user-initiated posture check should report on. + ClientDatabase, + /// The root filesystem. What the service reports on: it has no user session, so there is no user + /// database to resolve, and asking for one as root would resolve to root's home directory. + RootFilesystem, +} + +/// Returns the operating system name. +fn os_name() -> Result { + System::name().ok_or(UnavailableReason::DetectionFailed) +} + +/// Returns the operating system version. +fn os_version() -> Result { + #[cfg(windows)] + { + // Windows can report versions like "11 (26200)"; core expects a parseable major. + System::os_version() + .and_then(|version| version.split_whitespace().next().map(ToString::to_string)) + .ok_or(UnavailableReason::DetectionFailed) + } + + #[cfg(not(windows))] + { + System::os_version().ok_or(UnavailableReason::DetectionFailed) + } +} + +/// Returns the Linux kernel version. +fn linux_kernel_version() -> Result { + #[cfg(target_os = "linux")] + { + System::kernel_version().ok_or(UnavailableReason::DetectionFailed) + } + + #[cfg(not(target_os = "linux"))] + { + Err(UnavailableReason::NotApplicable) + } +} + +/// Returns the disk encryption status for `target`. +fn disk_encryption_status(target: DiskEncryptionTarget) -> Result { + // Only the Linux probe is path-sensitive. + #[cfg(not(target_os = "linux"))] + let _ = target; + + #[cfg(target_os = "macos")] + { + macos::disk_encryption_status() + } + + #[cfg(windows)] + { + windows::disk_encryption_status() + } + + #[cfg(target_os = "linux")] + { + let path = match target { + DiskEncryptionTarget::ClientDatabase => defguard_client_core::database::db_file_path() + .ok_or(UnavailableReason::DetectionFailed)?, + DiskEncryptionTarget::RootFilesystem => std::path::PathBuf::from("/"), + }; + linux::disk_encryption_status(&path) + } +} + +/// Returns the antivirus status. +fn anti_virus_status() -> Result { + #[cfg(windows)] + { + windows::anti_virus_status() + } + + #[cfg(not(windows))] + { + Err(UnavailableReason::NotApplicable) + } +} + +/// Checks whether the computer is part of a domain. +fn part_of_domain() -> Result { + #[cfg(windows)] + { + windows::part_of_domain() + } + + #[cfg(not(windows))] + { + Err(UnavailableReason::NotApplicable) + } +} + +/// Returns the device integrity status. +fn device_integrity() -> Result { + #[cfg(target_os = "macos")] + { + macos::system_integrity_status() + } + + #[cfg(not(target_os = "macos"))] + Err(UnavailableReason::NotApplicable) +} + +/// Returns the number of days since the last installed Windows security update. +fn security_update_age_days() -> Result { + #[cfg(windows)] + { + windows::security_update_age_days() + } + + #[cfg(not(windows))] + { + Err(UnavailableReason::NotApplicable) + } +} + +#[must_use] +pub fn device_posture_data(disk_target: DiskEncryptionTarget) -> DevicePostureData { + DevicePostureData { + defguard_client_version: PKG_VERSION.to_owned(), + os_type: OS.to_string(), + os_name: Some(StringCheck::from(os_name())), + os_version: Some(StringCheck::from(os_version())), + disk_encryption: Some(BoolCheck::from(disk_encryption_status(disk_target))), + antivirus_present: Some(BoolCheck::from(anti_virus_status())), + windows_ad_domain_joined: Some(BoolCheck::from(part_of_domain())), + windows_security_update_age_days: Some(Int32Check::from(security_update_age_days())), + linux_kernel_version: Some(StringCheck::from(linux_kernel_version())), + device_integrity: Some(BoolCheck::from(device_integrity())), + android_security_patch_date: None, + } +} diff --git a/src-tauri/enterprise/posture/src/inspector/tests/ci/linux.rs b/src-tauri/enterprise/posture/src/inspector/tests/ci/linux.rs new file mode 100644 index 000000000..b9bde6579 --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/tests/ci/linux.rs @@ -0,0 +1,108 @@ +use std::{fs::read_to_string, process::Command}; + +use super::super::super::{ + disk_encryption_status, linux_kernel_version, os_name, os_version, DiskEncryptionTarget, +}; + +fn expected_kernel_version() -> String { + let output = Command::new("uname") + .arg("-r") + .output() + .expect("failed to execute uname -r"); + assert!(output.status.success(), "uname -r failed: {output:?}"); + + String::from_utf8(output.stdout) + .expect("uname -r returned non-UTF8 output") + .trim() + .to_owned() +} + +fn expected_os_version() -> String { + if let Ok(os_release) = read_to_string("/etc/os-release") { + if let Some(value) = os_release + .lines() + .find_map(|line| line.strip_prefix("VERSION_ID=")) + { + return value.replace('"', ""); + } + } + let lsb_release = read_to_string("/etc/lsb-release").expect("failed to read /etc/lsb-release"); + lsb_release + .lines() + .find_map(|line| line.strip_prefix("DISTRIB_RELEASE=")) + .map(|value| value.replace('"', "")) + .expect("DISTRIB_RELEASE missing from /etc/lsb-release") +} + +fn expected_os_name() -> String { + if let Ok(os_release) = read_to_string("/etc/os-release") { + if let Some(value) = os_release + .lines() + .find_map(|line| line.strip_prefix("NAME=")) + { + return value.replace('"', ""); + } + } + let lsb_release = read_to_string("/etc/lsb-release").expect("failed to read /etc/lsb-release"); + lsb_release + .lines() + .find_map(|line| line.strip_prefix("DISTRIB_ID=")) + .map(|value| value.replace('"', "")) + .expect("DISTRIB_ID missing from /etc/lsb-release") +} + +mod setup1 { + use super::*; + + #[test] + #[ignore = "CI posture testing only"] + fn test_linux_os_name() { + assert_eq!(os_name().unwrap(), expected_os_name()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_linux_os_version() { + assert_eq!(os_version().unwrap(), expected_os_version()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_linux_kernel_version() { + assert_eq!(linux_kernel_version().unwrap(), expected_kernel_version()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_disk_encryption_status_unencrypted() { + assert!(!disk_encryption_status(DiskEncryptionTarget::ClientDatabase).unwrap()); + } +} + +mod setup2 { + use super::*; + + #[test] + #[ignore = "CI posture testing only"] + fn test_linux_os_name() { + assert_eq!(os_name().unwrap(), expected_os_name()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_linux_os_version() { + assert_eq!(os_version().unwrap(), expected_os_version()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_linux_kernel_version() { + assert_eq!(linux_kernel_version().unwrap(), expected_kernel_version()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_disk_encryption_status_encrypted() { + assert!(disk_encryption_status(DiskEncryptionTarget::ClientDatabase).unwrap()); + } +} diff --git a/src-tauri/enterprise/posture/src/inspector/tests/ci/macos.rs b/src-tauri/enterprise/posture/src/inspector/tests/ci/macos.rs new file mode 100644 index 000000000..e38fcec5a --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/tests/ci/macos.rs @@ -0,0 +1,76 @@ +use std::process::Command; + +use super::super::super::{ + device_integrity, disk_encryption_status, os_name, os_version, DiskEncryptionTarget, +}; + +fn expected_os_version() -> String { + let output = Command::new("sw_vers") + .arg("-productVersion") + .output() + .expect("failed to execute sw_vers -productVersion"); + assert!( + output.status.success(), + "sw_vers -productVersion failed: {output:?}" + ); + String::from_utf8(output.stdout) + .expect("sw_vers returned non-UTF8 output") + .trim() + .to_owned() +} + +mod setup1 { + use super::*; + + #[test] + #[ignore = "CI posture testing only"] + fn test_os_name() { + assert_eq!(os_name().unwrap(), "Darwin"); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_os_version() { + assert_eq!(os_version().unwrap(), expected_os_version()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_device_integrity() { + assert!(device_integrity().unwrap()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_disk_encryption_status_unencrypted() { + assert!(!disk_encryption_status(DiskEncryptionTarget::ClientDatabase).unwrap()); + } +} + +mod setup2 { + use super::*; + + #[test] + #[ignore = "CI posture testing only"] + fn test_os_name() { + assert_eq!(os_name().unwrap(), "Darwin"); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_os_version() { + assert_eq!(os_version().unwrap(), expected_os_version()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_device_integrity() { + assert!(!device_integrity().unwrap()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_disk_encryption_status_unencrypted() { + assert!(disk_encryption_status(DiskEncryptionTarget::ClientDatabase).unwrap()); + } +} diff --git a/src-tauri/enterprise/posture/src/inspector/tests/ci/mod.rs b/src-tauri/enterprise/posture/src/inspector/tests/ci/mod.rs new file mode 100644 index 000000000..0337daa13 --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/tests/ci/mod.rs @@ -0,0 +1,6 @@ +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; diff --git a/src-tauri/enterprise/posture/src/inspector/tests/ci/windows.rs b/src-tauri/enterprise/posture/src/inspector/tests/ci/windows.rs new file mode 100644 index 000000000..64dfdd87d --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/tests/ci/windows.rs @@ -0,0 +1,119 @@ +use std::process::Command; + +use super::super::super::{ + anti_virus_status, disk_encryption_status, os_name, os_version, part_of_domain, + security_update_age_days, DiskEncryptionTarget, +}; + +fn expected_security_update_age_days() -> i32 { + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + r#" + $today = (Get-Date).ToUniversalTime().Date + Get-CimInstance Win32_QuickFixEngineering | + Where-Object { $_.InstalledOn } | + ForEach-Object { ($today - ([datetime]$_.InstalledOn).Date).Days } | + Sort-Object | + Select-Object -First 1 + "#, + ]) + .output() + .expect("failed to query Windows security updates"); + assert!( + output.status.success(), + "PowerShell query failed: {output:?}" + ); + String::from_utf8(output.stdout) + .expect("PowerShell returned non-UTF8 output") + .trim() + .parse() + .expect("PowerShell did not return an integer update age") +} + +mod setup1 { + use super::*; + + #[test] + #[ignore = "CI posture testing only"] + fn test_os_name() { + assert_eq!(os_name().unwrap(), "Windows"); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_os_version() { + assert_eq!(os_version().unwrap(), "11"); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_anti_virus_status_on() { + assert!(anti_virus_status().unwrap()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_part_of_domain_false() { + assert!(!part_of_domain().unwrap()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_security_update_age_days() { + assert_eq!( + security_update_age_days().unwrap(), + expected_security_update_age_days() + ); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_disk_encryption_status_unencrypted() { + assert!(!disk_encryption_status(DiskEncryptionTarget::ClientDatabase).unwrap()); + } +} + +mod setup2 { + use super::*; + + #[test] + #[ignore = "CI posture testing only"] + fn test_os_name() { + assert_eq!(os_name().unwrap(), "Windows"); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_os_version() { + assert_eq!(os_version().unwrap(), "11"); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_anti_virus_status_off() { + assert!(!anti_virus_status().unwrap()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_part_of_domain_true() { + assert!(part_of_domain().unwrap()); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_security_update_age_days() { + assert_eq!( + security_update_age_days().unwrap(), + expected_security_update_age_days() + ); + } + + #[test] + #[ignore = "CI posture testing only"] + fn test_disk_encryption_status_encrypted() { + assert!(disk_encryption_status(DiskEncryptionTarget::ClientDatabase).unwrap()); + } +} diff --git a/src-tauri/enterprise/posture/src/inspector/tests/linux.rs b/src-tauri/enterprise/posture/src/inspector/tests/linux.rs new file mode 100644 index 000000000..a882aac00 --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/tests/linux.rs @@ -0,0 +1,17 @@ +use super::super::{disk_encryption_status, os_name, os_version, DiskEncryptionTarget}; + +#[test] +fn test_os_name() { + assert!(os_name().is_ok()); +} + +#[test] +fn test_os_version() { + assert!(os_version().is_ok()); +} + +#[test] +#[ignore = "development machine only"] +fn test_disk_encryption() { + assert!(!disk_encryption_status(DiskEncryptionTarget::ClientDatabase).unwrap()); +} diff --git a/src-tauri/enterprise/posture/src/inspector/tests/macos.rs b/src-tauri/enterprise/posture/src/inspector/tests/macos.rs new file mode 100644 index 000000000..81fb88ed9 --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/tests/macos.rs @@ -0,0 +1,25 @@ +use super::super::{ + device_integrity, disk_encryption_status, os_name, os_version, DiskEncryptionTarget, +}; + +#[test] +fn test_os_name() { + assert_eq!(os_name().unwrap(), "Darwin"); +} + +#[test] +fn test_os_version() { + assert!(os_version().is_ok()); +} + +#[test] +#[ignore = "development machine only"] +fn test_disk_encryption() { + assert!(disk_encryption_status(DiskEncryptionTarget::ClientDatabase).unwrap()); +} + +#[test] +#[ignore = "development machine only"] +fn test_device_integrity() { + assert!(device_integrity().unwrap()); +} diff --git a/src-tauri/enterprise/posture/src/inspector/tests/mod.rs b/src-tauri/enterprise/posture/src/inspector/tests/mod.rs new file mode 100644 index 000000000..7efa4fd9a --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/tests/mod.rs @@ -0,0 +1,7 @@ +mod ci; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; diff --git a/src-tauri/enterprise/posture/src/inspector/tests/windows.rs b/src-tauri/enterprise/posture/src/inspector/tests/windows.rs new file mode 100644 index 000000000..e6844e14f --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/tests/windows.rs @@ -0,0 +1,38 @@ +use super::super::{ + anti_virus_status, disk_encryption_status, os_name, os_version, part_of_domain, + security_update_age_days, DiskEncryptionTarget, +}; + +#[test] +fn test_os_name() { + assert_eq!(os_name().unwrap(), "Windows"); +} + +#[test] +fn test_os_version() { + assert!(os_version().is_ok()); +} + +#[test] +#[ignore = "development machine only"] +fn test_disk_encryption() { + assert!(!disk_encryption_status(DiskEncryptionTarget::ClientDatabase).unwrap()); +} + +#[test] +#[ignore = "development machine only"] +fn test_anti_virus() { + assert!(anti_virus_status().unwrap()); +} + +#[test] +#[ignore = "development machine only"] +fn test_part_of_domain() { + assert!(!part_of_domain().unwrap()); +} + +#[test] +#[ignore = "development machine only"] +fn test_security_update_age_days() { + assert!(security_update_age_days().unwrap() >= 0); +} diff --git a/src-tauri/enterprise/posture/src/inspector/windows.rs b/src-tauri/enterprise/posture/src/inspector/windows.rs new file mode 100644 index 000000000..42cf30b5e --- /dev/null +++ b/src-tauri/enterprise/posture/src/inspector/windows.rs @@ -0,0 +1,180 @@ +// TODO: use `async_raw_query` + +use serde::Deserialize; +use time::{Date, OffsetDateTime}; +use wmi::{AuthLevel, WMIConnection}; + +use super::UnavailableReason; + +#[derive(Deserialize)] +#[serde(rename = "Win32_EncryptableVolume")] +#[serde(rename_all = "PascalCase")] +struct Win32EncryptableVolume { + drive_letter: Option, + // 0 = unprotected, 1 = protected, 2 = unknown + protection_status: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AntiVirusProduct { + display_name: String, + product_state: u32, +} + +#[derive(Deserialize)] +#[serde(rename = "MSFT_MpComputerStatus")] +#[serde(rename_all = "PascalCase")] +struct MpComputerStatus { + antivirus_enabled: bool, + real_time_protection_enabled: bool, +} + +#[derive(Deserialize)] +#[serde(rename = "Win32_ComputerSystem")] +#[serde(rename_all = "PascalCase")] +struct Win32ComputerSystem { + part_of_domain: bool, +} + +#[derive(Deserialize)] +#[serde(rename = "Win32_OperatingSystem")] +#[serde(rename_all = "PascalCase")] +struct Win32OperatingSystem { + system_drive: String, +} + +// Custom format for `installed_on`. +time::serde::format_description!( + wmidate, + Date, + "[month padding:none]/[day padding:none]/[year]" +); + +#[derive(Deserialize)] +#[serde(rename = "Win32_QuickFixEngineering")] +#[serde(rename_all = "PascalCase")] +struct Win32QuickFixEngineering { + #[serde(with = "wmidate::option", default)] + installed_on: Option, + //description: Option, // "Update" or "Security Update" +} + +/// Determine system drive letter. +fn system_drive_letter() -> Result { + let conn = WMIConnection::new()?; + let mut results: Vec = conn.query()?; + match results.pop() { + Some(result) => Ok(result.system_drive), + None => Err(UnavailableReason::DetectionFailed), + } +} + +/// This requires Administrator access, and only detects BitLocker for drive C:. +/// +/// Equivalent to PowerShell command: +/// `Get-WmiObject -Namespace "root\CIMV2\Security\MicrosoftVolumeEncryption" -query "SELECT * FROM Win32_EncryptableVolume"` +pub(super) fn disk_encryption_status() -> Result { + let system_drive_letter = system_drive_letter()?; + + let conn = + WMIConnection::with_namespace_path("root\\CIMV2\\Security\\MicrosoftVolumeEncryption")?; + conn.set_proxy_blanket(AuthLevel::PktPrivacy)?; + + let volumes: Vec = conn.query()?; + for volume in volumes { + if let Some(drive_letter) = volume.drive_letter { + if drive_letter == system_drive_letter { + return match volume.protection_status { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(UnavailableReason::DetectionFailed), + }; + } + } + } + + Err(UnavailableReason::DetectionFailed) +} + +fn antivirus_product_active(product_state: u32) -> bool { + (product_state & 0x0000_F000) == 0x0000_1000 +} + +fn defender_realtime_enabled() -> Result { + let conn = WMIConnection::with_namespace_path("root\\Microsoft\\Windows\\Defender")?; + let statuses: Vec = conn.query()?; + let status = statuses + .into_iter() + .next() + .ok_or(UnavailableReason::DetectionFailed)?; + + Ok(status.antivirus_enabled && status.real_time_protection_enabled) +} + +/// Determine AntiVirus status. +/// +/// Check manually in PowerShell: +/// `Get-CimInstance -Namespace root\SecurityCenter2 -ClassName AntivirusProduct` +/// +/// Equivalent to PowerShell command: +/// `Get-WmiObject -Namespace "root\SecurityCenter2" -query "SELECT * FROM AntiVirusProduct"` +/// +/// For third-party products, SecurityCenter2's `productState` indicates whether protection is +/// active. Windows Defender can keep reporting an active product state even when real-time +/// protection is disabled, so Defender is verified with `MSFT_MpComputerStatus`. +pub(super) fn anti_virus_status() -> Result { + let conn = WMIConnection::with_namespace_path("root\\SecurityCenter2")?; + let products: Vec = conn.query()?; + + for product in products { + if !antivirus_product_active(product.product_state) { + continue; + } + + if product.display_name == "Windows Defender" { + if defender_realtime_enabled()? { + return Ok(true); + } + } else { + return Ok(true); + } + } + + Ok(false) +} + +/// Check if this machine is part of an Active Directory domain. +/// +/// Check manually in PowerShell: +/// `Get-CimInstance -ClassName Win32_ComputerSystem` +/// +/// Equivalent to PowerShell command: +/// `Get-WmiObject -query "SELECT * FROM Win32_ComputerSystem"` +pub(super) fn part_of_domain() -> Result { + let conn = WMIConnection::new()?; + let system = conn.get::()?; + Ok(system.part_of_domain) +} + +/// Number of days since the most recently installed security patch. +/// +/// Check manually in PowerShell: +/// `Get-CimInstance -ClassName Win32_QuickFixEngineering` +/// +/// Equivalent to PowerShell command: +/// `Get-WmiObject -query "SELECT * FROM Win32_QuickFixEngineering"` +pub(super) fn security_update_age_days() -> Result { + let conn = WMIConnection::new()?; + let fixes: Vec = conn.query()?; + + let today = OffsetDateTime::now_utc().date(); + let min_days = fixes + .into_iter() + .filter_map(|fix| fix.installed_on) + .map(|installed_on| (today - installed_on).whole_days()) + .min() + .ok_or(UnavailableReason::DetectionFailed)?; + + i32::try_from(min_days).map_err(|_| UnavailableReason::DetectionFailed) +} diff --git a/src-tauri/enterprise/posture/src/lib.rs b/src-tauri/enterprise/posture/src/lib.rs new file mode 100644 index 000000000..4e80e73b2 --- /dev/null +++ b/src-tauri/enterprise/posture/src/lib.rs @@ -0,0 +1,7 @@ +#[macro_use] +extern crate log; + +pub mod inspector; +pub mod posture; + +pub use posture::{authorize_posture_session, get_posture_data, request_posture_authorization}; diff --git a/src-tauri/enterprise/posture/src/posture.rs b/src-tauri/enterprise/posture/src/posture.rs new file mode 100644 index 000000000..b1d59366b --- /dev/null +++ b/src-tauri/enterprise/posture/src/posture.rs @@ -0,0 +1,263 @@ +#[cfg(windows)] +use defguard_client_core::connection::daemon_client::DAEMON_CLIENT; +use defguard_client_core::{ + database::{ + models::{instance::Instance, location::Location, wireguard_keys::WireguardKeys, Id}, + DB_POOL, + }, + error::Error, + proxy::post_with_headers, +}; +use defguard_client_proto::defguard::enterprise::posture::v2::{ + DevicePostureCheckRequest, DevicePostureCheckResponse, DevicePostureData, +}; +use reqwest::{StatusCode, Url}; +use serde::Deserialize; + +#[cfg(not(windows))] +use crate::inspector::{device_posture_data, DiskEncryptionTarget}; + +const POSTURE_ENDPOINT: &str = "/api/v1/posture/connect"; + +/// Collects device posture data, sends it to the proxy, and returns the optional runtime preshared +/// key. Core approves without a key when posture checks were removed from the location. +pub async fn authorize_posture_session(location: &Location) -> Result, Error> { + let instance = Instance::find_by_id(&*DB_POOL, location.instance_id) + .await? + .ok_or(Error::NotFound)?; + + let keys = WireguardKeys::find_by_instance_id(&*DB_POOL, location.instance_id) + .await? + .ok_or_else(|| { + Error::ResourceNotFound(format!( + "WireGuard keys not found for instance {}", + location.instance_id + )) + })?; + + // Posture checks are authenticated with the instance's config polling token. + let token = instance + .token + .clone() + .filter(|token| !token.is_empty()) + .ok_or(Error::NoToken)?; + + let posture_data = get_posture_data().await?; + + request_posture_authorization( + &instance.proxy_url, + keys.pubkey, + location.network_id, + token, + posture_data, + ) + .await +} + +/// Sends a posture check to the proxy and returns the optional runtime preshared key on approval. +/// +/// Note `device_pubkey` is the *device's* WireGuard public key, not a remote peer key, and +/// `location_id` is core's `WireguardNetwork` id (`Location::network_id`), not the client-local +/// location id. +pub async fn request_posture_authorization( + proxy_url: &str, + device_pubkey: String, + location_id: Id, + token: String, + posture_data: DevicePostureData, +) -> Result, Error> { + let request = DevicePostureCheckRequest { + location_id, + pubkey: device_pubkey, + device_posture_data: Some(posture_data), + token: Some(token), + }; + + let url = Url::parse(proxy_url) + .map_err(|e| Error::InternalError(format!("Invalid proxy URL: {e}")))? + .join(POSTURE_ENDPOINT) + .map_err(|e| Error::InternalError(format!("Failed to build posture URL: {e}")))?; + + debug!("Sending posture check request to {url}"); + let response = post_with_headers(url, &request) + .await + .map_err(|e| Error::ServiceUnavailable(e.to_string()))?; + + match response.status() { + StatusCode::OK => { + let body: DevicePostureCheckResponse = response + .json() + .await + .map_err(|e| Error::HttpError(e.to_string()))?; + info!("Posture check approved for location {location_id}"); + Ok((!body.preshared_key.is_empty()).then_some(body.preshared_key)) + } + StatusCode::FORBIDDEN => { + #[derive(Deserialize)] + struct PostureRejection { + error: String, + } + let body: PostureRejection = response + .json() + .await + .map_err(|e| Error::HttpError(e.to_string()))?; + error!( + "Posture check rejected for location {location_id}: {}", + body.error + ); + Err(Error::PostureCheckFailed(body.error)) + } + status if status.is_server_error() => Err(Error::ServiceUnavailable(format!( + "Unexpected proxy response: {status}" + ))), + status => Err(Error::HttpError(format!( + "Unexpected proxy response: {status}" + ))), + } +} + +/// Collects this device's posture data for a *user-initiated* check. +pub async fn get_posture_data() -> Result { + #[cfg(windows)] + { + DAEMON_CLIENT + .clone() + .get_posture_data(tonic::Request::new(())) + .await + .map(|response| response.into_inner()) + .map_err(|err| { + error!("Failed to get posture data from the daemon: {err}"); + Error::InternalError(format!("Failed to get posture data from the daemon: {err}")) + }) + } + #[cfg(not(windows))] + { + Ok(device_posture_data(DiskEncryptionTarget::ClientDatabase)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wiremock::{ + matchers::{body_partial_json, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + + async fn request(server: &MockServer) -> Result, Error> { + request_posture_authorization( + &server.uri(), + "device-key".into(), + 42, + "polling-token".into(), + DevicePostureData::default(), + ) + .await + } + + #[tokio::test] + async fn test_approved_posture_returns_preshared_key() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(POSTURE_ENDPOINT)) + .and(body_partial_json(json!({ + "location_id": 42, + "pubkey": "device-key", + "token": "polling-token", + "device_posture_data": {}, + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "preshared_key": "session-key", + }))) + .mount(&server) + .await; + + assert_eq!( + request(&server).await.unwrap().as_deref(), + Some("session-key") + ); + } + + #[tokio::test] + async fn test_approval_with_empty_key_returns_none() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(POSTURE_ENDPOINT)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "preshared_key": "", + }))) + .mount(&server) + .await; + + assert_eq!(request(&server).await.unwrap(), None); + } + + #[tokio::test] + async fn test_forbidden_response_is_a_posture_failure() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(POSTURE_ENDPOINT)) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "error": "disk encryption required", + }))) + .mount(&server) + .await; + + let err = request(&server).await.unwrap_err(); + assert!(matches!( + err, + Error::PostureCheckFailed(message) if message == "disk encryption required" + )); + } + + #[tokio::test] + async fn test_server_error_is_service_unavailable() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(POSTURE_ENDPOINT)) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + assert!(matches!( + request(&server).await.unwrap_err(), + Error::ServiceUnavailable(_) + )); + } + + #[tokio::test] + async fn test_malformed_success_response_is_an_http_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(POSTURE_ENDPOINT)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "unexpected": true }))) + .mount(&server) + .await; + + assert!(matches!( + request(&server).await.unwrap_err(), + Error::HttpError(_) + )); + } + + #[tokio::test] + async fn test_transport_failure_is_service_unavailable() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let unavailable_url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + + let err = request_posture_authorization( + &unavailable_url, + "device-key".into(), + 42, + "polling-token".into(), + DevicePostureData::default(), + ) + .await + .unwrap_err(); + + assert!(matches!(err, Error::ServiceUnavailable(_))); + } +} diff --git a/src-tauri/enterprise/provisioning/Cargo.toml b/src-tauri/enterprise/provisioning/Cargo.toml new file mode 100644 index 000000000..107409dbb --- /dev/null +++ b/src-tauri/enterprise/provisioning/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "defguard-client-provisioning" +description = "Zero-touch provisioning for the Defguard desktop client" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license = "LicenseRef-Proprietary" +rust-version.workspace = true +version.workspace = true + +[dependencies] +defguard-client-core = { path = "../../core" } +log.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/src-tauri/enterprise/provisioning/src/lib.rs b/src-tauri/enterprise/provisioning/src/lib.rs new file mode 100644 index 000000000..221c30d39 --- /dev/null +++ b/src-tauri/enterprise/provisioning/src/lib.rs @@ -0,0 +1,131 @@ +use std::{fmt, fs, path::Path}; + +use log::{debug, warn}; +use serde::{Deserialize, Serialize}; + +const CONFIG_FILE_NAME: &str = "provisioning.json"; + +#[derive(Clone, Deserialize, Serialize)] +pub struct ProvisioningConfig { + pub enrollment_url: String, + pub enrollment_token: String, +} + +impl fmt::Debug for ProvisioningConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + enrollment_url, + enrollment_token: _, + } = self; + + f.debug_struct("ProvisioningConfig") + .field("enrollment_url", enrollment_url) + .field("enrollment_token", &"***") + .finish() + } +} + +impl ProvisioningConfig { + /// Load configuration from a file at `path`. + fn load(path: &Path) -> Option { + let file_content = match fs::read_to_string(path) { + Ok(content) => content, + Err(err) => { + warn!( + "Failed to open provisioning configuration file at {}. Error details: {err}", + path.display() + ); + return None; + } + }; + + let file_content = file_content.trim_start_matches('\u{FEFF}'); + + match serde_json::from_str::(file_content) { + Ok(config) => Some(config), + Err(err) => { + warn!( + "Failed to parse provisioning configuration file at {}. Error details: {err}", + path.display() + ); + None + } + } + } +} + +/// Try to find and load the provisioning configuration from the given app data directory. +#[must_use] +pub fn try_get_provisioning_config(app_data_dir: &Path) -> Option { + debug!( + "Trying to find provisioning config in {}", + app_data_dir.display() + ); + + let config_file_path = app_data_dir.join(CONFIG_FILE_NAME); + ProvisioningConfig::load(&config_file_path) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn test_load_valid_config() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join(CONFIG_FILE_NAME), + r#"{"enrollment_url":"https://enroll","enrollment_token":"secret"}"#, + ) + .unwrap(); + + let config = try_get_provisioning_config(dir.path()).expect("config should load"); + assert_eq!(config.enrollment_url, "https://enroll"); + assert_eq!(config.enrollment_token, "secret"); + } + + #[test] + fn test_missing_file_returns_none() { + let dir = tempdir().unwrap(); + assert!(try_get_provisioning_config(dir.path()).is_none()); + } + + #[test] + fn test_malformed_json_returns_none() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(CONFIG_FILE_NAME), b"{ not valid json").unwrap(); + assert!(try_get_provisioning_config(dir.path()).is_none()); + } + + #[test] + fn test_bom_prefixed_config_parses() { + let dir = tempdir().unwrap(); + // A UTF-8 BOM prefix must be tolerated. + let content = + "\u{FEFF}{\"enrollment_url\":\"https://enroll\",\"enrollment_token\":\"secret\"}"; + fs::write(dir.path().join(CONFIG_FILE_NAME), content).unwrap(); + + let config = + try_get_provisioning_config(dir.path()).expect("BOM-prefixed config should load"); + assert_eq!(config.enrollment_url, "https://enroll"); + assert_eq!(config.enrollment_token, "secret"); + } + + #[test] + fn test_debug_redacts_token() { + let config = ProvisioningConfig { + enrollment_url: "https://enroll".into(), + enrollment_token: "super-secret".into(), + }; + + let rendered = format!("{config:?}"); + assert!(rendered.contains("***")); + assert!(!rendered.contains("super-secret")); + // The non-sensitive URL is still shown. + assert!(rendered.contains("https://enroll")); + } +} diff --git a/src-tauri/enterprise/service-locations/Cargo.toml b/src-tauri/enterprise/service-locations/Cargo.toml new file mode 100644 index 000000000..c70f03fc7 --- /dev/null +++ b/src-tauri/enterprise/service-locations/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "defguard-client-service-locations" +description = "Service location management for the Defguard client daemon" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license = "LicenseRef-Proprietary" +rust-version.workspace = true +version.workspace = true + +[dependencies] +defguard-client-common = { path = "../../common" } +defguard-client-core = { path = "../../core" } +defguard-client-posture = { path = "../posture" } +defguard-client-proto = { path = "../../client-proto" } +defguard_wireguard_rs.workspace = true +base64.workspace = true +futures-util.workspace = true +log.workspace = true +prost.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["time"] } +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[target.'cfg(windows)'.dependencies] +known-folders = "1.4" +windows = "0.62" +windows-acl = "0.3" +windows-service = "0.8" +windows-sys = "0.61" diff --git a/src-tauri/enterprise/service-locations/src/lib.rs b/src-tauri/enterprise/service-locations/src/lib.rs new file mode 100644 index 000000000..2c028c9b1 --- /dev/null +++ b/src-tauri/enterprise/service-locations/src/lib.rs @@ -0,0 +1,579 @@ +use std::{collections::HashMap, fmt, fs, path::Path, time::SystemTime}; +#[cfg(any(windows, target_os = "linux", test))] +use std::{ffi::OsStr, path::PathBuf}; + +use defguard_client_core::{ + database::models::{ + location::{Location, ServiceLocationMode}, + Id, + }, + error::Error as CoreError, +}; +use defguard_client_proto::defguard::client::v1::{ + SaveServiceLocationsRequest, ServiceLocation, ServiceLocationMode as ProtoServiceLocationMode, +}; +use defguard_wireguard_rs::{error::WireguardInterfaceError, WGApi}; +#[cfg(any(windows, target_os = "linux"))] +use log::debug; +use log::warn; +use serde::{Deserialize, Serialize}; +use uuid::fmt::Hyphenated; + +#[cfg(target_os = "linux")] +pub mod linux; +#[cfg(any(windows, target_os = "linux"))] +pub mod reconciler; +#[cfg(windows)] +pub mod windows; + +/// Current schema version of the on-disk service location JSON file. +pub const SERVICE_LOCATION_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, thiserror::Error)] +pub enum ServiceLocationError { + #[error("Error occurred while initializing service location API: {0}")] + InitError(String), + #[error("Failed to load service location storage: {0}")] + LoadError(String), + #[error("Invalid instance ID: {0}")] + InvalidInstanceId(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + DecodeError(#[from] base64::DecodeError), + #[error(transparent)] + WireGuardError(#[from] WireguardInterfaceError), + #[error(transparent)] + AddrParseError(#[from] defguard_wireguard_rs::net::IpAddrParseError), + #[error("WireGuard interface error: {0}")] + InterfaceError(String), + #[error(transparent)] + JsonError(#[from] serde_json::Error), + #[error(transparent)] + ProtoEnumError(#[from] prost::UnknownEnumValue), + #[cfg(windows)] + #[error(transparent)] + WindowsServiceError(#[from] windows_service::Error), +} + +#[allow(dead_code)] +#[derive(Default)] +pub struct ServiceLocationManager { + // Interface name: WireGuard API instance + wgapis: HashMap, + // Instance ID: Service locations connected under that instance + connected_service_locations: HashMap>, +} + +/// Runtime state for a connected location. +#[derive(Clone)] +struct ConnectedServiceLocation { + location: ServiceLocation, + /// Records when a posture-gated service location was authorized for staleness detection. + authorized_at: Option, +} + +#[cfg(any(windows, target_os = "linux"))] +impl ServiceLocationManager { + fn connected_service_location( + &self, + instance_id: &str, + location_pubkey: &str, + ) -> Option<&ConnectedServiceLocation> { + self.connected_service_locations + .get(instance_id)? + .iter() + .find(|connected| connected.location.pubkey == location_pubkey) + } + + fn connected_service_location_mut( + &mut self, + instance_id: &str, + location_pubkey: &str, + ) -> Option<&mut ConnectedServiceLocation> { + self.connected_service_locations + .get_mut(instance_id)? + .iter_mut() + .find(|connected| connected.location.pubkey == location_pubkey) + } + + fn is_service_location_connected(&self, instance_id: &str, location_pubkey: &str) -> bool { + self.connected_service_location(instance_id, location_pubkey) + .is_some() + } + + fn add_connected_service_location(&mut self, instance_id: &str, location: &ServiceLocation) { + self.connected_service_locations + .entry(instance_id.to_string()) + .or_default() + .push(ConnectedServiceLocation { + location: location.clone(), + authorized_at: None, + }); + + debug!( + "Added connected service location for instance '{instance_id}', location '{}'", + location.name + ); + } + + fn record_posture_session(&mut self, instance_id: &str, location_pubkey: &str) { + if let Some(connected) = self.connected_service_location_mut(instance_id, location_pubkey) { + connected.authorized_at = Some(SystemTime::now()); + } + } +} + +#[allow(dead_code)] +#[derive(Serialize, Deserialize)] +pub struct ServiceLocationData { + pub service_locations: Vec, + pub instance_id: String, + pub private_key: String, + #[serde(default)] + pub proxy_url: String, + #[serde(default)] + pub device_pubkey: String, + /// Device polling token, used to authenticate posture requests made by the service. + #[serde(default)] + pub token: Option, + #[serde(default)] + pub schema_version: u32, +} + +#[allow(dead_code)] +pub struct SingleServiceLocationData { + pub service_location: ServiceLocation, + pub instance_id: String, + pub private_key: String, +} + +impl fmt::Debug for ServiceLocationData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ServiceLocationData") + .field("service_locations", &self.service_locations) + .field("instance_id", &self.instance_id) + .field("private_key", &"***") + .field("proxy_url", &self.proxy_url) + .field("device_pubkey", &self.device_pubkey) + .field("token", &self.token.as_ref().map(|_| "***")) + .field("schema_version", &self.schema_version) + .finish() + } +} + +impl ServiceLocationData { + /// Builds the on-disk representation from a daemon save request. + /// + /// `service_locations` is passed separately rather than taken from the request because each + /// platform first filters the requested set down to the modes it supports. + #[must_use] + pub fn from_save_request( + request: &SaveServiceLocationsRequest, + service_locations: Vec, + ) -> Self { + Self { + service_locations, + instance_id: request.instance_id.clone(), + private_key: request.private_key.clone(), + proxy_url: request.proxy_url.clone(), + device_pubkey: request.device_pubkey.clone(), + token: request.token.clone(), + schema_version: SERVICE_LOCATION_SCHEMA_VERSION, + } + } +} + +impl fmt::Debug for SingleServiceLocationData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SingleServiceLocationData") + .field("service_locations", &self.service_location) + .field("instance_id", &self.instance_id) + .field("private_key", &"***") + .finish() + } +} + +pub fn validate_instance_id(instance_id: &str) -> Result { + instance_id + .parse::() + .map(|uuid| uuid.to_string()) + .map_err(|_| ServiceLocationError::InvalidInstanceId(instance_id.to_string())) +} + +#[cfg(any(windows, target_os = "linux", test))] +fn instance_file_path( + directory: &Path, + instance_id: &str, +) -> Result { + Ok(directory.join(format!("{}.json", validate_instance_id(instance_id)?))) +} + +/// Whether the file at `path` already holds exactly `contents`. Makes a save idempotent thus +/// allowing pushing service locations on every poll cycle. +/// +/// A read failure counts as "differs", so a missing or unreadable file is simply rewritten. +#[must_use] +pub fn is_unchanged_on_disk(path: &Path, contents: &str) -> bool { + fs::read_to_string(path).is_ok_and(|existing| existing == contents) +} + +#[cfg(any(windows, target_os = "linux", test))] +fn load_service_locations_from_file( + path: &Path, +) -> Result, ServiceLocationError> { + if !path.exists() { + return Ok(None); + } + + let data = fs::read_to_string(path)?; + Ok(Some(serde_json::from_str(&data)?)) +} + +#[cfg(any(windows, target_os = "linux", test))] +fn load_service_locations_from_directory( + directory: &Path, +) -> Result, ServiceLocationError> { + if !directory.exists() { + return Ok(Vec::new()); + } + + let mut instances = Vec::new(); + for entry in fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() || path.extension() != Some(OsStr::new("json")) { + continue; + } + + match load_service_locations_from_file(&path) { + Ok(Some(data)) => instances.push(data), + Ok(None) => {} + Err(err) => warn!( + "Failed to load service locations from file {}: {err}", + path.display() + ), + } + } + + Ok(instances) +} + +pub fn to_service_location(location: &Location) -> Result { + if !location.is_service_location() { + warn!("Location {location} is not a service location, so it can't be converted to one."); + return Err(CoreError::ConversionError(format!( + "Failed to convert location {location} to a service location as it's either not marked \ + as one or has MFA enabled." + ))); + } + + let mode = match location.service_location_mode { + ServiceLocationMode::Disabled => { + warn!( + "Location {location} has an invalid service location mode, so it can't be converted to \ + one." + ); + return Err(CoreError::ConversionError(format!( + "Location {location} has an invalid service location mode ({:?}), so it can't be \ + converted to one.", + location.service_location_mode + ))); + } + ServiceLocationMode::PreLogon => ProtoServiceLocationMode::PreLogon as i32, + ServiceLocationMode::AlwaysOn => ProtoServiceLocationMode::AlwaysOn as i32, + }; + + Ok(ServiceLocation { + name: location.name.clone(), + address: location.address.clone(), + pubkey: location.pubkey.clone(), + endpoint: location.endpoint.clone(), + allowed_ips: location.allowed_ips.clone(), + dns: location.dns.clone().unwrap_or_default(), + keepalive_interval: location.keepalive_interval.try_into().unwrap_or(0), + mode, + core_location_id: location.network_id, + posture_check_required: location.posture_check_required, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_INSTANCE_ID: &str = "0f8fad5b-d9cb-469f-a165-70867728950e"; + + #[test] + fn test_instance_id_is_normalized() { + assert_eq!( + validate_instance_id("0F8FAD5B-D9CB-469F-A165-70867728950E").unwrap(), + VALID_INSTANCE_ID + ); + } + + #[test] + fn test_non_uuid_instance_ids_are_rejected() { + let invalid = [ + "", + "..", + "../../etc/defguard/evil", + "..\\..\\Windows\\Temp\\evil", + "C:\\Windows\\Temp\\evil", + "/etc/defguard/evil", + "0f8fad5b-d9cb-469f-a165-70867728950e/../evil", + "0f8fad5b-d9cb-469f-a165-70867728950", + "0f8fad5b-d9cb-469f-a165-70867728950eb", + "0f8fad5b-d9cb-469f-a165-7086772895ez", + "0f8fad5bd9cb469fa16570867728950e", + "{0f8fad5b-d9cb-469f-a165-70867728950e}", + "urn:uuid:0f8fad5b-d9cb-469f-a165-70867728950e", + ]; + + let dir = tempfile::tempdir().expect("failed to create temp dir"); + + for instance_id in invalid { + assert!( + validate_instance_id(instance_id).is_err(), + "instance ID {instance_id} should be rejected" + ); + assert!( + instance_file_path(dir.path(), instance_id).is_err(), + "instance ID {instance_id} must not produce a file path" + ); + } + } + + #[test] + fn test_instance_file_path_stays_in_the_storage_directory() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let path = instance_file_path(dir.path(), VALID_INSTANCE_ID).unwrap(); + + assert_eq!(path.parent(), Some(dir.path())); + assert_eq!( + path.file_name().unwrap(), + OsStr::new(&format!("{VALID_INSTANCE_ID}.json")) + ); + } + + /// A save is a no-op only if this comparison is exact: getting it wrong does not merely cost a + /// disk write, it drops and rebuilds every tunnel on the box. + #[test] + fn test_unchanged_contents_are_detected() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let path = dir.path().join("locations.json"); + + assert!( + !is_unchanged_on_disk(&path, "first"), + "a missing file must count as changed" + ); + + fs::write(&path, "first").expect("failed to write"); + assert!( + is_unchanged_on_disk(&path, "first"), + "identical contents must be detected as unchanged" + ); + assert!( + !is_unchanged_on_disk(&path, "second"), + "different contents must be detected as changed" + ); + assert!( + !is_unchanged_on_disk(&path, "first "), + "a trailing-whitespace difference must still count as changed" + ); + } + + /// JSON exactly as written by a client that predates posture checks: no `proxy_url`, + /// `device_pubkey`, `token` or `schema_version` at the top level, and no `core_location_id` or + /// `posture_check_required` inside the locations. This is the upgrade path. + const LEGACY_JSON: &str = r#"{ + "service_locations": [ + { + "name": "Office", + "address": "10.0.0.2/24", + "pubkey": "remote-peer-pubkey", + "endpoint": "vpn.example.com:51820", + "allowed_ips": "10.0.0.0/24", + "keepalive_interval": 25, + "dns": "10.0.0.1", + "mode": 2 + } + ], + "instance_id": "d3a5b1f0-0000-0000-0000-000000000001", + "private_key": "device-private-key" + }"#; + + #[test] + fn test_load_service_locations_from_file_handles_missing_and_valid_files() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let path = dir.path().join("locations.json"); + + assert!(load_service_locations_from_file(&path).unwrap().is_none()); + + fs::write(&path, LEGACY_JSON).expect("failed to write service locations"); + let data = load_service_locations_from_file(&path) + .unwrap() + .expect("service location file should load"); + + assert_eq!(data.instance_id, "d3a5b1f0-0000-0000-0000-000000000001"); + assert_eq!(data.service_locations.len(), 1); + } + + #[test] + fn test_load_service_locations_from_file_rejects_malformed_json() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let path = dir.path().join("locations.json"); + fs::write(&path, "{").expect("failed to write malformed service locations"); + + assert!(matches!( + load_service_locations_from_file(&path), + Err(ServiceLocationError::JsonError(_)) + )); + } + + #[test] + fn test_load_service_locations_from_directory_isolates_invalid_entries() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + fs::write(dir.path().join("valid.json"), LEGACY_JSON) + .expect("failed to write valid service locations"); + fs::write(dir.path().join("invalid.json"), "{") + .expect("failed to write malformed service locations"); + fs::write(dir.path().join("ignored.txt"), LEGACY_JSON) + .expect("failed to write ignored service locations"); + fs::create_dir(dir.path().join("directory.json")) + .expect("failed to create ignored directory"); + + let data = load_service_locations_from_directory(dir.path()).unwrap(); + + assert_eq!(data.len(), 1); + assert_eq!(data[0].instance_id, "d3a5b1f0-0000-0000-0000-000000000001"); + } + + #[test] + fn test_legacy_json_without_new_fields_deserializes_with_defaults() { + let data: ServiceLocationData = serde_json::from_str(LEGACY_JSON) + .expect("legacy service location file must still load"); + + assert_eq!(data.instance_id, "d3a5b1f0-0000-0000-0000-000000000001"); + assert_eq!(data.private_key, "device-private-key"); + assert_eq!(data.proxy_url, ""); + assert_eq!(data.device_pubkey, ""); + assert_eq!(data.token, None); + // 0 marks a file written before schema versioning existed. + assert_eq!(data.schema_version, 0); + + assert_eq!(data.service_locations.len(), 1); + let location = &data.service_locations[0]; + assert_eq!(location.name, "Office"); + assert_eq!(location.pubkey, "remote-peer-pubkey"); + assert_eq!(location.core_location_id, 0); + assert!(!location.posture_check_required); + } + + #[test] + fn test_truncated_json_still_fails_to_deserialize() { + // A container-level `#[serde(default)]` on `ServiceLocation` would let malformed entries + // silently vanish; make sure missing required keys are still an error. + let json = r#"{ + "service_locations": [{ "core_location_id": 7 }], + "instance_id": "id", + "private_key": "key" + }"#; + + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn test_round_trip_preserves_new_fields() { + let data = ServiceLocationData { + service_locations: vec![ServiceLocation { + name: "Office".into(), + address: "10.0.0.2/24".into(), + pubkey: "remote-peer-pubkey".into(), + endpoint: "vpn.example.com:51820".into(), + allowed_ips: "10.0.0.0/24".into(), + keepalive_interval: 25, + dns: "10.0.0.1".into(), + mode: ProtoServiceLocationMode::AlwaysOn as i32, + core_location_id: 42, + posture_check_required: true, + }], + instance_id: "instance-uuid".into(), + private_key: "device-private-key".into(), + proxy_url: "https://proxy.example.com".into(), + device_pubkey: "device-public-key".into(), + token: Some("polling-token".into()), + schema_version: SERVICE_LOCATION_SCHEMA_VERSION, + }; + + let json = serde_json::to_string(&data).expect("serialization must succeed"); + let restored: ServiceLocationData = + serde_json::from_str(&json).expect("deserialization must succeed"); + + assert_eq!(restored.proxy_url, "https://proxy.example.com"); + assert_eq!(restored.device_pubkey, "device-public-key"); + assert_eq!(restored.token.as_deref(), Some("polling-token")); + assert_eq!(restored.schema_version, SERVICE_LOCATION_SCHEMA_VERSION); + assert_eq!(restored.service_locations[0].core_location_id, 42); + assert!(restored.service_locations[0].posture_check_required); + // The remote peer key must not be confused with the device key. + assert_eq!(restored.service_locations[0].pubkey, "remote-peer-pubkey"); + } + + #[test] + fn test_debug_masks_private_key_and_token() { + let data = ServiceLocationData { + service_locations: Vec::new(), + instance_id: "instance-uuid".into(), + private_key: "super-secret-private-key".into(), + proxy_url: "https://proxy.example.com".into(), + device_pubkey: "device-public-key".into(), + token: Some("super-secret-token".into()), + schema_version: SERVICE_LOCATION_SCHEMA_VERSION, + }; + + let debug = format!("{data:?}"); + assert!(!debug.contains("super-secret-private-key"), "{debug}"); + assert!(!debug.contains("super-secret-token"), "{debug}"); + // Non-secret fields are still visible for diagnostics. + assert!(debug.contains("https://proxy.example.com"), "{debug}"); + assert!(debug.contains("device-public-key"), "{debug}"); + } + + #[test] + fn test_debug_of_absent_token_is_not_masked_as_present() { + let data = ServiceLocationData { + service_locations: Vec::new(), + instance_id: "instance-uuid".into(), + private_key: "private".into(), + proxy_url: String::new(), + device_pubkey: String::new(), + token: None, + schema_version: SERVICE_LOCATION_SCHEMA_VERSION, + }; + + assert!(format!("{data:?}").contains("token: None")); + } + + #[test] + fn test_single_service_location_debug_masks_private_key() { + let data = SingleServiceLocationData { + service_location: ServiceLocation { + name: "Office".into(), + address: "10.0.0.2/24".into(), + pubkey: "remote-peer-pubkey".into(), + endpoint: "vpn.example.com:51820".into(), + allowed_ips: "10.0.0.0/24".into(), + keepalive_interval: 25, + dns: "10.0.0.1".into(), + mode: ProtoServiceLocationMode::AlwaysOn as i32, + core_location_id: 42, + posture_check_required: true, + }, + instance_id: "instance-uuid".into(), + private_key: "super-secret-private-key".into(), + }; + + assert!(!format!("{data:?}").contains("super-secret-private-key")); + } +} diff --git a/src-tauri/enterprise/service-locations/src/linux.rs b/src-tauri/enterprise/service-locations/src/linux.rs new file mode 100644 index 000000000..cfdadbed2 --- /dev/null +++ b/src-tauri/enterprise/service-locations/src/linux.rs @@ -0,0 +1,695 @@ +use std::{ + collections::HashSet, + fs::{self, create_dir_all, set_permissions, OpenOptions}, + io::Write, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + path::PathBuf, + str::FromStr, + time::SystemTime, +}; + +use defguard_client_common::{dns_borrow, find_free_tcp_port, get_interface_name}; +use defguard_client_proto::{ + conversions::normalize_allowed_ips, + defguard::client::v1::{SaveServiceLocationsRequest, ServiceLocation, ServiceLocationMode}, +}; +use defguard_wireguard_rs::{ + key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, WGApi, WireguardInterfaceApi, +}; +use log::{debug, error, info, warn}; + +use crate::{ + instance_file_path, is_unchanged_on_disk, load_service_locations_from_directory, + load_service_locations_from_file, + reconciler::{ + reconcile_action, PostureAuthorizationRequest, PostureAuthorizations, ReconcileAction, + }, + ServiceLocationData, ServiceLocationError, ServiceLocationManager, +}; + +const DEFGUARD_DIR: &str = "/etc/defguard"; +const SERVICE_LOCATIONS_SUBDIR: &str = "service_locations"; +const SERVICE_LOCATION_DIR_PERMS: u32 = 0o700; +const SERVICE_LOCATION_FILE_PERMS: u32 = 0o600; +const DEFAULT_WIREGUARD_PORT: u16 = 51820; + +fn get_shared_directory() -> PathBuf { + PathBuf::from(DEFGUARD_DIR).join(SERVICE_LOCATIONS_SUBDIR) +} + +fn get_instance_file_path(instance_id: &str) -> Result { + instance_file_path(&get_shared_directory(), instance_id) +} + +fn ensure_shared_directory() -> Result { + let path = get_shared_directory(); + create_dir_all(&path)?; + set_permissions( + &path, + fs::Permissions::from_mode(SERVICE_LOCATION_DIR_PERMS), + )?; + Ok(path) +} + +/// Removes an interface created during setup after a later configuration step failed. +fn remove_created_interface(wgapi: &WGApi, ifname: &str) { + if let Err(err) = wgapi.remove_interface() { + error!( + "Failed to remove Linux service location interface {ifname} after setup failure: {err}" + ); + } +} + +fn preshared_key_update(preshared_key: Option<&str>) -> Result { + // Netlink interprets an omitted attribute as "leave unchanged". WireGuard's explicit all-zero + // key removes the PSK from an existing peer. + Ok(match preshared_key { + Some(preshared_key) => Key::from_str(preshared_key)?, + None => Key::default(), + }) +} + +impl ServiceLocationManager { + pub fn init() -> Result { + debug!("Initializing Linux service location storage"); + ensure_shared_directory()?; + Ok(Self::default()) + } + + /// Persists Linux-supported service locations and resets their runtime connection state. + /// + /// **Idempotent.** Callers push on every poll cycle without doing their own change detection, + /// so this returns early when the data it would write matches what is already on disk, leaving + /// the running tunnels alone. Only a real change proceeds to the reset loop below. + /// + /// Linux supports Always-on service locations only. Unsupported modes are filtered out before + /// storage - and before the comparison, so a PreLogon location does not read as a change on + /// every push. Stale previously-saved locations are disconnected, and every saved Always-on + /// location is reset. All resets are attempted before returning an aggregate error. + pub fn save_service_locations( + &mut self, + request: &SaveServiceLocationsRequest, + ) -> Result<(), ServiceLocationError> { + let instance_id = request.instance_id.as_str(); + let service_locations = request.service_locations.as_slice(); + debug!( + "Received a request to save {} service location(s) for instance {instance_id}", + service_locations.len(), + ); + + debug!("Service locations to save: {service_locations:?}"); + let old_locations = self + .load_service_locations_for_instance(instance_id)? + .map_or_else(Vec::new, |data| data.service_locations); + let old_pubkeys = old_locations + .iter() + .map(|location| location.pubkey.clone()) + .collect::>(); + + // Only AlwaysOn service locations are supported on linux + let service_locations = service_locations + .iter() + .filter(|location| location.mode == ServiceLocationMode::AlwaysOn as i32) + .cloned() + .collect::>(); + let new_pubkeys = service_locations + .iter() + .map(|location| location.pubkey.clone()) + .collect::>(); + + let service_location_data = + ServiceLocationData::from_save_request(request, service_locations.clone()); + + ensure_shared_directory()?; + let instance_file_path = get_instance_file_path(instance_id)?; + let json = serde_json::to_string_pretty(&service_location_data)?; + + // Saving is pushed unconditionally on every poll cycle, so nothing having changed is the + // normal case. Return before the reset loop below, which disconnects and reconnects every + // tunnel: proceeding would drop working tunnels at the poll interval forever. Permissions + // are still reapplied, so a file whose mode drifted is repaired even on this path. + if is_unchanged_on_disk(&instance_file_path, &json) { + debug!( + "Service locations for instance {instance_id} are unchanged, leaving {} and the \ + existing tunnels untouched", + instance_file_path.display() + ); + set_permissions( + &instance_file_path, + fs::Permissions::from_mode(SERVICE_LOCATION_FILE_PERMS), + )?; + return Ok(()); + } + + debug!( + "Writing service location data to file: {}", + instance_file_path.display() + ); + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(SERVICE_LOCATION_FILE_PERMS) + .open(&instance_file_path)? + .write_all(json.as_bytes())?; + set_permissions( + &instance_file_path, + fs::Permissions::from_mode(SERVICE_LOCATION_FILE_PERMS), + )?; + + debug!("Service locations saved for instance {instance_id}"); + + for removed_pubkey in old_pubkeys.difference(&new_pubkeys) { + self.disconnect_service_location(instance_id, removed_pubkey)?; + } + + let mut reset_failed = false; + for location in &service_locations { + if let Err(err) = + self.reset_service_location_state(instance_id, location, &request.private_key) + { + warn!( + "Failed to reset Linux service location '{}' after saving: {err}", + location.name + ); + reset_failed = true; + } + } + + if reset_failed { + return Err(ServiceLocationError::InterfaceError(format!( + "Failed to connect one or more Linux service locations for instance {instance_id}" + ))); + } + + Ok(()) + } + + /// Reconnects one Linux always-on service location after its configuration changed. + /// + /// A posture-gated location is only torn down here, not brought back: obtaining a preshared key + /// means an HTTP round trip, and this runs inside the gRPC save handler while the manager write + /// guard is held. The reconciler authorizes and reconnects it on its next pass instead, so the + /// location is down for at most one interval. + fn reset_service_location_state( + &mut self, + instance_id: &str, + location: &ServiceLocation, + private_key: &str, + ) -> Result<(), ServiceLocationError> { + debug!( + "Resetting Linux service location '{}' for instance {instance_id}", + location.name + ); + + self.disconnect_service_location(instance_id, &location.pubkey)?; + + if location.posture_check_required { + debug!( + "Leaving Linux service location '{}' disconnected: it needs a posture check, which \ + the reconciler will run", + location.name + ); + return Ok(()); + } + + self.connect_service_location(instance_id, location, private_key, None)?; + + debug!( + "Linux service location '{}' state reset successfully", + location.name + ); + Ok(()) + } + + fn find_interface_by_peer_pubkey(&self, location_pubkey: &str) -> Option { + let peer_key = match Key::from_str(location_pubkey) { + Ok(peer_key) => peer_key, + Err(err) => { + warn!( + "Failed to parse Linux service location peer pubkey {location_pubkey}: {err}" + ); + return None; + } + }; + + for (ifname, wgapi) in &self.wgapis { + match wgapi.read_interface_data() { + Ok(host) => { + if host.peers.contains_key(&peer_key) { + return Some(ifname.clone()); + } + } + Err(err) => warn!( + "Failed to read Linux service location interface {ifname} while looking for \ + peer {location_pubkey}: {err}" + ), + } + } + + None + } + + fn remove_tracked_interface(&mut self, ifname: &str) -> Result<(), ServiceLocationError> { + debug!("Tearing down Linux service location interface: {ifname}"); + let Some(wgapi) = self.wgapis.get(ifname) else { + return Err(ServiceLocationError::InterfaceError(format!( + "Linux service location interface {ifname} is not tracked" + ))); + }; + wgapi.remove_interface()?; + self.wgapis.remove(ifname); + info!("Linux service location interface {ifname} removed successfully"); + Ok(()) + } + + pub fn disconnect_service_locations_by_instance( + &mut self, + instance_id: &str, + ) -> Result<(), ServiceLocationError> { + debug!("Disconnecting Linux service locations for instance {instance_id}"); + + let Some(locations) = self.connected_service_locations.get(instance_id) else { + debug!("No connected Linux service locations found for instance {instance_id}"); + return Ok(()); + }; + let location_pubkeys = locations + .iter() + .map(|connected| connected.location.pubkey.clone()) + .collect::>(); + + let mut disconnect_failed = false; + for location_pubkey in location_pubkeys { + if let Err(err) = self.disconnect_service_location(instance_id, &location_pubkey) { + error!( + "Failed to disconnect Linux service location peer {location_pubkey} for \ + instance {instance_id}: {err}" + ); + disconnect_failed = true; + } + } + + if disconnect_failed { + return Err(ServiceLocationError::InterfaceError(format!( + "Failed to disconnect one or more Linux service locations for instance \ + {instance_id}" + ))); + } + + Ok(()) + } + + fn disconnect_service_location( + &mut self, + instance_id: &str, + location_pubkey: &str, + ) -> Result<(), ServiceLocationError> { + let Some(position) = + self.connected_service_locations + .get(instance_id) + .and_then(|locations| { + locations + .iter() + .position(|connected| connected.location.pubkey == location_pubkey) + }) + else { + debug!("No connected Linux service locations found for instance {instance_id}"); + return Ok(()); + }; + + let location = self.connected_service_locations[instance_id][position] + .location + .clone(); + let Some(ifname) = self.find_interface_by_peer_pubkey(location_pubkey) else { + return Err(ServiceLocationError::InterfaceError(format!( + "No service location interface found for location '{}' and peer \ + {location_pubkey}", + location.name + ))); + }; + self.remove_tracked_interface(&ifname)?; + + let Some(locations) = self.connected_service_locations.get_mut(instance_id) else { + warn!("Linux service location for instance {instance_id} disappeared before removal"); + return Ok(()); + }; + locations.remove(position); + if locations.is_empty() { + self.connected_service_locations.remove(instance_id); + } + + Ok(()) + } + + fn setup_service_location_interface( + &mut self, + location: &ServiceLocation, + private_key: &str, + preshared_key: Option<&str>, + ) -> Result<(), ServiceLocationError> { + let peer_key = Key::from_str(&location.pubkey)?; + let mut peer = Peer::new(peer_key); + peer.set_endpoint(&location.endpoint)?; + peer.preshared_key = preshared_key.map(Key::from_str).transpose()?; + peer.persistent_keepalive_interval = location.keepalive_interval.try_into().ok(); + + for allowed_ip in location.allowed_ips.split(',').map(str::trim) { + if allowed_ip.is_empty() { + continue; + } + match IpAddrMask::from_str(allowed_ip) { + Ok(addr) => peer.allowed_ips.push(addr), + Err(err) => error!( + "Error parsing allowed IP {allowed_ip} while setting up Linux service location \ + {}: {err}", + location.name + ), + } + } + + let addresses = location + .address + .split(',') + .map(str::trim) + .filter(|address| !address.is_empty()) + .map(IpAddrMask::from_str) + .collect::, _>>()?; + + let ifname = get_interface_name(&location.name); + let mut config = InterfaceConfiguration { + name: ifname.clone(), + prvkey: private_key.to_string(), + addresses, + port: find_free_tcp_port().unwrap_or(DEFAULT_WIREGUARD_PORT), + peers: vec![peer], + mtu: None, + fwmark: None, + }; + normalize_allowed_ips(&mut config); + + let mut wgapi = WGApi::new(&ifname).map_err(|err| { + ServiceLocationError::InterfaceError(format!( + "Failed to setup Linux WireGuard API for interface {ifname}: {err}" + )) + })?; + + wgapi.create_interface()?; + let dns_config = Some(location.dns.clone()); + let (dns, search_domains) = dns_borrow(&dns_config); + debug!( + "Configuring Linux service location interface {ifname} with DNS: {dns:?} and search \ + domains: {search_domains:?}" + ); + if let Err(err) = wgapi.configure_interface(&config) { + remove_created_interface(&wgapi, &ifname); + return Err(err.into()); + } + debug!("Configuring Linux service location interface {ifname} routing"); + if let Err(err) = wgapi.configure_peer_routing(&config.peers) { + remove_created_interface(&wgapi, &ifname); + return Err(err.into()); + } + if let Err(err) = wgapi.configure_dns(&dns, &search_domains) { + remove_created_interface(&wgapi, &ifname); + return Err(err.into()); + } + self.wgapis.insert(ifname.clone(), wgapi); + + debug!("Linux service location interface {ifname} configured successfully"); + Ok(()) + } + + fn connect_service_location( + &mut self, + instance_id: &str, + location: &ServiceLocation, + private_key: &str, + preshared_key: Option<&str>, + ) -> Result<(), ServiceLocationError> { + if self.is_service_location_connected(instance_id, &location.pubkey) { + debug!( + "Skipping Linux service location '{}' because it's already connected", + location.name + ); + return Ok(()); + } + + if self + .find_interface_by_peer_pubkey(&location.pubkey) + .is_some() + { + debug!( + "Skipping Linux service location '{}' because its interface already exists", + location.name + ); + self.add_connected_service_location(instance_id, location); + return Ok(()); + } + + self.setup_service_location_interface(location, private_key, preshared_key)?; + self.add_connected_service_location(instance_id, location); + debug!("Connected Linux service location '{}'", location.name); + Ok(()) + } + + /// Reads the last handshake for a peer from the interface carrying it. + /// + /// `None` means either no interface was found or the peer has never completed a handshake. The + /// staleness rule treats both the same, falling back to when the session was authorized. + fn read_last_handshake(&self, location_pubkey: &str) -> Option { + let ifname = self.find_interface_by_peer_pubkey(location_pubkey)?; + let wgapi = self.wgapis.get(&ifname)?; + let host = wgapi + .read_interface_data() + .inspect_err(|err| { + warn!("Failed to read data for service location interface {ifname}: {err}"); + }) + .ok()?; + let peer_key = Key::from_str(location_pubkey).ok()?; + host.peers.get(&peer_key)?.last_handshake + } + + /// Applies a freshly obtained preshared key to an already-running interface. + /// + /// Uses `configure_peer` rather than rebuilding the interface: on Linux that is a single + /// netlink call carrying the new key, so the tunnel keeps its listen port and the gap in + /// traffic is as short as it can be. + fn reapply_preshared_key( + &mut self, + instance_id: &str, + location: &ServiceLocation, + preshared_key: Option<&str>, + ) -> Result<(), ServiceLocationError> { + let Some(ifname) = self.find_interface_by_peer_pubkey(&location.pubkey) else { + return Err(ServiceLocationError::InterfaceError(format!( + "No interface found for service location '{}' while renewing its posture session", + location.name + ))); + }; + let Some(wgapi) = self.wgapis.get(&ifname) else { + return Err(ServiceLocationError::InterfaceError(format!( + "No WireGuard API for interface {ifname} while renewing a posture session" + ))); + }; + + let mut peer = Peer::new(Key::from_str(&location.pubkey)?); + peer.set_endpoint(&location.endpoint)?; + peer.persistent_keepalive_interval = location.keepalive_interval.try_into().ok(); + peer.preshared_key = Some(preshared_key_update(preshared_key)?); + for allowed_ip in location.allowed_ips.split(',').map(str::trim) { + if allowed_ip.is_empty() { + continue; + } + match IpAddrMask::from_str(allowed_ip) { + Ok(addr) => peer.allowed_ips.push(addr), + Err(err) => error!( + "Error parsing allowed IP {allowed_ip} while renewing service location {}: \ + {err}", + location.name + ), + } + } + + wgapi.configure_peer(&peer)?; + self.record_posture_session(instance_id, &location.pubkey); + info!( + "Renewed the posture session for Linux service location '{}'", + location.name + ); + Ok(()) + } + + /// Brings the running tunnels in line with what is on disk. + pub(crate) fn reconcile( + &mut self, + authorizations: &PostureAuthorizations, + ) -> Result { + self.connect_to_service_locations(authorizations) + } + + /// Lists the locations that need a posture check before the next pass can connect them. + /// Healthy connected locations are excluded, while stale connected locations are included so + /// their authorization and preshared key can be renewed in place. + pub(crate) fn locations_needing_authorization(&self) -> Vec { + let Ok(data) = self.load_service_locations() else { + warn!("Failed to load service locations while looking for posture checks to run"); + return Vec::new(); + }; + + self.collect_posture_authorization_requests( + &data, + |location| location.mode == ServiceLocationMode::AlwaysOn as i32, + |location| self.read_last_handshake(&location.pubkey), + ) + } + + /// Attempts to connect all persisted Linux always-on service locations. + /// + /// Returns `Ok(true)` when every supported location is connected or already connected, and + /// `Ok(false)` when at least one supported location failed so the caller can retry later. + pub(crate) fn connect_to_service_locations( + &mut self, + authorizations: &PostureAuthorizations, + ) -> Result { + debug!("Attempting to auto-connect Linux Always-on service locations"); + + let data = self.load_service_locations()?; + let mut all_connected = true; + + for instance_data in data { + for location in instance_data.service_locations { + if location.mode != ServiceLocationMode::AlwaysOn as i32 { + debug!( + "Skipping Linux service location '{}' because only Always-on is supported", + location.name + ); + continue; + } + + let authorization = authorizations + .get(&(instance_data.instance_id.clone(), location.pubkey.clone())); + let action = reconcile_action( + self.is_service_location_connected( + &instance_data.instance_id, + &location.pubkey, + ), + location.posture_check_required, + authorization, + ); + + match action { + ReconcileAction::LeaveConnected => { + debug!( + "Skipping Linux service location '{}' because it's already connected", + location.name + ); + continue; + } + ReconcileAction::LeaveDisconnected => continue, + ReconcileAction::WaitForAuthorization => { + debug!( + "Leaving Linux service location '{}' disconnected: no posture check \ + has approved it yet", + location.name + ); + all_connected = false; + continue; + } + ReconcileAction::Disconnect => { + if let Err(err) = self.disconnect_service_location( + &instance_data.instance_id, + &location.pubkey, + ) { + error!( + "Failed to disconnect rejected Linux service location '{}': \ + {err}", + location.name + ); + all_connected = false; + } + continue; + } + ReconcileAction::Renew(preshared_key) => { + if let Err(err) = self.reapply_preshared_key( + &instance_data.instance_id, + &location, + preshared_key, + ) { + error!( + "Failed to renew the posture session for '{}': {err}", + location.name + ); + all_connected = false; + } + continue; + } + ReconcileAction::Connect(preshared_key) => { + if let Err(err) = self.connect_service_location( + &instance_data.instance_id, + &location, + &instance_data.private_key, + preshared_key, + ) { + error!( + "Failed to setup Linux service location interface for '{}': \ + {err:?}", + location.name + ); + all_connected = false; + } else if authorization.is_some() { + self.record_posture_session( + &instance_data.instance_id, + &location.pubkey, + ); + } + } + } + } + } + + Ok(all_connected) + } + + pub fn delete_all_service_locations_for_instance( + &self, + instance_id: &str, + ) -> Result<(), ServiceLocationError> { + debug!("Deleting Linux service locations for instance {instance_id}"); + + let instance_file_path = get_instance_file_path(instance_id)?; + if instance_file_path.exists() { + fs::remove_file(&instance_file_path)?; + debug!("Deleted Linux service locations for instance {instance_id}"); + } else { + debug!("No Linux service location file found for instance {instance_id}"); + } + + Ok(()) + } + + #[allow(dead_code)] + /// Loads persisted service-location data for all Linux instances. + fn load_service_locations(&self) -> Result, ServiceLocationError> { + let base_dir = ensure_shared_directory()?; + load_service_locations_from_directory(&base_dir) + } + + /// Loads persisted service-location data for one Linux instance, if present. + fn load_service_locations_for_instance( + &self, + instance_id: &str, + ) -> Result, ServiceLocationError> { + let instance_file_path = get_instance_file_path(instance_id)?; + load_service_locations_from_file(&instance_file_path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_removing_a_preshared_key_emits_an_explicit_zero_key() { + assert_eq!(preshared_key_update(None).unwrap(), Key::default()); + } +} diff --git a/src-tauri/enterprise/service-locations/src/reconciler.rs b/src-tauri/enterprise/service-locations/src/reconciler.rs new file mode 100644 index 000000000..ec241f4b8 --- /dev/null +++ b/src-tauri/enterprise/service-locations/src/reconciler.rs @@ -0,0 +1,483 @@ +//! Keeps runtime service-location tunnels aligned with persisted configuration. +//! +//! A one-shot connection attempt is insufficient: the daemon may start before networking or DNS +//! is ready, a suspend may leave an interface up after the gateway has discarded its peer, and +//! platform event watchers may miss notifications. Posture-gated locations also need periodic +//! authorization renewal. The reconciler therefore retries forever on a timer and can be woken +//! early by platform events. Each pass is idempotent, so redundant wakeups are harmless. + +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, + time::{Duration, SystemTime}, +}; + +use defguard_client_core::error::Error as CoreError; +use defguard_client_posture::{ + inspector::{device_posture_data, DiskEncryptionTarget}, + request_posture_authorization, +}; +use defguard_client_proto::defguard::client::v1::ServiceLocation; +use futures_util::{stream, StreamExt}; +use log::{debug, error, info, warn}; + +use crate::{ServiceLocationData, ServiceLocationManager}; + +/// How long a posture session may go without evidence of life before it is renewed. +/// Deliberately below core's default `peer_disconnect_threshold` of 300s, so a session is refreshed +/// before core would drop the peer rather than after. +pub const POSTURE_SESSION_STALE_AFTER: Duration = Duration::from_secs(180); +/// Prevents a large configuration from flooding the proxy while avoiding serial timeout delays. +const MAX_CONCURRENT_POSTURE_AUTHORIZATIONS: usize = 8; + +/// Whether a posture session needs renewing. +/// +/// The interface is the only honest source here. A location the daemon believes it connected can +/// be dead: while a machine sleeps, core's `peer_disconnect_threshold` elapses and the gateway +/// drops the peer, leaving an interface that looks perfectly healthy and passes nothing. A +/// handshake is the evidence that the far side still has us. +/// +/// `authorized_at` covers the case where no handshake has happened yet, which is normal immediately +/// after connecting and suspicious a few minutes later. It is the one thing here that cannot be +/// recovered from the interface, which is why it has to be remembered. +#[must_use] +pub(crate) fn posture_session_is_stale( + last_handshake: Option, + authorized_at: Option, + now: SystemTime, +) -> bool { + // Linux represents a peer that has never handshaken as the Unix epoch. + let last_handshake = last_handshake.filter(|handshake| *handshake != SystemTime::UNIX_EPOCH); + let beyond_threshold = |moment: SystemTime| { + now.duration_since(moment) + .is_ok_and(|elapsed| elapsed > POSTURE_SESSION_STALE_AFTER) + }; + + match (last_handshake, authorized_at) { + // A handshake is the strongest evidence available, so it wins whenever there is one. + (Some(handshake), _) => beyond_threshold(handshake), + // Never handshaken: expected just after connecting, suspicious much later. + (None, Some(authorized)) => beyond_threshold(authorized), + // Neither, so the daemon has no record of authorizing this at all. Renewing is the safe + // reading: at worst it is redundant, whereas assuming health leaves a dead tunnel up. + (None, None) => true, + } +} + +/// A location that cannot be connected until a posture check approves it. +#[derive(Debug)] +pub(crate) struct PostureAuthorizationRequest { + pub instance_id: String, + pub location_pubkey: String, + pub location_name: String, + pub core_location_id: i64, + pub proxy_url: String, + pub device_pubkey: String, + pub token: Option, +} + +impl PostureAuthorizationRequest { + fn new(instance: &ServiceLocationData, location: &ServiceLocation) -> Self { + Self { + instance_id: instance.instance_id.clone(), + location_pubkey: location.pubkey.clone(), + location_name: location.name.clone(), + core_location_id: location.core_location_id, + proxy_url: instance.proxy_url.clone(), + device_pubkey: instance.device_pubkey.clone(), + token: instance.token.clone(), + } + } +} + +impl ServiceLocationManager { + /// Collects posture-gated locations that should currently be connected and need authorization. + /// + /// Platform modules supply connection eligibility and handshake lookup while this method owns + /// the common persisted-data traversal and posture-session staleness policy. + pub(crate) fn collect_posture_authorization_requests( + &self, + data: &[ServiceLocationData], + should_be_connected: impl Fn(&ServiceLocation) -> bool, + last_handshake: impl Fn(&ServiceLocation) -> Option, + ) -> Vec { + let mut pending = Vec::new(); + + for instance in data { + for location in &instance.service_locations { + if !location.posture_check_required || !should_be_connected(location) { + continue; + } + + if let Some(connected) = + self.connected_service_location(&instance.instance_id, &location.pubkey) + { + let handshake = last_handshake(location); + if !posture_session_is_stale( + handshake, + connected.authorized_at, + SystemTime::now(), + ) { + continue; + } + + debug!( + "Posture session for service location '{}' looks stale (last handshake: \ + {handshake:?}), it will be renewed", + location.name + ); + } + + pending.push(PostureAuthorizationRequest::new(instance, location)); + } + } + + pending + } +} + +/// What one reconcile pass should do with a persisted service location. +/// +/// An authorization records a definitive posture outcome from this pass. An absent outcome means +/// the request failed transiently, while an approval key may be absent when posture checks were +/// removed from the location. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReconcileAction<'a> { + LeaveConnected, + LeaveDisconnected, + WaitForAuthorization, + Disconnect, + Renew(Option<&'a str>), + Connect(Option<&'a str>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PostureAuthorization { + Approved(Option), + Rejected, +} + +#[must_use] +pub(crate) fn reconcile_action( + is_connected: bool, + posture_check_required: bool, + authorization: Option<&PostureAuthorization>, +) -> ReconcileAction<'_> { + if posture_check_required && matches!(authorization, Some(PostureAuthorization::Rejected)) { + return if is_connected { + ReconcileAction::Disconnect + } else { + ReconcileAction::LeaveDisconnected + }; + } + + let approved_key = match authorization { + Some(PostureAuthorization::Approved(preshared_key)) => Some(preshared_key.as_deref()), + Some(PostureAuthorization::Rejected) | None => None, + }; + + if is_connected { + return approved_key.map_or(ReconcileAction::LeaveConnected, ReconcileAction::Renew); + } + + if posture_check_required && approved_key.is_none() { + ReconcileAction::WaitForAuthorization + } else { + ReconcileAction::Connect(approved_key.flatten()) + } +} + +/// Posture outcomes obtained this pass, keyed by (instance id, location public key). +/// +/// An absent map entry means a transient failure and leaves an existing location alone. An approval +/// with no key means core removed posture checks; a rejection tears an existing location down. +pub(crate) type PostureAuthorizations = HashMap<(String, String), PostureAuthorization>; + +/// Obtains a definitive posture outcome for each location that needs one. +async fn authorize_pending(pending: Vec) -> PostureAuthorizations { + let mut authorizations = PostureAuthorizations::new(); + if pending.is_empty() { + return authorizations; + } + + debug!( + "{} service location(s) need a posture check before they can be connected", + pending.len() + ); + let posture_data = device_posture_data(DiskEncryptionTarget::RootFilesystem); + + // Run checks concurrently so one slow proxy does not delay every posture-gated location. + let requests = stream::iter(pending.into_iter().map(|request| { + let posture_data = posture_data.clone(); + async move { + let Some(token) = request.token.clone().filter(|token| !token.is_empty()) else { + error!( + "Cannot run a posture check for service location '{}': no polling token was \ + stored for its instance. Re-enrolling the device will store one.", + request.location_name + ); + return None; + }; + + match request_posture_authorization( + &request.proxy_url, + request.device_pubkey.clone(), + request.core_location_id, + token, + posture_data, + ) + .await + { + Ok(preshared_key) => { + info!( + "Posture check approved for service location '{}'", + request.location_name + ); + Some(( + (request.instance_id, request.location_pubkey), + PostureAuthorization::Approved(preshared_key), + )) + } + Err(CoreError::PostureCheckFailed(reason)) => { + error!( + "Posture check rejected for service location '{}': {reason}. Any existing \ + tunnel will be disconnected.", + request.location_name + ); + Some(( + (request.instance_id, request.location_pubkey), + PostureAuthorization::Rejected, + )) + } + Err(err) => { + error!( + "Posture check could not be completed for service location '{}': {err}. \ + Existing tunnel state will be preserved and the check retried.", + request.location_name + ); + None + } + } + } + })) + .buffer_unordered(MAX_CONCURRENT_POSTURE_AUTHORIZATIONS); + futures_util::pin_mut!(requests); + + while let Some(authorization) = requests.next().await { + if let Some((location, outcome)) = authorization { + authorizations.insert(location, outcome); + } + } + + authorizations +} + +/// Signal used to wake the reconciler before its next tick. +/// +/// `notify_one` is callable from synchronous code, which matters because the Windows watchers are +/// plain OS threads wrapping blocking syscalls. A wake that arrives while a pass is already running +/// is remembered rather than dropped, so an event can never be missed by arriving at a bad moment. +pub type ReconcileSignal = Arc; + +/// Runs a loop that brings the running tunnels in line with what is on disk, forever. +/// +/// Each pass is idempotent - already-correct locations are left alone - so waking it spuriously +/// costs nothing, and callers are free to wake it whenever something *might* have changed rather +/// than working out whether it did. +/// +/// `wake` is the only way to react faster than `tick`. On Windows it is signalled by the network, +/// logon and resume watchers. **On Linux nothing signals it**, so there the tick is the sole +/// trigger and recovery from any disruption takes up to one interval. +pub async fn run_reconciler( + manager: Arc>, + wake: ReconcileSignal, + tick: Duration, +) { + info!("Service location reconciler started, reconciling every {tick:?}"); + + loop { + // Reconcile regular locations and login-dependent teardown before posture HTTP calls. With + // no authorizations, connected posture locations are left alone and disconnected ones wait. + let initial_outcome = { + let mut manager_guard = manager + .write() + .expect("Failed to write-lock service location manager"); + manager_guard.reconcile(&PostureAuthorizations::new()) + }; + + let pending = { + let manager_guard = manager + .read() + .expect("Failed to read-lock service location manager"); + manager_guard.locations_needing_authorization() + }; + + let outcome = if pending.is_empty() { + initial_outcome + } else { + let authorizations = authorize_pending(pending).await; + let mut manager_guard = manager + .write() + .expect("Failed to write-lock service location manager"); + manager_guard.reconcile(&authorizations) + }; + + match outcome { + Ok(true) => debug!("Service locations reconciled"), + Ok(false) => warn!( + "Service location reconcile pass completed with failures, retrying in {tick:?}" + ), + Err(err) => { + error!("Service location reconcile pass failed: {err}. Retrying in {tick:?}"); + } + } + + tokio::select! { + () = tokio::time::sleep(tick) => {} + () = wake.notified() => debug!("Service location reconciler woken early by an event"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ago(seconds: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000 - seconds) + } + + fn now() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000) + } + + #[test] + fn test_a_recent_handshake_is_healthy() { + assert!(!posture_session_is_stale( + Some(ago(10)), + Some(ago(10_000)), + now() + )); + } + + /// A handshake outranks `authorized_at`: the far side has stopped answering, and having + /// authorized recently does not make the tunnel work. + #[test] + fn test_an_old_handshake_is_stale_even_if_just_authorized() { + assert!(posture_session_is_stale( + Some(ago(1_000)), + Some(ago(1)), + now() + )); + } + + /// Expected right after connecting - there has been no traffic to handshake for yet. + #[test] + fn test_no_handshake_yet_is_healthy_if_authorized_recently() { + assert!(!posture_session_is_stale(None, Some(ago(10)), now())); + } + + #[test] + fn test_epoch_handshake_falls_back_to_recent_authorization() { + assert!(!posture_session_is_stale( + Some(SystemTime::UNIX_EPOCH), + Some(ago(10)), + now() + )); + } + + /// The suspend case: authorized long ago, never handshaken, so nothing says it works. + #[test] + fn test_no_handshake_long_after_authorizing_is_stale() { + assert!(posture_session_is_stale(None, Some(ago(1_000)), now())); + } + + /// No record at all. Renewing is redundant at worst; assuming health leaves a dead tunnel up. + #[test] + fn test_no_evidence_at_all_is_stale() { + assert!(posture_session_is_stale(None, None, now())); + } + + /// A clock that moved backwards must not read as "ancient", which would renew every pass. + #[test] + fn test_a_handshake_in_the_future_is_not_stale() { + let future = now() + Duration::from_secs(60); + assert!(!posture_session_is_stale(Some(future), None, now())); + } + + #[test] + fn test_the_threshold_boundary_is_not_yet_stale() { + assert!(!posture_session_is_stale( + Some(now() - POSTURE_SESSION_STALE_AFTER), + None, + now() + )); + } + + #[test] + fn test_connected_location_with_fresh_key_is_renewed() { + let authorization = PostureAuthorization::Approved(Some("fresh-key".to_string())); + assert_eq!( + reconcile_action(true, true, Some(&authorization)), + ReconcileAction::Renew(Some("fresh-key")) + ); + } + + #[test] + fn test_approval_without_a_key_connects_without_a_key() { + let authorization = PostureAuthorization::Approved(None); + assert_eq!( + reconcile_action(false, true, Some(&authorization)), + ReconcileAction::Connect(None) + ); + } + + #[test] + fn test_approval_without_a_key_removes_the_old_key_from_a_connected_location() { + let authorization = PostureAuthorization::Approved(None); + assert_eq!( + reconcile_action(true, true, Some(&authorization)), + ReconcileAction::Renew(None) + ); + } + + #[test] + fn test_posture_rejection_disconnects_a_connected_location() { + assert_eq!( + reconcile_action(true, true, Some(&PostureAuthorization::Rejected)), + ReconcileAction::Disconnect + ); + } + + #[test] + fn test_transient_failure_leaves_a_connected_location_unchanged() { + assert_eq!( + reconcile_action(true, true, None), + ReconcileAction::LeaveConnected + ); + } + + #[test] + fn test_posture_rejection_keeps_a_disconnected_location_down() { + assert_eq!( + reconcile_action(false, true, Some(&PostureAuthorization::Rejected)), + ReconcileAction::LeaveDisconnected + ); + } + + #[test] + fn test_authorization_failure_keeps_a_posture_location_disconnected() { + assert_eq!( + reconcile_action(false, true, None), + ReconcileAction::WaitForAuthorization + ); + } + + #[test] + fn test_regular_location_connects_without_authorization() { + assert_eq!( + reconcile_action(false, false, None), + ReconcileAction::Connect(None) + ); + } +} diff --git a/src-tauri/enterprise/service-locations/src/windows.rs b/src-tauri/enterprise/service-locations/src/windows.rs new file mode 100644 index 000000000..fd3e5f13f --- /dev/null +++ b/src-tauri/enterprise/service-locations/src/windows.rs @@ -0,0 +1,1127 @@ +use std::{ + collections::HashSet, + fs::{self, create_dir_all}, + path::PathBuf, + result::Result, + str::FromStr, + thread::sleep, + time::{Duration, SystemTime}, +}; + +use defguard_client_common::{dns_borrow, find_free_tcp_port, get_interface_name}; +use defguard_client_proto::{ + conversions::normalize_allowed_ips, + defguard::client::v1::{SaveServiceLocationsRequest, ServiceLocation, ServiceLocationMode}, +}; +use defguard_wireguard_rs::{ + key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, WGApi, WireguardInterfaceApi, +}; +use known_folders::get_known_folder_path; +use log::{debug, error, info, warn}; +use windows::{ + core::PSTR, + Win32::System::RemoteDesktop::{ + self, WTSQuerySessionInformationA, WTSWaitSystemEvent, WTS_CURRENT_SERVER_HANDLE, + WTS_EVENT_LOGOFF, WTS_EVENT_LOGON, WTS_SESSION_INFOA, + }, +}; +use windows_acl::acl::ACL; +use windows_sys::Win32::NetworkManagement::IpHelper::NotifyAddrChange; + +use crate::{ + instance_file_path, is_unchanged_on_disk, load_service_locations_from_directory, + load_service_locations_from_file, + reconciler::{ + reconcile_action, PostureAuthorizationRequest, PostureAuthorizations, ReconcileAction, + ReconcileSignal, + }, + ServiceLocationData, ServiceLocationError, ServiceLocationManager, SingleServiceLocationData, +}; + +const LOGIN_LOGOFF_EVENT_RETRY_DELAY_SECS: u64 = 5; +// How long to wait after a network change before attempting to connect. +// Gives DHCP time to complete and DNS to become available. +const NETWORK_STABILIZATION_DELAY: Duration = Duration::from_secs(3); +// How long to wait before restarting the network change watcher on error. +const NETWORK_CHANGE_MONITOR_RESTART_DELAY: Duration = Duration::from_secs(5); +const DEFAULT_WIREGUARD_PORT: u16 = 51820; +const DEFGUARD_DIR: &str = "Defguard"; +const SERVICE_LOCATIONS_SUBDIR: &str = "service_locations"; + +/// Watches for IP address changes on any network interface and attempts to connect to any +/// service locations that are not yet connected. This handles the case where the endpoint +/// hostname cannot be resolved at service startup because the network (e.g. Wi-Fi) is not +/// yet available. When the network comes up and an IP is assigned, this watcher fires and +/// retries the connection. +/// +/// Note: `NotifyAddrChange` also fires when WireGuard interfaces are created. This is harmless +/// because a reconcile pass leaves already-correct locations alone. +/// +/// Runs on a dedicated OS thread because `NotifyAddrChange` is a blocking syscall. It only wakes +/// the reconciler and never touches the manager itself, so tunnel state has a single owner. +pub fn watch_for_network_change(wake: ReconcileSignal) { + loop { + // NotifyAddrChange blocks until any IP address is added or removed on any interface. + // Passing NULL for both handle and overlapped selects the synchronous (blocking) mode. + let result = unsafe { NotifyAddrChange(std::ptr::null_mut(), std::ptr::null()) }; + + if result != 0 { + error!("NotifyAddrChange failed with error code: {result}"); + sleep(NETWORK_CHANGE_MONITOR_RESTART_DELAY); + continue; + } + + debug!( + "Network address change detected, waiting {NETWORK_STABILIZATION_DELAY:?}s for \ + network to stabilize before attempting service location connections..." + ); + sleep(NETWORK_STABILIZATION_DELAY); + + debug!("Waking the service location reconciler after a network change"); + wake.notify_one(); + } +} + +/// Watches for user logon and logoff events and wakes the reconciler. +/// +/// Which event occurred is deliberately not passed on: the reconciler establishes whether a user is +/// logged in for itself, so a logon and a logoff are both simply "look again". +/// +/// Runs on a dedicated OS thread because `WTSWaitSystemEvent` is a blocking syscall. +pub fn watch_for_login_logoff(wake: &ReconcileSignal) -> ! { + loop { + let mut event_flags: u32 = 0; + let success = unsafe { + WTSWaitSystemEvent( + Some(WTS_CURRENT_SERVER_HANDLE), + WTS_EVENT_LOGON | WTS_EVENT_LOGOFF, + &mut event_flags, + ) + }; + + match success { + Ok(_) => { + debug!("Waiting for system event returned with event_flags: 0x{event_flags:x}"); + } + Err(err) => { + error!("Failed waiting for login/logoff event: {err:?}"); + sleep(Duration::from_secs(LOGIN_LOGOFF_EVENT_RETRY_DELAY_SECS)); + continue; + } + }; + + if event_flags & (WTS_EVENT_LOGON | WTS_EVENT_LOGOFF) != 0 { + debug!("Detected a logon or logoff, waking the service location reconciler"); + wake.notify_one(); + } + } +} + +fn setup_wgapi(ifname: &str) -> Result { + WGApi::new(ifname).map_err(|err| { + let msg = format!("Failed to setup WireGuard API for interface {ifname}: {err}"); + error!("{msg}"); + ServiceLocationError::InterfaceError(msg) + }) +} + +/// Builds the Windows WireGuard configuration for one service location. +/// +/// Initial setup supplies a free listen port, while posture-session renewal supplies the running +/// interface's current port so applying a new preshared key does not change its local endpoint. +fn interface_configuration( + location: &ServiceLocation, + private_key: &str, + preshared_key: Option<&str>, + port: u16, +) -> Result { + let mut peer = Peer::new(Key::from_str(&location.pubkey)?); + peer.set_endpoint(&location.endpoint)?; + // Held only by the running interface. It is never written to the service location file, + // which already holds two long-lived secrets, and a session key is reconstructible by + // authorizing again. + peer.preshared_key = preshared_key.map(Key::from_str).transpose()?; + peer.persistent_keepalive_interval = location.keepalive_interval.try_into().ok(); + + for allowed_ip in location.allowed_ips.split(',') { + match IpAddrMask::from_str(allowed_ip) { + Ok(addr) => peer.allowed_ips.push(addr), + Err(err) => error!( + "Error parsing IP address {allowed_ip} while setting up interface for location \ + {location:?}, error details: {err}" + ), + } + } + + let addresses = location + .address + .split(',') + .map(str::trim) + .map(IpAddrMask::from_str) + .collect::, _>>()?; + + Ok(InterfaceConfiguration { + name: location.name.clone(), + prvkey: private_key.to_string(), + addresses, + port, + peers: vec![peer], + mtu: None, + fwmark: None, + }) +} + +fn get_shared_directory() -> Result { + match get_known_folder_path(known_folders::KnownFolder::ProgramData) { + Some(mut path) => { + path.push(DEFGUARD_DIR); + path.push(SERVICE_LOCATIONS_SUBDIR); + Ok(path) + } + None => Err(ServiceLocationError::LoadError( + "Could not find ProgramData known folder".to_string(), + )), + } +} + +fn set_protected_acls(path: &str) -> Result<(), ServiceLocationError> { + debug!("Setting secure ACLs on: {path}"); + + const SYSTEM_SID: &str = "S-1-5-18"; // NT AUTHORITY\SYSTEM + const ADMINISTRATORS_SID: &str = "S-1-5-32-544"; // BUILTIN\Administrators + + const FILE_ALL_ACCESS: u32 = 0x001F_01FF; + + match ACL::from_file_path(path, false) { + Ok(mut acl) => { + // Remove everything else from access + debug!("Removing all existing ACL entries for {path}"); + let all_entries = acl.all().map_err(|e| { + ServiceLocationError::LoadError(format!("Failed to get ACL entries: {e}")) + })?; + + for entry in all_entries { + if let Some(sid) = entry.sid { + if let Err(e) = acl.remove(sid.as_ptr() as *mut _, None, None) { + debug!("Note: Could not remove ACL entry (might be expected): {e}"); + } + } + } + + debug!("Cleared existing ACL entries, now adding secure entries"); + + // Add SYSTEM with full control + debug!("Adding SYSTEM with full control"); + let system_sid_result = windows_acl::helper::string_to_sid(SYSTEM_SID); + match system_sid_result { + Ok(system_sid) => { + acl.allow(system_sid.as_ptr() as *mut _, true, FILE_ALL_ACCESS) + .map_err(|e| { + ServiceLocationError::LoadError(format!( + "Failed to add SYSTEM ACL: {e}" + )) + })?; + } + Err(e) => { + return Err(ServiceLocationError::LoadError(format!( + "Failed to convert SYSTEM SID: {e}" + ))); + } + } + + // Add Administrators with full control + debug!("Adding Administrators with full control"); + let admin_sid_result = windows_acl::helper::string_to_sid(ADMINISTRATORS_SID); + match admin_sid_result { + Ok(admin_sid) => { + acl.allow(admin_sid.as_ptr() as *mut _, true, FILE_ALL_ACCESS) + .map_err(|e| { + ServiceLocationError::LoadError(format!( + "Failed to add Administrators ACL: {e}" + )) + })?; + } + Err(e) => { + return Err(ServiceLocationError::LoadError(format!( + "Failed to convert Administrators SID: {e}" + ))); + } + } + + debug!("Successfully set secure ACLs on {path} for SYSTEM and Administrators"); + Ok(()) + } + Err(e) => { + error!("Failed to get ACL for {path}: {e}"); + Err(ServiceLocationError::LoadError(format!( + "Failed to get ACL for {path}: {e}" + ))) + } + } +} + +fn get_instance_file_path(instance_id: &str) -> Result { + instance_file_path(&get_shared_directory()?, instance_id) +} + +pub(crate) fn is_user_logged_in() -> bool { + debug!("Starting checking if user is logged in..."); + + unsafe { + let mut pp_sessions: *mut WTS_SESSION_INFOA = std::ptr::null_mut(); + let mut count: u32 = 0; + + debug!("Calling WTSEnumerateSessionsA..."); + let ret = RemoteDesktop::WTSEnumerateSessionsA(None, 0, 1, &mut pp_sessions, &mut count); + + match ret { + Ok(_) => { + debug!("WTSEnumerateSessionsA succeeded, found {count} sessions"); + let sessions = std::slice::from_raw_parts(pp_sessions, count as usize); + + for (index, session) in sessions.iter().enumerate() { + debug!( + "Session {index}: SessionId={}, State={:?}, WinStationName={:?}", + session.SessionId, + session.State, + std::ffi::CStr::from_ptr(session.pWinStationName.0 as *const i8) + .to_string_lossy() + ); + + if session.State == windows::Win32::System::RemoteDesktop::WTSActive { + let mut buffer = PSTR::null(); + let mut bytes_returned: u32 = 0; + + let result = WTSQuerySessionInformationA( + None, + session.SessionId, + windows::Win32::System::RemoteDesktop::WTSUserName, + &mut buffer, + &mut bytes_returned, + ); + + match result { + Ok(_) => { + if !buffer.is_null() { + let username = std::ffi::CStr::from_ptr(buffer.0 as *const i8) + .to_string_lossy() + .into_owned(); + + debug!( + "Found session {} username: {username}", + session.SessionId + ); + + windows::Win32::System::RemoteDesktop::WTSFreeMemory( + buffer.0 as *mut _, + ); + + // We found an active session with a username. + // Free the session list before returning to avoid a leak. + windows::Win32::System::RemoteDesktop::WTSFreeMemory( + pp_sessions as _, + ); + return true; + } + } + Err(err) => { + debug!( + "Failed to get username for session {}: {err:?}", + session.SessionId + ); + } + } + } + } + windows::Win32::System::RemoteDesktop::WTSFreeMemory(pp_sessions as _); + debug!("No active sessions found"); + } + Err(err) => { + error!("Failed to enumerate user sessions: {err:?}"); + debug!("WTSEnumerateSessionsA failed: {err:?}"); + } + } + } + + debug!("User is not logged in."); + false +} + +impl ServiceLocationManager { + pub fn init() -> Result { + debug!("Initializing ServiceLocationApi"); + let path = get_shared_directory()?; + + debug!("Creating directory: {path:?}"); + create_dir_all(&path)?; + + if let Some(path_str) = path.to_str() { + debug!("Setting ACLs on service locations directory"); + if let Err(e) = set_protected_acls(path_str) { + warn!("Failed to set ACLs on service locations directory: {e}. Continuing anyway."); + } + } else { + warn!("Failed to convert path to string for ACL setting"); + } + + let manager = Self::default(); + + debug!("ServiceLocationApi initialized successfully"); + Ok(manager) + } + + /// Remove connected service locations by filter (write disk-first, then memory) + fn remove_connected_service_locations( + &mut self, + filter: F, + ) -> Result<(), ServiceLocationError> + where + F: Fn(&str, &ServiceLocation) -> bool, + { + // Iterate through connected_service_locations and remove matching locations + let mut instances_to_remove = Vec::new(); + + for (instance_id, locations) in self.connected_service_locations.iter_mut() { + locations.retain(|connected| !filter(instance_id, &connected.location)); + + // Mark instance for removal if it has no more locations + if locations.is_empty() { + instances_to_remove.push(instance_id.clone()); + } + } + + // Remove instances with no locations + for instance_id in instances_to_remove { + self.connected_service_locations.remove(&instance_id); + } + + debug!("Removed connected service locations matching filter"); + Ok(()) + } + + // Resets the state of the service location: + // 1. If it's an always on location, disconnects and reconnects it. + // 2. Otherwise, just disconnects it if the user is not logged in. + pub fn reset_service_location_state( + &mut self, + instance_id: &str, + location_pubkey: &str, + ) -> Result<(), ServiceLocationError> { + debug!( + "Reseting the state of service location for instance_id: {instance_id}, \ + location_pubkey: {location_pubkey}" + ); + + let service_location_data = self + .load_service_location(instance_id, location_pubkey)? + .ok_or_else(|| { + ServiceLocationError::LoadError(format!( + "Service location with pubkey {} for instance {} not found", + location_pubkey, instance_id + )) + })?; + + debug!( + "Disconnecting service location for instance_id: {instance_id}, location_pubkey: \ + {location_pubkey} ({})", + service_location_data.service_location.name + ); + + self.disconnect_service_location(instance_id, location_pubkey)?; + + debug!( + "Disconnected service location for instance_id: {instance_id}, \ + location_pubkey: {location_pubkey} ({})", + service_location_data.service_location.name + ); + + debug!( + "Reconnecting service location if needed for instance_id: {instance_id}, \ + location_pubkey: {location_pubkey} ({})", + service_location_data.service_location.name + ); + + // A posture-gated location is only torn down here, not brought back: obtaining a preshared + // key means an HTTP round trip, and this runs inside the gRPC save handler while the + // manager write guard is held. The reconciler authorizes and reconnects it on its next pass + // instead, so the location is down for at most one interval. + if service_location_data + .service_location + .posture_check_required + { + debug!( + "Leaving service location '{}' disconnected: it needs a posture check, which the \ + reconciler will run", + service_location_data.service_location.name + ); + return Ok(()); + } + + // We should reconnect only if: + // 1. It's an always on location + // 2. It's a pre-logon location and the user is not logged in + if service_location_data.service_location.mode == ServiceLocationMode::AlwaysOn as i32 + || (service_location_data.service_location.mode == ServiceLocationMode::PreLogon as i32 + && !is_user_logged_in()) + { + debug!( + "Reconnecting service location for instance_id: {instance_id}, location_pubkey: \ + {location_pubkey} ({})", + service_location_data.service_location.name + ); + self.connect_to_service_location(&service_location_data)?; + } + + debug!("Service location state reset completed."); + + Ok(()) + } + + pub fn disconnect_service_locations_by_instance( + &mut self, + instance_id: &str, + ) -> Result<(), ServiceLocationError> { + debug!("Disconnecting all service locations for instance_id: {instance_id}"); + + let Some(locations) = self.connected_service_locations.get(instance_id) else { + debug!( + "No connected service locations found for instance_id: {instance_id}. Skipping \ + disconnect" + ); + return Ok(()); + }; + let location_pubkeys = locations + .iter() + .map(|connected| connected.location.pubkey.clone()) + .collect::>(); + + let mut disconnect_failed = false; + for location_pubkey in location_pubkeys { + if let Err(err) = self.disconnect_service_location(instance_id, &location_pubkey) { + error!( + "Failed to disconnect service location peer {location_pubkey} for instance \ + {instance_id}: {err}" + ); + disconnect_failed = true; + } + } + + if disconnect_failed { + return Err(ServiceLocationError::InterfaceError(format!( + "Failed to disconnect one or more service locations for instance {instance_id}" + ))); + } + + debug!("Disconnected all service locations for instance_id: {instance_id}"); + + Ok(()) + } + + pub(crate) fn disconnect_service_location( + &mut self, + instance_id: &str, + location_pubkey: &str, + ) -> Result<(), ServiceLocationError> { + debug!( + "Disconnecting service location for instance_id: {instance_id}, location_pubkey: \ + {location_pubkey}" + ); + + let Some((position, location)) = self + .connected_service_locations + .get(instance_id) + .and_then(|locations| { + locations + .iter() + .enumerate() + .find(|(_, connected)| connected.location.pubkey == location_pubkey) + .map(|(position, connected)| (position, connected.location.clone())) + }) + else { + debug!( + "No connected service locations found for instance_id: {instance_id}, skipping \ + disconnect" + ); + return Ok(()); + }; + + let ifname = get_interface_name(&location.name); + debug!("Tearing down interface: {ifname}"); + let Some(wgapi) = self.wgapis.get_mut(&ifname) else { + return Err(ServiceLocationError::InterfaceError(format!( + "Failed to find WireGuard API for interface {ifname}" + ))); + }; + wgapi.remove_interface()?; + self.wgapis.remove(&ifname); + + let Some(locations) = self.connected_service_locations.get_mut(instance_id) else { + return Ok(()); + }; + locations.remove(position); + if locations.is_empty() { + self.connected_service_locations.remove(instance_id); + } + + debug!( + "Disconnected service location for instance_id: {instance_id}, location_pubkey: \ + {location_pubkey}" + ); + + Ok(()) + } + + /// Helper function to setup a WireGuard interface for a service location + fn setup_service_location_interface( + &mut self, + location: &ServiceLocation, + private_key: &str, + preshared_key: Option<&str>, + ) -> Result<(), ServiceLocationError> { + let mut config = interface_configuration( + location, + private_key, + preshared_key, + find_free_tcp_port().unwrap_or(DEFAULT_WIREGUARD_PORT), + )?; + normalize_allowed_ips(&mut config); + + let ifname = location.name.clone(); + let ifname = get_interface_name(&ifname); + let mut wgapi = match setup_wgapi(&ifname) { + Ok(api) => api, + Err(err) => { + let msg = format!("Failed to setup WireGuard API for interface {ifname}: {err:?}"); + debug!("{msg}"); + return Err(ServiceLocationError::InterfaceError(msg)); + } + }; + + wgapi.create_interface()?; + + // Extract DNS configuration if available + let dns_config = Some(location.dns.clone()); + let (dns, search_domains) = dns_borrow(&dns_config); + debug!( + "Configuring interface {ifname} with DNS: {dns:?} and search domains: \ + {search_domains:?}", + ); + debug!("Interface Configuration: {config:?}"); + + wgapi.configure_interface(&config)?; + wgapi.configure_dns(&dns, &search_domains)?; + + self.wgapis.insert(ifname.clone(), wgapi); + + debug!("Interface {ifname} configured successfully."); + Ok(()) + } + + pub(crate) fn connect_to_service_location( + &mut self, + location_data: &SingleServiceLocationData, + ) -> Result<(), ServiceLocationError> { + let instance_id = &location_data.instance_id; + let location_pubkey = &location_data.service_location.pubkey; + debug!( + "Connecting to service location for instance_id: {instance_id}, location_pubkey: \ + {location_pubkey}" + ); + + // Check if already connected to this service location + if self.is_service_location_connected(instance_id, location_pubkey) { + debug!( + "Service location with pubkey {location_pubkey} for instance {instance_id} is \ + already connected, skipping" + ); + return Ok(()); + } + + let location_data = self + .load_service_location(instance_id, location_pubkey)? + .ok_or_else(|| { + ServiceLocationError::LoadError(format!( + "Service location with pubkey {location_pubkey} for instance {instance_id} not \ + found", + )) + })?; + + self.setup_service_location_interface( + &location_data.service_location, + &location_data.private_key, + None, + )?; + self.add_connected_service_location( + &location_data.instance_id, + &location_data.service_location, + ); + let ifname = get_interface_name(&location_data.service_location.name); + debug!("Successfully connected to service location '{ifname}'"); + + Ok(()) + } + + /// Disconnects every connected service location in `mode`. + pub(crate) fn disconnect_service_locations( + &mut self, + mode: ServiceLocationMode, + ) -> Result<(), ServiceLocationError> { + debug!("Disconnecting service locations with mode: {mode:?}"); + + for (instance, locations) in &self.connected_service_locations { + for connected in locations { + let location = &connected.location; + debug!( + "Found connected service location for instance_id: {instance}, \ + location_pubkey: {}", + location.pubkey + ); + let location_mode: ServiceLocationMode = location.mode.try_into()?; + if location_mode != mode { + debug!( + "Skipping interface {} due to the service location mode doesn't match the \ + requested mode (expected {mode:?}, found {:?})", + location.name, location.mode + ); + continue; + } + + let ifname = get_interface_name(&location.name); + debug!("Tearing down interface: {ifname}"); + if let Some(mut wgapi) = self.wgapis.remove(&ifname) { + if let Err(err) = wgapi.remove_interface() { + error!("Failed to remove interface {ifname}: {err}"); + } else { + debug!("Interface {ifname} removed successfully."); + } + } else { + error!("Failed to find WireGuard API for interface {ifname}"); + } + } + } + + self.remove_connected_service_locations(|_, location| { + // An unparseable mode is left in place rather than removed: dropping the record of a + // tunnel that is still up would leak it. + location + .mode + .try_into() + .is_ok_and(|location_mode: ServiceLocationMode| location_mode == mode) + })?; + + debug!("Service locations disconnected."); + + Ok(()) + } + + /// Reads the last handshake for a location from the interface carrying it. + /// + /// Goes through the stored `WGApi` deliberately: on Windows `read_interface_data` needs the + /// very instance that created the adapter, so a freshly built one would fail with + /// `AdapterNotFound`. + fn read_last_handshake(&self, location: &ServiceLocation) -> Option { + let ifname = get_interface_name(&location.name); + let wgapi = self.wgapis.get(&ifname)?; + let host = wgapi + .read_interface_data() + .inspect_err(|err| { + warn!("Failed to read data for service location interface {ifname}: {err}"); + }) + .ok()?; + let peer_key = Key::from_str(&location.pubkey).ok()?; + host.peers.get(&peer_key)?.last_handshake + } + + /// Applies a freshly obtained preshared key to an already-running interface. + /// + /// Reconfigures the whole interface rather than the single peer, because `configure_peer` does + /// nothing on Windows. The tracked API owns the existing adapter, so renewal configures that + /// adapter directly without opening/creating an interface or replacing the API handle. + fn reapply_preshared_key( + &mut self, + instance_id: &str, + location: &ServiceLocation, + private_key: &str, + preshared_key: Option<&str>, + ) -> Result<(), ServiceLocationError> { + let ifname = get_interface_name(&location.name); + let Some(wgapi) = self.wgapis.get(&ifname) else { + return Err(ServiceLocationError::InterfaceError(format!( + "No WireGuard API for interface {ifname} while renewing a posture session" + ))); + }; + let port = wgapi.read_interface_data()?.listen_port; + let mut config = interface_configuration(location, private_key, preshared_key, port)?; + normalize_allowed_ips(&mut config); + wgapi.configure_interface(&config)?; + self.record_posture_session(instance_id, &location.pubkey); + info!( + "Renewed the posture session for service location '{}'", + location.name + ); + Ok(()) + } + + /// Brings the running tunnels in line with what is on disk and who is logged in. + /// + /// Both directions, unlike `connect_to_service_locations` alone. Tearing down a pre-logon + /// location once a user logs in used to happen only in the logon event handler, which meant it + /// depended on having observed the event. Deriving it from `is_user_logged_in()` instead makes + /// the pass correct on its own, so the watchers can be reduced to "something happened, look + /// again" and a missed event costs a tick rather than leaving a tunnel up that should be down. + pub(crate) fn reconcile( + &mut self, + authorizations: &PostureAuthorizations, + ) -> Result { + if is_user_logged_in() { + debug!("A user is logged in, disconnecting any connected pre-logon service locations"); + self.disconnect_service_locations(ServiceLocationMode::PreLogon)?; + } + + self.connect_to_service_locations(authorizations) + } + + /// Lists the locations that need a posture check before the next pass can connect them. + /// + /// Read-only, so the caller can hold a read guard briefly, release it, and do the network calls + /// unlocked. Healthy connected locations are excluded, while stale connected locations are + /// included for in-place authorization and key renewal. Locations that should not currently be + /// up are also excluded, such as a pre-logon location while a user is logged in. + pub(crate) fn locations_needing_authorization(&self) -> Vec { + let Ok(data) = self.load_service_locations() else { + warn!("Failed to load service locations while looking for posture checks to run"); + return Vec::new(); + }; + + let user_logged_in = is_user_logged_in(); + self.collect_posture_authorization_requests( + &data, + |location| location.mode != ServiceLocationMode::PreLogon as i32 || !user_logged_in, + |location| self.read_last_handshake(location), + ) + } + + /// Attempts to connect every persisted service location that should currently be up. + /// + /// Returns `Ok(true)` when every eligible location is connected or already connected, and + /// `Ok(false)` when at least one eligible location failed so the caller can retry later. + pub(crate) fn connect_to_service_locations( + &mut self, + authorizations: &PostureAuthorizations, + ) -> Result { + debug!("Attempting to auto-connect to VPN..."); + + let data = self.load_service_locations()?; + debug!("Loaded {} instance(s) from ServiceLocationApi", data.len()); + + let mut all_connected = true; + + for instance_data in data { + debug!( + "Found service locations for instance ID: {}", + instance_data.instance_id + ); + debug!( + "Instance has {} service location(s)", + instance_data.service_locations.len() + ); + for location in instance_data.service_locations { + debug!("Service Location: {location:?}"); + + if location.mode == ServiceLocationMode::PreLogon as i32 { + if is_user_logged_in() { + debug!( + "Skipping pre-logon service location '{}' because user is logged in", + location.name + ); + continue; + } + debug!( + "Proceeding to connect pre-logon service location '{}' because no user \ + is logged in", + location.name + ); + } + + let authorization = authorizations + .get(&(instance_data.instance_id.clone(), location.pubkey.clone())); + let action = reconcile_action( + self.is_service_location_connected( + &instance_data.instance_id, + &location.pubkey, + ), + location.posture_check_required, + authorization, + ); + + match action { + ReconcileAction::LeaveConnected => { + debug!( + "Skipping service location '{}' because it's already connected", + location.name + ); + continue; + } + ReconcileAction::LeaveDisconnected => continue, + ReconcileAction::WaitForAuthorization => { + debug!( + "Leaving service location '{}' disconnected: no posture check has \ + approved it yet", + location.name + ); + all_connected = false; + continue; + } + ReconcileAction::Disconnect => { + if let Err(err) = self.disconnect_service_location( + &instance_data.instance_id, + &location.pubkey, + ) { + warn!( + "Failed to disconnect rejected service location '{}': {err}", + location.name + ); + all_connected = false; + } + continue; + } + ReconcileAction::Renew(preshared_key) => { + if let Err(err) = self.reapply_preshared_key( + &instance_data.instance_id, + &location, + &instance_data.private_key, + preshared_key, + ) { + warn!( + "Failed to renew the posture session for '{}': {err}", + location.name + ); + all_connected = false; + } + continue; + } + ReconcileAction::Connect(preshared_key) => { + if let Err(err) = self.setup_service_location_interface( + &location, + &instance_data.private_key, + preshared_key, + ) { + warn!( + "Failed to setup service location interface for '{}': {err:?}", + location.name + ); + all_connected = false; + continue; + } + + self.add_connected_service_location(&instance_data.instance_id, &location); + + if authorization.is_some() { + self.record_posture_session( + &instance_data.instance_id, + &location.pubkey, + ); + } + + debug!( + "Successfully connected to service location '{}'", + location.name + ); + } + } + } + } + + debug!("Auto-connect attempt completed"); + + Ok(all_connected) + } + + /// Persists service locations and resets their runtime connection state. + /// + /// **Idempotent.** Callers push on every poll cycle without doing their own change detection, + /// so this returns early when the data it would write matches what is already on disk, leaving + /// the running tunnels alone. Only a real change proceeds to the reset loop below. + pub fn save_service_locations( + &mut self, + request: &SaveServiceLocationsRequest, + ) -> Result<(), ServiceLocationError> { + let instance_id = request.instance_id.as_str(); + let service_locations = request.service_locations.as_slice(); + debug!( + "Received a request to save {} service location(s) for instance {instance_id}", + service_locations.len(), + ); + + debug!("Service locations to save: {service_locations:?}"); + let old_locations = self + .load_service_locations_for_instance(instance_id)? + .map_or_else(Vec::new, |data| data.service_locations); + let old_pubkeys = old_locations + .iter() + .map(|location| location.pubkey.clone()) + .collect::>(); + let new_pubkeys = service_locations + .iter() + .map(|location| location.pubkey.clone()) + .collect::>(); + + create_dir_all(get_shared_directory()?)?; + + let instance_file_path = get_instance_file_path(instance_id)?; + + let service_location_data = + ServiceLocationData::from_save_request(request, service_locations.to_vec()); + + let json = serde_json::to_string_pretty(&service_location_data)?; + + // Saving is pushed unconditionally on every poll cycle, so nothing having changed is the + // normal case. Return before the reset loop below, which disconnects and reconnects every + // tunnel: proceeding would drop working tunnels at the poll interval forever. ACLs are + // still reapplied, so a file whose ACLs drifted is repaired even on this path. + if is_unchanged_on_disk(&instance_file_path, &json) { + debug!( + "Service locations for instance {instance_id} are unchanged, leaving {} and the \ + existing tunnels untouched", + instance_file_path.display() + ); + if let Some(file_path_str) = instance_file_path.to_str() { + if let Err(err) = set_protected_acls(file_path_str) { + warn!( + "Failed to reapply ACLs on unchanged service location file \ + {file_path_str}: {err}" + ); + } + } + return Ok(()); + } + + debug!( + "Writing service location data to file: {}", + instance_file_path.display() + ); + + fs::write(&instance_file_path, &json)?; + + if let Some(file_path_str) = instance_file_path.to_str() { + debug!("Setting ACLs on service location file: {file_path_str}"); + if let Err(err) = set_protected_acls(file_path_str) { + warn!( + "Failed to set ACLs on service location file {file_path_str}: {err}. \ + File saved but may have insecure permissions." + ); + } else { + debug!("Successfully set ACLs on service location file"); + } + } else { + warn!("Failed to convert file path to string for ACL setting"); + } + + debug!( + "Service locations saved successfully for instance {instance_id} to {}", + instance_file_path.display() + ); + + for removed_pubkey in old_pubkeys.difference(&new_pubkeys) { + self.disconnect_service_location(instance_id, removed_pubkey)?; + } + + let mut reset_failed = false; + for saved_location in service_locations { + match self.reset_service_location_state(instance_id, &saved_location.pubkey) { + Ok(()) => { + debug!( + "Service location '{}' state reset successfully", + saved_location.name + ); + } + Err(err) => { + error!( + "Failed to reset state for service location '{}': {err}", + saved_location.name + ); + reset_failed = true; + } + } + } + + if reset_failed { + return Err(ServiceLocationError::InterfaceError(format!( + "Failed to reset one or more service locations for instance {instance_id}" + ))); + } + + Ok(()) + } + + fn load_service_locations(&self) -> Result, ServiceLocationError> { + let base_dir = get_shared_directory()?; + let all_locations_data = load_service_locations_from_directory(&base_dir)?; + + debug!( + "Loaded service locations data for {} instances", + all_locations_data.len() + ); + Ok(all_locations_data) + } + + fn load_service_location( + &self, + instance_id: &str, + location_pubkey: &str, + ) -> Result, ServiceLocationError> { + debug!("Loading service location for instance {instance_id} and pubkey {location_pubkey}"); + + let instance_file_path = get_instance_file_path(instance_id)?; + let Some(service_location_data) = load_service_locations_from_file(&instance_file_path)? + else { + debug!("No service location file found for instance {instance_id}"); + return Ok(None); + }; + + for location in service_location_data.service_locations { + if location.pubkey == location_pubkey { + debug!( + "Successfully loaded service location for instance {instance_id} and pubkey \ + {location_pubkey}" + ); + return Ok(Some(SingleServiceLocationData { + service_location: location, + instance_id: service_location_data.instance_id, + private_key: service_location_data.private_key, + })); + } + } + + debug!( + "No service location found for instance {instance_id} with pubkey {location_pubkey}" + ); + Ok(None) + } + + fn load_service_locations_for_instance( + &self, + instance_id: &str, + ) -> Result, ServiceLocationError> { + let instance_file_path = get_instance_file_path(instance_id)?; + load_service_locations_from_file(&instance_file_path) + } + + pub fn delete_all_service_locations_for_instance( + &self, + instance_id: &str, + ) -> Result<(), ServiceLocationError> { + debug!("Deleting all service locations for instance {instance_id}"); + + let instance_file_path = get_instance_file_path(instance_id)?; + + if instance_file_path.exists() { + fs::remove_file(&instance_file_path)?; + debug!("Successfully deleted all service locations for instance {instance_id}"); + } else { + debug!("No service location file found for instance {instance_id}"); + } + + Ok(()) + } +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png deleted file mode 100644 index aadd4c3ae..000000000 Binary files a/src-tauri/icons/128x128.png and /dev/null differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png deleted file mode 100644 index 8ac9864d6..000000000 Binary files a/src-tauri/icons/128x128@2x.png and /dev/null differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png deleted file mode 100644 index 642465263..000000000 Binary files a/src-tauri/icons/32x32.png and /dev/null differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index d5a40dc70..000000000 Binary files a/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index 9c5db8e79..000000000 Binary files a/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index c0bd24c95..000000000 Binary files a/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index 3c4f099a4..000000000 Binary files a/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index 3a8afb21d..000000000 Binary files a/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index 08e2174c1..000000000 Binary files a/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index e74a882fc..000000000 Binary files a/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index 2920aaa64..000000000 Binary files a/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index e449a4d51..000000000 Binary files a/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png deleted file mode 100644 index e2c596cb6..000000000 Binary files a/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns deleted file mode 100644 index d963baeb0..000000000 Binary files a/src-tauri/icons/icon.icns and /dev/null differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico deleted file mode 100644 index 20a0d9c3f..000000000 Binary files a/src-tauri/icons/icon.ico and /dev/null differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png deleted file mode 100644 index 8ee5d77e2..000000000 Binary files a/src-tauri/icons/icon.png and /dev/null differ diff --git a/src-tauri/icons/macos/128x128.png b/src-tauri/icons/macos/128x128.png new file mode 100644 index 000000000..1ac9d6ff3 Binary files /dev/null and b/src-tauri/icons/macos/128x128.png differ diff --git a/src-tauri/icons/macos/128x128@2x.png b/src-tauri/icons/macos/128x128@2x.png new file mode 100644 index 000000000..d2c3a8a43 Binary files /dev/null and b/src-tauri/icons/macos/128x128@2x.png differ diff --git a/src-tauri/icons/macos/32x32.png b/src-tauri/icons/macos/32x32.png new file mode 100644 index 000000000..51f3e79c8 Binary files /dev/null and b/src-tauri/icons/macos/32x32.png differ diff --git a/src-tauri/icons/macos/64x64.png b/src-tauri/icons/macos/64x64.png new file mode 100644 index 000000000..2e4a16f1d Binary files /dev/null and b/src-tauri/icons/macos/64x64.png differ diff --git a/src-tauri/icons/macos/Square107x107Logo.png b/src-tauri/icons/macos/Square107x107Logo.png new file mode 100644 index 000000000..4ebe30dc6 Binary files /dev/null and b/src-tauri/icons/macos/Square107x107Logo.png differ diff --git a/src-tauri/icons/macos/Square142x142Logo.png b/src-tauri/icons/macos/Square142x142Logo.png new file mode 100644 index 000000000..157f993d4 Binary files /dev/null and b/src-tauri/icons/macos/Square142x142Logo.png differ diff --git a/src-tauri/icons/macos/Square150x150Logo.png b/src-tauri/icons/macos/Square150x150Logo.png new file mode 100644 index 000000000..c18070e5b Binary files /dev/null and b/src-tauri/icons/macos/Square150x150Logo.png differ diff --git a/src-tauri/icons/macos/Square284x284Logo.png b/src-tauri/icons/macos/Square284x284Logo.png new file mode 100644 index 000000000..3b62b38c0 Binary files /dev/null and b/src-tauri/icons/macos/Square284x284Logo.png differ diff --git a/src-tauri/icons/macos/Square30x30Logo.png b/src-tauri/icons/macos/Square30x30Logo.png new file mode 100644 index 000000000..c2efa03e5 Binary files /dev/null and b/src-tauri/icons/macos/Square30x30Logo.png differ diff --git a/src-tauri/icons/macos/Square310x310Logo.png b/src-tauri/icons/macos/Square310x310Logo.png new file mode 100644 index 000000000..5b61aef70 Binary files /dev/null and b/src-tauri/icons/macos/Square310x310Logo.png differ diff --git a/src-tauri/icons/macos/Square44x44Logo.png b/src-tauri/icons/macos/Square44x44Logo.png new file mode 100644 index 000000000..9b0b98e0a Binary files /dev/null and b/src-tauri/icons/macos/Square44x44Logo.png differ diff --git a/src-tauri/icons/macos/Square71x71Logo.png b/src-tauri/icons/macos/Square71x71Logo.png new file mode 100644 index 000000000..3bf20dfa2 Binary files /dev/null and b/src-tauri/icons/macos/Square71x71Logo.png differ diff --git a/src-tauri/icons/macos/Square89x89Logo.png b/src-tauri/icons/macos/Square89x89Logo.png new file mode 100644 index 000000000..2e7c0e93b Binary files /dev/null and b/src-tauri/icons/macos/Square89x89Logo.png differ diff --git a/src-tauri/icons/macos/StoreLogo.png b/src-tauri/icons/macos/StoreLogo.png new file mode 100644 index 000000000..4b60501f0 Binary files /dev/null and b/src-tauri/icons/macos/StoreLogo.png differ diff --git a/src-tauri/icons/macos/icon.icns b/src-tauri/icons/macos/icon.icns new file mode 100644 index 000000000..fa83b19bf Binary files /dev/null and b/src-tauri/icons/macos/icon.icns differ diff --git a/src-tauri/icons/macos/icon.ico b/src-tauri/icons/macos/icon.ico new file mode 100644 index 000000000..a6d2d15ce Binary files /dev/null and b/src-tauri/icons/macos/icon.ico differ diff --git a/src-tauri/icons/macos/icon.png b/src-tauri/icons/macos/icon.png new file mode 100644 index 000000000..28f35ff77 Binary files /dev/null and b/src-tauri/icons/macos/icon.png differ diff --git a/src-tauri/icons/windows/128x128.png b/src-tauri/icons/windows/128x128.png new file mode 100644 index 000000000..2654b91a5 Binary files /dev/null and b/src-tauri/icons/windows/128x128.png differ diff --git a/src-tauri/icons/windows/128x128@2x.png b/src-tauri/icons/windows/128x128@2x.png new file mode 100644 index 000000000..f78ee7805 Binary files /dev/null and b/src-tauri/icons/windows/128x128@2x.png differ diff --git a/src-tauri/icons/windows/32x32.png b/src-tauri/icons/windows/32x32.png new file mode 100644 index 000000000..8af4a6a8f Binary files /dev/null and b/src-tauri/icons/windows/32x32.png differ diff --git a/src-tauri/icons/windows/64x64.png b/src-tauri/icons/windows/64x64.png new file mode 100644 index 000000000..13c7101f6 Binary files /dev/null and b/src-tauri/icons/windows/64x64.png differ diff --git a/src-tauri/icons/windows/Square107x107Logo.png b/src-tauri/icons/windows/Square107x107Logo.png new file mode 100644 index 000000000..8c7b03a3f Binary files /dev/null and b/src-tauri/icons/windows/Square107x107Logo.png differ diff --git a/src-tauri/icons/windows/Square142x142Logo.png b/src-tauri/icons/windows/Square142x142Logo.png new file mode 100644 index 000000000..8c0d477df Binary files /dev/null and b/src-tauri/icons/windows/Square142x142Logo.png differ diff --git a/src-tauri/icons/windows/Square150x150Logo.png b/src-tauri/icons/windows/Square150x150Logo.png new file mode 100644 index 000000000..d56a40f85 Binary files /dev/null and b/src-tauri/icons/windows/Square150x150Logo.png differ diff --git a/src-tauri/icons/windows/Square284x284Logo.png b/src-tauri/icons/windows/Square284x284Logo.png new file mode 100644 index 000000000..aba36d4f6 Binary files /dev/null and b/src-tauri/icons/windows/Square284x284Logo.png differ diff --git a/src-tauri/icons/windows/Square30x30Logo.png b/src-tauri/icons/windows/Square30x30Logo.png new file mode 100644 index 000000000..2bb3d089f Binary files /dev/null and b/src-tauri/icons/windows/Square30x30Logo.png differ diff --git a/src-tauri/icons/windows/Square310x310Logo.png b/src-tauri/icons/windows/Square310x310Logo.png new file mode 100644 index 000000000..cc40252d1 Binary files /dev/null and b/src-tauri/icons/windows/Square310x310Logo.png differ diff --git a/src-tauri/icons/windows/Square44x44Logo.png b/src-tauri/icons/windows/Square44x44Logo.png new file mode 100644 index 000000000..310590310 Binary files /dev/null and b/src-tauri/icons/windows/Square44x44Logo.png differ diff --git a/src-tauri/icons/windows/Square71x71Logo.png b/src-tauri/icons/windows/Square71x71Logo.png new file mode 100644 index 000000000..aa3ddf7c7 Binary files /dev/null and b/src-tauri/icons/windows/Square71x71Logo.png differ diff --git a/src-tauri/icons/windows/Square89x89Logo.png b/src-tauri/icons/windows/Square89x89Logo.png new file mode 100644 index 000000000..8f9446bd8 Binary files /dev/null and b/src-tauri/icons/windows/Square89x89Logo.png differ diff --git a/src-tauri/icons/windows/StoreLogo.png b/src-tauri/icons/windows/StoreLogo.png new file mode 100644 index 000000000..204148e1c Binary files /dev/null and b/src-tauri/icons/windows/StoreLogo.png differ diff --git a/src-tauri/icons/windows/icon.icns b/src-tauri/icons/windows/icon.icns new file mode 100644 index 000000000..6ca93db78 Binary files /dev/null and b/src-tauri/icons/windows/icon.icns differ diff --git a/src-tauri/icons/windows/icon.ico b/src-tauri/icons/windows/icon.ico new file mode 100644 index 000000000..464126c7f Binary files /dev/null and b/src-tauri/icons/windows/icon.ico differ diff --git a/src-tauri/icons/windows/icon.png b/src-tauri/icons/windows/icon.png new file mode 100644 index 000000000..78ccaaa97 Binary files /dev/null and b/src-tauri/icons/windows/icon.png differ diff --git a/src-tauri/migrations/20260511093103_posture_check_required.sql b/src-tauri/migrations/20260511093103_posture_check_required.sql new file mode 100644 index 000000000..c9743138e --- /dev/null +++ b/src-tauri/migrations/20260511093103_posture_check_required.sql @@ -0,0 +1 @@ +ALTER TABLE location ADD COLUMN posture_check_required BOOLEAN NOT NULL DEFAULT false; diff --git a/src-tauri/migrations/20260513120000_add_location_mfa_method.sql b/src-tauri/migrations/20260513120000_add_location_mfa_method.sql new file mode 100644 index 000000000..137ef86cf --- /dev/null +++ b/src-tauri/migrations/20260513120000_add_location_mfa_method.sql @@ -0,0 +1,6 @@ +-- 0 - TOTP +-- 2 - OIDC +-- NULL - unset (used for Disabled MFA mode locations) +ALTER TABLE location ADD COLUMN mfa_method INTEGER; +UPDATE location SET mfa_method = 0 WHERE location_mfa_mode = 2; +UPDATE location SET mfa_method = 2 WHERE location_mfa_mode = 3; diff --git a/src-tauri/migrations/20260724000000_disable_tunnels.sql b/src-tauri/migrations/20260724000000_disable_tunnels.sql new file mode 100644 index 000000000..5094975e8 --- /dev/null +++ b/src-tauri/migrations/20260724000000_disable_tunnels.sql @@ -0,0 +1 @@ +ALTER TABLE instance ADD COLUMN disable_tunnels BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src-tauri/migrations/20260729120000_add_stats_diffs.sql b/src-tauri/migrations/20260729120000_add_stats_diffs.sql new file mode 100644 index 000000000..8138563ef --- /dev/null +++ b/src-tauri/migrations/20260729120000_add_stats_diffs.sql @@ -0,0 +1,11 @@ +ALTER TABLE location_stats ADD COLUMN upload_diff INTEGER NOT NULL DEFAULT 0; +ALTER TABLE location_stats ADD COLUMN download_diff INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE tunnel_stats ADD COLUMN upload_diff BIGINT NOT NULL DEFAULT 0; +ALTER TABLE tunnel_stats ADD COLUMN download_diff BIGINT NOT NULL DEFAULT 0; + +CREATE INDEX idx_location_stats_location_collected + ON location_stats (location_id, collected_at); + +CREATE INDEX idx_tunnel_stats_tunnel_collected + ON tunnel_stats (tunnel_id, collected_at); diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml new file mode 100644 index 000000000..e6aac4cc8 --- /dev/null +++ b/src-tauri/permissions/default.toml @@ -0,0 +1,57 @@ +[[permission]] +identifier = "allow-app-commands" +description = "Allow all application commands for both UI windows." +commands.allow = [ + "enrollment_start", + "enrollment_create_device", + "enrollment_activate_user", + "enrollment_register_mfa_start", + "enrollment_register_mfa_finish", + "enrollment_network_info", + "enrollment_finish", + "mfa_start", + "mfa_finish_code", + "mfa_poll_openid", + "mfa_connect_mobile_approve", + "cancel_mfa", + "all_locations", + "has_any_visible_locations", + "save_device_config", + "all_instances", + "connect", + "disconnect", + "update_instance", + "location_stats", + "location_interface_details", + "all_connections", + "last_connection", + "active_connection", + "update_location_routing", + "delete_instance", + "parse_tunnel_config", + "save_tunnel", + "all_tunnels", + "open_link", + "tunnel_details", + "update_tunnel", + "delete_tunnel", + "get_latest_app_version", + "start_global_logwatcher", + "stop_global_logwatcher", + "command_get_app_config", + "command_set_app_config", + "get_provisioning_config", + "get_platform_header", + "set_location_mfa_method", + "open_tray_window", + "open_full_view_window", + "swap_to_tray", + "swap_to_full_view", + "close_tray_window", + "close_welcome_window", + "all_active_connections", + "disconnect_locations", + "get_posture_data", + "get_session_state", + "patch_session_state", +] diff --git a/src-tauri/proto b/src-tauri/proto index 5dfc8c8d2..7e1c6a5ed 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit 5dfc8c8d23ac0613108a2b7b921fd9a97613bb3a +Subproject commit 7e1c6a5ed1336522bff0610edf1e216f7dcde444 diff --git a/src-tauri/resources-windows/scripts/Sign-Binaries.ps1 b/src-tauri/resources-windows/scripts/Sign-Binaries.ps1 new file mode 100644 index 000000000..bbef2f501 --- /dev/null +++ b/src-tauri/resources-windows/scripts/Sign-Binaries.ps1 @@ -0,0 +1,54 @@ +$ErrorActionPreference = 'Stop' + +$tauriConfigPath = Join-Path $PSScriptRoot '..\..\tauri.conf.json' +$tauriConfig = Get-Content $tauriConfigPath -Raw | ConvertFrom-Json +$windowsConfig = $tauriConfig.bundle.windows + +$thumbprint = $windowsConfig.certificateThumbprint + +$timestampUrl = if ($env:DEFGUARD_WINDOWS_TIMESTAMP_URL) { + $env:DEFGUARD_WINDOWS_TIMESTAMP_URL +} else { + $windowsConfig.timestampUrl +} + +$digestAlgorithm = if ($windowsConfig.digestAlgorithm) { + $windowsConfig.digestAlgorithm.ToUpperInvariant() +} else { + 'SHA256' +} + +if (-not $thumbprint) { + throw 'Windows certificate thumbprint is not configured.' +} + +# Resolve signtool to a plain path string. Get-Command returns a +# CommandInfo (use .Source); the Windows Kits fallback returns a +# FileInfo (use .FullName). +$signtoolPath = (Get-Command signtool.exe -ErrorAction SilentlyContinue).Source +if (-not $signtoolPath) { + $signtoolPath = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName +} + +if (-not $signtoolPath) { + throw 'signtool.exe was not found.' +} + +$binaries = @( + 'target\release\defguard-service.exe' +) + +foreach ($binary in $binaries) { + if (-not (Test-Path $binary)) { + throw "Binary not found: $binary" + } + + Write-Host "Signing $binary" + & $signtoolPath sign /sha1 $thumbprint /fd $digestAlgorithm /tr $timestampUrl /td $digestAlgorithm $binary + if ($LASTEXITCODE -ne 0) { + throw "Failed to sign $binary" + } +} diff --git a/src-tauri/resources/icons/tray-32x32-black-active.png b/src-tauri/resources/icons/tray-32x32-black-active.png deleted file mode 100644 index 69e37b717..000000000 Binary files a/src-tauri/resources/icons/tray-32x32-black-active.png and /dev/null differ diff --git a/src-tauri/resources/icons/tray-32x32-black.png b/src-tauri/resources/icons/tray-32x32-black.png deleted file mode 100644 index 62a64e761..000000000 Binary files a/src-tauri/resources/icons/tray-32x32-black.png and /dev/null differ diff --git a/src-tauri/resources/icons/tray-32x32-color-active.png b/src-tauri/resources/icons/tray-32x32-color-active.png deleted file mode 100644 index 5602c6201..000000000 Binary files a/src-tauri/resources/icons/tray-32x32-color-active.png and /dev/null differ diff --git a/src-tauri/resources/icons/tray-32x32-color.png b/src-tauri/resources/icons/tray-32x32-color.png deleted file mode 100644 index 7a809fb82..000000000 Binary files a/src-tauri/resources/icons/tray-32x32-color.png and /dev/null differ diff --git a/src-tauri/resources/icons/tray-32x32-gray-active.png b/src-tauri/resources/icons/tray-32x32-gray-active.png deleted file mode 100644 index 9ab5bd446..000000000 Binary files a/src-tauri/resources/icons/tray-32x32-gray-active.png and /dev/null differ diff --git a/src-tauri/resources/icons/tray-32x32-gray.png b/src-tauri/resources/icons/tray-32x32-gray.png deleted file mode 100644 index 3352072fe..000000000 Binary files a/src-tauri/resources/icons/tray-32x32-gray.png and /dev/null differ diff --git a/src-tauri/resources/icons/tray-32x32-white-active.png b/src-tauri/resources/icons/tray-32x32-white-active.png deleted file mode 100644 index f674c43be..000000000 Binary files a/src-tauri/resources/icons/tray-32x32-white-active.png and /dev/null differ diff --git a/src-tauri/resources/icons/tray-32x32-white.png b/src-tauri/resources/icons/tray-32x32-white.png deleted file mode 100644 index b7b2e5daa..000000000 Binary files a/src-tauri/resources/icons/tray-32x32-white.png and /dev/null differ diff --git a/src-tauri/resources/icons/tray/blue-connected.png b/src-tauri/resources/icons/tray/blue-connected.png new file mode 100644 index 000000000..2351ec33e Binary files /dev/null and b/src-tauri/resources/icons/tray/blue-connected.png differ diff --git a/src-tauri/resources/icons/tray/blue.png b/src-tauri/resources/icons/tray/blue.png new file mode 100644 index 000000000..9c9e80191 Binary files /dev/null and b/src-tauri/resources/icons/tray/blue.png differ diff --git a/src-tauri/resources/icons/tray/dark-connected.png b/src-tauri/resources/icons/tray/dark-connected.png new file mode 100644 index 000000000..817011ae7 Binary files /dev/null and b/src-tauri/resources/icons/tray/dark-connected.png differ diff --git a/src-tauri/resources/icons/tray/dark.png b/src-tauri/resources/icons/tray/dark.png new file mode 100644 index 000000000..94ab992dd Binary files /dev/null and b/src-tauri/resources/icons/tray/dark.png differ diff --git a/src-tauri/resources/icons/tray/white-connected.png b/src-tauri/resources/icons/tray/white-connected.png new file mode 100644 index 000000000..d1f5136f5 Binary files /dev/null and b/src-tauri/resources/icons/tray/white-connected.png differ diff --git a/src-tauri/resources/icons/tray/white.png b/src-tauri/resources/icons/tray/white.png new file mode 100644 index 000000000..6cbdeddd7 Binary files /dev/null and b/src-tauri/resources/icons/tray/white.png differ diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs deleted file mode 100644 index 731b54e53..000000000 --- a/src-tauri/src/app_config.rs +++ /dev/null @@ -1,146 +0,0 @@ -use std::{ - fs::{create_dir_all, File, OpenOptions}, - path::PathBuf, -}; - -use log::LevelFilter; -use serde::{Deserialize, Serialize}; -use struct_patch::Patch; -use strum::{AsRefStr, EnumString}; -use tauri::{AppHandle, Manager}; - -#[cfg(unix)] -use crate::set_perms; - -static APP_CONFIG_FILE_NAME: &str = "config.json"; - -fn get_config_file_path(app: &AppHandle) -> PathBuf { - let mut config_file_path = app - .path() - .app_data_dir() - .expect("Failed to access app data"); - if !config_file_path.exists() { - create_dir_all(&config_file_path).expect("Failed to create missing app data dir"); - } - #[cfg(unix)] - set_perms(&config_file_path); - config_file_path.push(APP_CONFIG_FILE_NAME); - #[cfg(unix)] - set_perms(&config_file_path); - config_file_path -} - -fn get_config_file(app: &AppHandle, for_write: bool) -> File { - let config_file_path = get_config_file_path(app); - OpenOptions::new() - .create(true) - .read(true) - .truncate(for_write) - .write(true) - .open(config_file_path) - .expect("Failed to create and open app config.") -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum AppTheme { - Light, - Dark, -} - -#[derive(AsRefStr, Clone, Copy, Debug, Deserialize, EnumString, PartialEq, Serialize)] -#[strum(serialize_all = "lowercase")] -#[serde(rename_all = "lowercase")] -pub enum AppTrayTheme { - Color, - White, - Black, - Gray, -} - -// config stored in config.json in app data -// config is loaded once at startup and saved when modified to the app data file -// information's needed at startup of the application. -#[derive(Clone, Debug, Deserialize, Patch, Serialize)] -#[patch(attribute(derive(Debug, Deserialize, Serialize)))] -pub struct AppConfig { - pub theme: AppTheme, - pub tray_theme: AppTrayTheme, - pub check_for_updates: bool, - pub log_level: LevelFilter, - /// In seconds. How much time after last network activity the connection is automatically dropped. - pub peer_alive_period: u32, - /// Maximal transmission unit. 0 means default value. - mtu: u32, -} - -// Important: keep in sync with client store default in frontend -impl Default for AppConfig { - fn default() -> Self { - Self { - theme: AppTheme::Light, - check_for_updates: true, - tray_theme: AppTrayTheme::Color, - log_level: LevelFilter::Info, - peer_alive_period: 300, - mtu: 0, - } - } -} - -impl AppConfig { - /// Try to load application configuration from application data directory. - /// If reading the configuration file fails, default settings will be returned. - #[must_use] - pub fn new(app: &AppHandle) -> Self { - let config_path = get_config_file_path(app); - if !config_path.exists() { - eprintln!( - "Application configuration file doesn't exist; initializing it with the defaults." - ); - let res = Self::default(); - res.save(app); - return res; - } - let config_file = get_config_file(app, false); - let mut app_config = Self::default(); - match serde_json::from_reader::<_, AppConfigPatch>(config_file) { - Ok(patch) => { - app_config.apply(patch); - } - // If deserialization fails, remove file and return the default. - Err(err) => { - eprintln!( - "Failed to deserialize application configuration file: {err}. Using defaults." - ); - app_config.save(app); - } - } - app_config - } - - /// Saves currently loaded AppConfig into app data dir file. - /// Warning: this will always overwrite file contents. - pub fn save(&self, app: &AppHandle) { - let file = get_config_file(app, true); - match serde_json::to_writer(file, &self) { - Ok(()) => debug!("Application configuration file has been saved."), - Err(err) => { - error!( - "Application configuration file couldn't be saved. Failed to serialize: {err}", - ); - } - } - } - - /// Wraps MTU in an Option. We don't store Option directly in AppConfig to avoid struct-patch - /// ambiguity when applying updates coming from the frontend. An incoming MTU value of 0 is - /// interpreted as a request to fall back to the default. - #[must_use] - pub fn mtu(&self) -> Option { - match self.mtu { - 0 => None, - v => Some(v), - } - } -} diff --git a/src-tauri/src/apple.rs b/src-tauri/src/apple.rs index 708afea28..37ff8a576 100644 --- a/src-tauri/src/apple.rs +++ b/src-tauri/src/apple.rs @@ -1,77 +1,28 @@ //! Interchangeability and communication with VPNExtension (written in Swift). -use std::{ - collections::HashMap, - hint::spin_loop, - net::IpAddr, - ptr::NonNull, - str::FromStr, - sync::{ - atomic::{AtomicBool, Ordering}, - mpsc::{self, channel, Receiver, RecvTimeoutError, Sender}, - Arc, LazyLock, Mutex, - }, - time::Duration, -}; +use std::{collections::HashMap, time::Duration}; -use block2::RcBlock; -use common::dns_owned; -use defguard_wireguard_rs::{key::Key, net::IpAddrMask, peer::Peer}; -use objc2::{ - rc::Retained, - runtime::{AnyObject, ProtocolObject}, -}; -use objc2_foundation::{ - ns_string, NSArray, NSData, NSDate, NSDictionary, NSError, NSMutableArray, NSMutableDictionary, - NSNotification, NSNotificationCenter, NSNumber, NSObjectProtocol, NSOperationQueue, NSRunLoop, - NSString, -}; -use objc2_network_extension::{ - NETunnelProviderManager, NETunnelProviderProtocol, NETunnelProviderSession, NEVPNConnection, - NEVPNStatus, NEVPNStatusDidChangeNotification, +use defguard_client_core::connection::{ + active_connections::find_connection, + apple::{manager_for_key_and_value, LOCATION_ID, TUNNEL_ID, VPN_STATE_UPDATE_COMMS}, }; -use serde::Deserialize; +use objc2::rc::Retained; +use objc2_network_extension::{NETunnelProviderManager, NEVPNStatus}; use tauri::{AppHandle, Emitter, Manager}; +use tokio::time::sleep; use tracing::Level; use crate::{ - active_connections::find_connection, appstate::AppState, - database::{ - models::{ - instance::{ClientTrafficPolicy, Instance}, - location::Location, - tunnel::Tunnel, - wireguard_keys::WireguardKeys, - Id, - }, - DB_POOL, - }, - error::Error, + database::models::{get_all_tunnels_locations, location::Location, tunnel::Tunnel, Id}, events::EventKey, log_watcher::service_log_watcher::spawn_log_watcher_task, - tray::{configure_tray_icon, reload_tray_menu, show_main_window}, - utils::{DEFAULT_ROUTE_IPV4, DEFAULT_ROUTE_IPV6}, + tray::{configure_tray_icon, reload_tray_menu}, + window_manager::trigger_mfa, ConnectionType, }; -const PLUGIN_BUNDLE_ID: &str = "net.defguard.VPNExtension"; const SYSTEM_SYNC_DELAY: Duration = Duration::from_millis(500); -const LOCATION_ID: &str = "locationId"; -const TUNNEL_ID: &str = "tunnelId"; - -static OBSERVER_COMMS: LazyLock<( - Mutex>, - Mutex>>, -)> = LazyLock::new(|| { - let (tx, rx) = mpsc::channel(); - (Mutex::new(tx), Mutex::new(Some(rx))) -}); -static VPN_STATE_UPDATE_COMMS: LazyLock<(Mutex>, Mutex>>)> = - LazyLock::new(|| { - let (tx, rx) = mpsc::channel(); - (Mutex::new(tx), Mutex::new(Some(rx))) - }); /// Thread responsible for handling VPN status update requests. /// This is an async function. @@ -88,7 +39,7 @@ pub async fn connection_state_update_thread(app_handle: &AppHandle) { debug!("Waiting for status update message from channel..."); while receiver.recv().is_ok() { debug!("Status update message received, synchronizing state..."); - tokio::time::sleep(SYSTEM_SYNC_DELAY).await; + sleep(SYSTEM_SYNC_DELAY).await; sync_connections_with_system(app_handle).await; reload_tray_menu(app_handle).await; let _ = configure_tray_icon(app_handle).await; @@ -99,349 +50,204 @@ pub async fn connection_state_update_thread(app_handle: &AppHandle) { /// Synchronize the app's connection state with the system's VPN state. /// This checks all locations and tunnels and updates the app state to match /// what's actually running in the system. -pub async fn sync_connections_with_system(app_handle: &AppHandle) { - let pool = DB_POOL.clone(); +async fn sync_connections_with_system(app_handle: &AppHandle) { let app_state = app_handle.state::(); + let (tunnels, locations) = get_all_tunnels_locations().await; - if let Ok(locations) = Location::all(&pool, false).await { - for location in locations { - debug!( - "Synchronizing VPN status for location with system status: {}. Querying status...", - location.name - ); - let status = location.status(); - debug!( - "Location {} (ID {}) status: {status:?}", - location.name, location.id - ); + for location in locations { + debug!( + "Synchronizing VPN status for location with system status: {}. Querying status...", + location.name + ); + let status = location.status(); + debug!( + "Location {} (ID {}) status: {status:?}", + location.name, location.id + ); - match status { - Some(NEVPNStatus::Connected) => { - debug!("Location {} is connected", location.name); - if find_connection(location.id, crate::ConnectionType::Location) - .await - .is_some() - { - debug!( - "Location {} has already a connected state, skipping synchronization", + match status { + Some(NEVPNStatus::Connected) => { + debug!("Location {} is connected", location.name); + if find_connection(location.id, crate::ConnectionType::Location) + .await + .is_some() + { + debug!( + "Location {} has already a connected state, skipping synchronization", + location.name + ); + } else { + // Check if location requires MFA - if so, we need to cancel this connection + // and trigger MFA flow through the app + if location.mfa_enabled() { + info!( + "Location {} requires MFA but was started from system settings, \ + canceling system connection and triggering MFA flow", location.name ); - } else { - // Check if location requires MFA - if so, we need to cancel this connection - // and trigger MFA flow through the app - if location.mfa_enabled() { - info!( - "Location {} requires MFA but was started from system settings, \ - canceling system connection and triggering MFA flow", - location.name - ); - location.stop_vpn_tunnel(); - show_main_window(app_handle); - let _ = app_handle.emit(EventKey::MfaTrigger.into(), &location); - continue; - } - - debug!("Adding connection for location {}", location.name); + let _ = location.stop_vpn_tunnel(); + trigger_mfa(app_handle, &location); + continue; + } - app_state - .add_connection( - location.id, - &location.name, - crate::ConnectionType::Location, - ) - .await; - app_handle - .emit(EventKey::ConnectionChanged.into(), ()) - .unwrap(); + debug!("Adding connection for location {}", location.name); - debug!( - "Spawning log watcher for location {} (started from system settings)", - location.name - ); - if let Err(e) = spawn_log_watcher_task( - app_handle, + app_state + .add_connection( location.id, - location.name.clone(), - ConnectionType::Location, - Level::DEBUG, - None, + &location.name, + crate::ConnectionType::Location, ) - .await - { - warn!( - "Failed to spawn log watcher for location {}: {e}", - location.name - ); - } - } - } - Some(NEVPNStatus::Disconnected) => { - debug!("Location {} is disconnected", location.name); - if find_connection(location.id, crate::ConnectionType::Location) - .await - .is_some() + .await; + app_handle + .emit(EventKey::ConnectionChanged.into(), ()) + .unwrap(); + + debug!( + "Spawning log watcher for location {} (started from system settings)", + location.name + ); + if let Err(e) = spawn_log_watcher_task( + app_handle, + location.id, + location.name.clone(), + ConnectionType::Location, + Level::DEBUG, + None, + ) + .await { - debug!("Removing connection for location {}", location.name); - app_state - .remove_connection(location.id, crate::ConnectionType::Location) - .await; - app_handle - .emit(EventKey::ConnectionChanged.into(), ()) - .unwrap(); - } else { - debug!( - "Location {} has no active connection, skipping removal", + warn!( + "Failed to spawn log watcher for location {}: {e}", location.name ); } } - Some(unknown_status) => { - debug!( - "Location {} has unknown status {unknown_status:?}, skipping synchronization", - location.name - ); - } - None => { + } + Some(NEVPNStatus::Disconnected) => { + debug!("Location {} is disconnected", location.name); + if find_connection(location.id, crate::ConnectionType::Location) + .await + .is_some() + { + debug!("Removing connection for location {}", location.name); + app_state + .remove_connection(location.id, crate::ConnectionType::Location) + .await; + app_handle + .emit(EventKey::ConnectionChanged.into(), ()) + .unwrap(); + } else { debug!( - "Couldn't find configuration for tunnel {}, skipping synchronization", + "Location {} has no active connection, skipping removal", location.name ); } } + Some(unknown_status) => { + debug!( + "Location {} has unknown status {unknown_status:?}, skipping synchronization", + location.name + ); + } + None => { + debug!( + "Couldn't find configuration for tunnel {}, skipping synchronization", + location.name + ); + } } } - if let Ok(tunnels) = Tunnel::all(&pool).await { - for tunnel in tunnels { - debug!( - "Synchronizing VPN status for tunnel with system status: {}. Querying status...", - tunnel.name - ); - let status = tunnel.status(); - debug!( - "Location {} (ID {}) status: {status:?}", - tunnel.name, tunnel.id - ); + for tunnel in tunnels { + debug!( + "Synchronizing VPN status for tunnel with system status: {}. Querying status...", + tunnel.name + ); + let status = tunnel.status(); + debug!( + "Location {} (ID {}) status: {status:?}", + tunnel.name, tunnel.id + ); - match status { - Some(NEVPNStatus::Connected) => { - debug!("Location {} is connected", tunnel.name); - if find_connection(tunnel.id, crate::ConnectionType::Tunnel) - .await - .is_some() - { - debug!( - "Location {} has already a connected state, skipping synchronization", - tunnel.name - ); - } else { - debug!("Adding connection for location {}", tunnel.name); + match status { + Some(NEVPNStatus::Connected) => { + debug!("Location {} is connected", tunnel.name); + if find_connection(tunnel.id, crate::ConnectionType::Tunnel) + .await + .is_some() + { + debug!( + "Location {} has already a connected state, skipping synchronization", + tunnel.name + ); + } else { + debug!("Adding connection for location {}", tunnel.name); - app_state - .add_connection(tunnel.id, &tunnel.name, crate::ConnectionType::Tunnel) - .await; + app_state + .add_connection(tunnel.id, &tunnel.name, crate::ConnectionType::Tunnel) + .await; - app_handle - .emit(EventKey::ConnectionChanged.into(), ()) - .unwrap(); + app_handle + .emit(EventKey::ConnectionChanged.into(), ()) + .unwrap(); - // Spawn log watcher for this tunnel (VPN was started from system settings) - debug!( - "Spawning log watcher for tunnel {} (started from system settings)", - tunnel.name - ); - if let Err(e) = spawn_log_watcher_task( - app_handle, - tunnel.id, - tunnel.name.clone(), - ConnectionType::Tunnel, - Level::DEBUG, - None, - ) - .await - { - warn!( - "Failed to spawn log watcher for tunnel {}: {e}", - tunnel.name - ); - } - } - } - Some(NEVPNStatus::Disconnected) => { - debug!("Location {} is disconnected", tunnel.name); - if find_connection(tunnel.id, crate::ConnectionType::Tunnel) - .await - .is_some() + // Spawn log watcher for this tunnel (VPN was started from system settings) + debug!( + "Spawning log watcher for tunnel {} (started from system settings)", + tunnel.name + ); + if let Err(e) = spawn_log_watcher_task( + app_handle, + tunnel.id, + tunnel.name.clone(), + ConnectionType::Tunnel, + Level::DEBUG, + None, + ) + .await { - debug!("Removing connection for location {}", tunnel.name); - app_state - .remove_connection(tunnel.id, crate::ConnectionType::Tunnel) - .await; - app_handle - .emit(EventKey::ConnectionChanged.into(), ()) - .unwrap(); - } else { - debug!( - "Location {} has no active connection, skipping removal", + warn!( + "Failed to spawn log watcher for tunnel {}: {e}", tunnel.name ); } } - Some(unknown_status) => { - debug!( - "Location {} has unknown status {:?}, skipping synchronization", - tunnel.name, unknown_status - ); - } - None => { - debug!( - "Couldn't find configuration for tunnel {}, skipping synchronization", - tunnel.name - ); - } } - } - } -} - -const OBSERVER_CLEANUP_INTERVAL: Duration = Duration::from_secs(30); - -/// Thread responsible for observing VPN status changes. -/// This is intentionally a blocking function, as it uses the Objective-C objects which are not -/// thread safe. -pub fn observer_thread( - initial_managers: HashMap<(&'static str, Id), Retained>, -) { - debug!("Starting VPN connection observer thread"); - let receiver = { - let mut rx_opt = OBSERVER_COMMS - .1 - .lock() - .expect("Failed to lock observer receiver"); - rx_opt.take().expect("Receiver already taken") - }; - - let mut observers = HashMap::new(); - - // spawn initial observers for existing managers - for ((key, value), manager) in initial_managers { - debug!("Spawning initial observer for manager with key: {key}, value: {value}"); - let connection = unsafe { manager.connection() }; - let observer = create_observer(&connection); - debug!("Registered initial observer for manager with key: {key}, value: {value}"); - observers.insert((key, value), observer); - } - - loop { - match receiver.recv_timeout(OBSERVER_CLEANUP_INTERVAL) { - Ok(message) => { - debug!("Received message to observe the following connection: {message:?}"); - - let (key, value) = message; - - if observers.contains_key(&(key, value)) { + Some(NEVPNStatus::Disconnected) => { + debug!("Location {} is disconnected", tunnel.name); + if find_connection(tunnel.id, crate::ConnectionType::Tunnel) + .await + .is_some() + { + debug!("Removing connection for location {}", tunnel.name); + app_state + .remove_connection(tunnel.id, crate::ConnectionType::Tunnel) + .await; + app_handle + .emit(EventKey::ConnectionChanged.into(), ()) + .unwrap(); + } else { debug!( - "Observer for manager with key: {key}, value: {value} already exists, - skipping", + "Location {} has no active connection, skipping removal", + tunnel.name ); - continue; } - - let manager = manager_for_key_and_value(key, value).unwrap(); - let connection = unsafe { manager.connection() }; - let observer = create_observer(&connection); - - observers.insert((key, value), observer); - debug!("Registered observer for manager with key: {key}, value: {value}"); } - Err(RecvTimeoutError::Timeout) => { - debug!("Performing periodic cleanup of dead observers"); - let mut dead_keys = Vec::new(); - - for (key, value) in observers.keys() { - if manager_for_key_and_value(key, *value).is_none() { - debug!( - "Manager for key: {key}, value: {value} no longer exists, marking for - removal" - ); - dead_keys.push((*key, *value)); - } - } - - for dead_key in dead_keys { - if let Some(_observer) = observers.remove(&dead_key) { - debug!( - "Removed dead VPN connection observer for key: {}, value: {}", - dead_key.0, dead_key.1 - ); - } - } + Some(unknown_status) => { + debug!( + "Location {} has unknown status {:?}, skipping synchronization", + tunnel.name, unknown_status + ); } - Err(RecvTimeoutError::Disconnected) => { - error!("Observer receiver channel disconnected, exiting observer thread"); - break; + None => { + debug!( + "Couldn't find configuration for tunnel {}, skipping synchronization", + tunnel.name + ); } } } - - debug!("Exiting VPN connection observer thread"); -} - -/// Tunnel statistics shared with VPNExtension (written in Swift). -#[derive(Deserialize)] -#[repr(C)] -#[serde(rename_all = "camelCase")] -pub(crate) struct Stats { - pub(crate) location_id: Option, - pub(crate) tunnel_id: Option, - pub(crate) tx_bytes: u64, - pub(crate) rx_bytes: u64, - pub(crate) last_handshake: u64, -} - -/// Run [`NSRunLoop`] until semaphore becomes `true`. -pub fn spawn_runloop_and_wait_for(semaphore: &Arc) { - const ONE_SECOND: f64 = 1.; - let run_loop = NSRunLoop::currentRunLoop(); - let mut date = NSDate::dateWithTimeIntervalSinceNow(ONE_SECOND); - loop { - run_loop.runUntilDate(&date); - if semaphore.load(Ordering::Acquire) { - break; - } - date = date.dateByAddingTimeInterval(ONE_SECOND); - } -} - -/// Handle VPN status change. -fn vpn_status_change_handler(notification: &NSNotification) { - let name = notification.name(); - debug!("Received VPN status change notification: {name:?}"); - VPN_STATE_UPDATE_COMMS - .0 - .lock() - .expect("Failed to lock state update sender") - .send(()) - .expect("Failed to send to state update channel"); - debug!("Sent status update request to channel"); -} - -/// Observe VPN status change. -fn create_observer(object: &NEVPNConnection) -> Retained> { - let center = NSNotificationCenter::defaultCenter(); - let block = RcBlock::new(move |notification: NonNull| { - vpn_status_change_handler(unsafe { notification.as_ref() }); - }); - let queue = NSOperationQueue::mainQueue(); - unsafe { - let name = NEVPNStatusDidChangeNotification; - center.addObserverForName_object_queue_usingBlock( - Some(name), - Some(object), - Some(&queue), - &block, - ) - } } #[must_use] @@ -465,740 +271,3 @@ pub fn get_managers_for_tunnels_and_locations( managers } - -/// Try to get `Id` out of manager. ID is embedded in configuration dictionary under `key`. -fn id_from_manager(manager: &NETunnelProviderManager, key: &NSString) -> Option { - let plugin_bundle_id = ns_string!(PLUGIN_BUNDLE_ID); - - let vpn_protocol = (unsafe { manager.protocolConfiguration() })?; - let Ok(tunnel_protocol) = vpn_protocol.downcast::() else { - error!("Failed to downcast to NETunnelProviderProtocol"); - return None; - }; - // Sometimes all managers from all apps come through, so filter by bundle ID. - if let Some(bundle_id) = unsafe { tunnel_protocol.providerBundleIdentifier() } { - if &*bundle_id != plugin_bundle_id { - return None; - } - } - - if let Some(config_dict) = unsafe { tunnel_protocol.providerConfiguration() } { - if let Some(any_object) = config_dict.objectForKey(key) { - let Ok(id) = any_object.downcast::() else { - warn!("Failed to downcast ID to NSNumber"); - return None; - }; - return Some(id.as_i64()); - } - } - - None -} - -/// Try to find [`NETunnelProviderManager`] in system settings that matches key and value. -/// Key is usually `locationId` or `tunnelId`. -fn manager_for_key_and_value(key: &str, value: Id) -> Option> { - let key_string = NSString::from_str(key); - let (tx, rx) = channel(); - - let handler = RcBlock::new( - move |managers_ptr: *mut NSArray, error_ptr: *mut NSError| { - if !error_ptr.is_null() { - error!("Failed to load tunnel provider managers."); - return; - } - - let Some(managers) = (unsafe { managers_ptr.as_ref() }) else { - error!("No managers"); - return; - }; - - for manager in managers { - if let Some(id) = id_from_manager(&manager, &key_string) { - if id == value { - // This is the manager we were looking for. - tx.send(Some(manager)).expect("Sender is dead"); - return; - } - } - } - - tx.send(None).expect("Sender is dead"); - }, - ); - unsafe { - NETunnelProviderManager::loadAllFromPreferencesWithCompletionHandler(&handler); - } - - rx.recv().expect("Receiver is dead") -} - -/// Tunnel configuration shared with VPNExtension (written in Swift). -pub(crate) struct TunnelConfiguration { - location_id: Option, - tunnel_id: Option, - name: String, - private_key: String, - addresses: Vec, - listen_port: Option, - peers: Vec, - mtu: Option, - dns: Vec, - dns_search: Vec, -} - -impl TunnelConfiguration { - /// Convert to [`NSDictionary`]. - fn as_nsdict(&self) -> Retained> { - let dict = NSMutableDictionary::new(); - - if let Some(location_id) = self.location_id { - dict.insert( - ns_string!(LOCATION_ID), - NSNumber::new_i64(location_id).as_ref(), - ); - } - - if let Some(tunnel_id) = self.tunnel_id { - dict.insert(ns_string!(TUNNEL_ID), NSNumber::new_i64(tunnel_id).as_ref()); - } - - dict.insert(ns_string!("name"), NSString::from_str(&self.name).as_ref()); - - dict.insert( - ns_string!("privateKey"), - NSString::from_str(&self.private_key).as_ref(), - ); - - // IpAddrMask - let addresses = NSMutableArray::>::new(); - for addr in &self.addresses { - let addr_dict = NSMutableDictionary::::new(); - addr_dict.insert( - ns_string!("address"), - NSString::from_str(&addr.address.to_string()).as_ref(), - ); - addr_dict.insert(ns_string!("cidr"), NSNumber::new_u8(addr.cidr).as_ref()); - addresses.addObject(addr_dict.into_super().as_ref()); - } - dict.insert(ns_string!("addresses"), addresses.as_ref()); - - if let Some(listen_port) = self.listen_port { - dict.insert( - ns_string!("listenPort"), - NSNumber::new_u16(listen_port).as_ref(), - ); - } - - // Peer - let peers = NSMutableArray::>::new(); - for peer in &self.peers { - let peer_dict = NSMutableDictionary::::new(); - peer_dict.insert( - ns_string!("publicKey"), - NSString::from_str(&peer.public_key.to_string()).as_ref(), - ); - - if let Some(preshared_key) = &peer.preshared_key { - peer_dict.insert( - ns_string!("preSharedKey"), - NSString::from_str(&preshared_key.to_string()).as_ref(), - ); - } - - if let Some(endpoint) = &peer.endpoint { - peer_dict.insert( - ns_string!("endpoint"), - NSString::from_str(&endpoint.to_string()).as_ref(), - ); - } - - // Skipping: lastHandshake, txBytes, rxBytes. - - if let Some(persistent_keep_alive) = peer.persistent_keepalive_interval { - peer_dict.insert( - ns_string!("persistentKeepAlive"), - NSNumber::new_u16(persistent_keep_alive).as_ref(), - ); - } - - // IpAddrMask - let allowed_ips = NSMutableArray::>::new(); - for addr in &peer.allowed_ips { - let addr_dict = NSMutableDictionary::::new(); - addr_dict.insert( - ns_string!("address"), - NSString::from_str(&addr.address.to_string()).as_ref(), - ); - addr_dict.insert(ns_string!("cidr"), NSNumber::new_u8(addr.cidr).as_ref()); - allowed_ips.addObject(addr_dict.into_super().as_ref()); - } - peer_dict.insert(ns_string!("allowedIPs"), allowed_ips.as_ref()); - - peers.addObject(peer_dict.into_super().as_ref()); - } - dict.insert(ns_string!("peers"), peers.into_super().as_ref()); - - if let Some(mtu) = self.mtu { - dict.insert(ns_string!("mtu"), NSNumber::new_u32(mtu).as_ref()); - } - - let dns = NSMutableArray::::new(); - for entry in &self.dns { - dns.addObject(NSString::from_str(&entry.to_string()).as_ref()); - } - dict.insert(ns_string!("dns"), dns.as_ref()); - - let dns_search = NSMutableArray::::new(); - for entry in &self.dns_search { - dns_search.addObject(NSString::from_str(entry).as_ref()); - } - dict.insert(ns_string!("dnsSearch"), dns_search.as_ref()); - - dict.into_super() - } - - /// Try to find `NETunnelProviderManager` for this configuration, based on location ID or - /// tunnel ID. - pub(crate) fn tunnel_provider_manager(&self) -> Option> { - let (key, value) = match (self.location_id, self.tunnel_id) { - (Some(location_id), None) => (LOCATION_ID, location_id), - (None, Some(tunnel_id)) => (TUNNEL_ID, tunnel_id), - _ => return None, - }; - - manager_for_key_and_value(key, value) - } - - /// Create or update system VPN settings with this configuration. - pub(crate) fn save(&self) { - let spinlock = Arc::new(AtomicBool::new(false)); - let spinlock_clone = Arc::clone(&spinlock); - let plugin_bundle_id = ns_string!(PLUGIN_BUNDLE_ID); - - let provider_manager = self - .tunnel_provider_manager() - .unwrap_or_else(|| unsafe { NETunnelProviderManager::new() }); - - unsafe { - let tunnel_protocol = NETunnelProviderProtocol::new(); - tunnel_protocol.setProviderBundleIdentifier(Some(plugin_bundle_id)); - let server_address = self.peers.first().map_or(String::new(), |peer| { - peer.endpoint.map_or(String::new(), |sa| sa.to_string()) - }); - let server_address = NSString::from_str(&server_address); - // `serverAddress` must have a non-nil string value for the protocol configuration to be - // valid. - tunnel_protocol.setServerAddress(Some(&server_address)); - - let provider_config = self.as_nsdict(); - tunnel_protocol.setProviderConfiguration(Some(&*provider_config)); - - provider_manager.setProtocolConfiguration(Some(&tunnel_protocol)); - let name = NSString::from_str(&self.name); - provider_manager.setLocalizedDescription(Some(&name)); - provider_manager.setEnabled(true); - - // Save to system settings. - let handler = RcBlock::new(move |error_ptr: *mut NSError| { - if error_ptr.is_null() { - debug!("Saved tunnel configuration for {name} to system settings"); - } else { - error!("Failed to save tunnel configuration for: {name} to system settings"); - } - spinlock_clone.store(true, Ordering::Release); - }); - provider_manager.saveToPreferencesWithCompletionHandler(Some(&*handler)); - } - - while !spinlock.load(Ordering::Acquire) { - spin_loop(); - } - } - - /// Start tunnel for this configuration. - pub(crate) fn start_tunnel(&self) { - if let Some(provider_manager) = self.tunnel_provider_manager() { - if let Err(err) = - unsafe { provider_manager.connection().startVPNTunnelAndReturnError() } - { - error!("Failed to start VPN: {err}"); - } else { - OBSERVER_COMMS - .0 - .lock() - .expect("Failed to lock observer sender") - .send(( - self.location_id - .map_or_else(|| TUNNEL_ID, |_location_id| LOCATION_ID), - self.location_id.or(self.tunnel_id).unwrap(), - )) - .expect("Failed to send to observer channel"); - info!("VPN started"); - } - } else { - debug!( - "Couldn't find configuration from system settings for {}", - self.name - ); - } - } -} - -/// Retrieve VPN tunnel statistics from VPNExtension. -pub(crate) fn tunnel_stats(id: Id, connection_type: &ConnectionType) -> Option { - let new_stats = Arc::new(Mutex::new(None)); - let plugin_bundle_id = ns_string!(PLUGIN_BUNDLE_ID); - - let new_stats_clone = Arc::clone(&new_stats); - - let finished = Arc::new(AtomicBool::new(false)); - let finished_clone = Arc::clone(&finished); - - let response_handler = RcBlock::new(move |data_ptr: *mut NSData| { - if let Some(data) = unsafe { data_ptr.as_ref() } { - if let Ok(stats) = serde_json::from_slice(data.to_vec().as_slice()) { - if let Ok(mut new_stats_locked) = new_stats_clone.lock() { - *new_stats_locked = Some(stats); - } - } else { - warn!("Failed to deserialize tunnel stats"); - } - } else { - debug!("No data received in tunnel stats response, skipping"); - } - finished_clone.store(true, Ordering::Release); - }); - - let manager = manager_for_key_and_value( - match connection_type { - ConnectionType::Location => LOCATION_ID, - ConnectionType::Tunnel => TUNNEL_ID, - }, - id, - )?; - - let vpn_protocol = (unsafe { manager.protocolConfiguration() })?; - let Ok(tunnel_protocol) = vpn_protocol.downcast::() else { - error!("Failed to downcast to NETunnelProviderProtocol"); - return None; - }; - - // Sometimes all managers from all apps come through, so filter by bundle ID. - if let Some(bundle_id) = unsafe { tunnel_protocol.providerBundleIdentifier() } { - if &*bundle_id != plugin_bundle_id { - return None; - } - } - - let Ok(session) = unsafe { manager.connection() }.downcast::() else { - error!("Failed to downcast to NETunnelProviderSession"); - return None; - }; - - let message_data = NSData::new(); - if unsafe { - session.sendProviderMessage_returnError_responseHandler( - &message_data, - None, - Some(&response_handler), - ) - } { - debug!("Message sent to NETunnelProviderSession"); - } else { - error!("Failed to send to NETunnelProviderSession while requesting stats"); - } - - // Wait for all handlers to complete. - while !finished.load(Ordering::Acquire) { - spin_loop(); - } - - let stats = new_stats - .lock() - .map_or(None, |mut new_stats_locked| new_stats_locked.take()); - - stats -} - -/// Synchronize locations and tunnels with system settings. -pub async fn sync_locations_and_tunnels(mtu: Option) -> Result<(), sqlx::Error> { - // Update location settings. - let all_locations = Location::all(&*DB_POOL, false).await?; - for location in &all_locations { - // For syncing, set `preshred_key` to `None`. - let Ok(tunnel_config) = location.tunnel_configurarion(None, mtu).await else { - error!( - "Failed to convert location {} to tunnel configuration.", - location.name - ); - continue; - }; - tunnel_config.save(); - } - - // Update tunnel settings. - let all_tunnels = Tunnel::all(&*DB_POOL).await?; - for tunnel in &all_tunnels { - let Ok(tunnel_config) = tunnel.tunnel_configurarion(mtu) else { - error!( - "Failed to convert tunnel {} to tunnel configuration.", - tunnel.name - ); - continue; - }; - tunnel_config.save(); - } - - debug!("Saved all configurations with system settings."); - - // Convert to Vec. - let mut all_location_ids = all_locations - .into_iter() - .map(|entry| entry.id) - .collect::>(); - let mut all_tunnel_ids = all_tunnels - .into_iter() - .map(|entry| entry.id) - .collect::>(); - // For faster lookup using binary search (see below). - all_location_ids.sort_unstable(); - all_tunnel_ids.sort_unstable(); - - let spinlock = Arc::new(AtomicBool::new(false)); - let spinlock_clone = Arc::clone(&spinlock); - let handler = RcBlock::new( - move |managers_ptr: *mut NSArray, error_ptr: *mut NSError| { - if !error_ptr.is_null() { - error!("Failed to load tunnel provider managers."); - return; - } - - let Some(managers) = (unsafe { managers_ptr.as_ref() }) else { - error!("No managers"); - return; - }; - - let location_key = NSString::from_str(LOCATION_ID); - let tunnel_key = NSString::from_str(TUNNEL_ID); - for manager in managers { - if let Some(id) = id_from_manager(&manager, &location_key) { - if all_location_ids.binary_search(&id).is_ok() { - // Known location - skip. - continue; - } - } - if let Some(id) = id_from_manager(&manager, &tunnel_key) { - if all_tunnel_ids.binary_search(&id).is_ok() { - // Known tunnel - skip. - continue; - } - } - unsafe { manager.removeFromPreferencesWithCompletionHandler(None) }; - } - - spinlock_clone.store(true, Ordering::Release); - }, - ); - unsafe { - NETunnelProviderManager::loadAllFromPreferencesWithCompletionHandler(&handler); - } - - while !spinlock.load(Ordering::Acquire) { - spin_loop(); - } - - debug!("Removed unknown configurations from system settings."); - - Ok(()) -} - -impl Location { - /// Build [`TunnelConfiguration`] from [`Location`]. - pub(crate) async fn tunnel_configurarion( - &self, - preshared_key: Option, - mtu: Option, - ) -> Result { - debug!("Looking for WireGuard keys for location {self} instance"); - let Some(keys) = WireguardKeys::find_by_instance_id(&*DB_POOL, self.instance_id).await? - else { - error!("No keys found for instance: {}", self.instance_id); - return Err(Error::InternalError( - "No keys found for instance".to_string(), - )); - }; - debug!("WireGuard keys found for location {self} instance"); - - // prepare peer config - debug!("Decoding location {self} public key: {}.", self.pubkey); - let peer_key = Key::from_str(&self.pubkey)?; - debug!("Location {self} public key decoded: {peer_key}"); - let mut peer = Peer::new(peer_key); - - debug!("Parsing location {self} endpoint: {}", self.endpoint); - peer.set_endpoint(&self.endpoint)?; - peer.persistent_keepalive_interval = Some(25); - debug!("Parsed location {self} endpoint: {}", self.endpoint); - - if let Some(psk) = preshared_key { - debug!("Decoding location {self} preshared key."); - let peer_psk = Key::from_str(&psk)?; - info!("Location {self} preshared key decoded."); - peer.preshared_key = Some(peer_psk); - } - - debug!("Parsing location {self} allowed IPs: {}", self.allowed_ips); - let Some(instance) = Instance::find_by_id(&*DB_POOL, self.instance_id).await? else { - error!("Instance {} not found", self.instance_id); - return Err(Error::InternalError(format!( - "Instance {} not found", - self.instance_id - ))); - }; - let route_all_traffic = match instance.client_traffic_policy { - ClientTrafficPolicy::ForceAllTraffic => true, - ClientTrafficPolicy::DisableAllTraffic => false, - ClientTrafficPolicy::None => self.route_all_traffic, - }; - let allowed_ips = if route_all_traffic { - debug!("Using all traffic routing for location {self}"); - vec![DEFAULT_ROUTE_IPV4.into(), DEFAULT_ROUTE_IPV6.into()] - } else { - debug!( - "Using predefined location {self} traffic: {}", - self.allowed_ips - ); - self.allowed_ips.split(',').map(str::to_string).collect() - }; - for allowed_ip in &allowed_ips { - match IpAddrMask::from_str(allowed_ip) { - Ok(addr) => { - peer.allowed_ips.push(addr); - } - Err(err) => { - // Handle the error from IpAddrMask::from_str, if needed - error!( - "Error parsing IP address {allowed_ip} while setting up interface for \ - location {self}, error details: {err}" - ); - } - } - } - debug!( - "Parsed allowed IPs for location {self}: {:?}", - peer.allowed_ips - ); - - let addresses = self - .address - .split(',') - .map(str::trim) - .map(IpAddrMask::from_str) - .collect::>() - .map_err(|err| { - let msg = format!("Failed to parse IP addresses '{}': {err}", self.address); - error!("{msg}"); - Error::InternalError(msg) - })?; - let (dns, dns_search) = dns_owned(&self.dns); - Ok(TunnelConfiguration { - location_id: Some(self.id), - tunnel_id: None, - name: self.name.clone(), - private_key: keys.prvkey, - addresses, - listen_port: Some(0), - peers: vec![peer], - mtu, - dns, - dns_search, - }) - } - - /// Check whether VPN tunnel is running for [`Location`]. - pub(crate) fn status(&self) -> Option { - manager_for_key_and_value(LOCATION_ID, self.id).map_or_else( - || { - debug!( - "Couldn't find configuration in system settings for location {}", - self.name - ); - None - }, - |provider_manager| unsafe { - let connection = provider_manager.connection(); - Some(connection.status()) - }, - ) - } - - /// Remove configuration from system settings for [`Location`]. - pub(crate) fn remove_config(&self) { - if let Some(provider_manager) = manager_for_key_and_value(LOCATION_ID, self.id) { - unsafe { - provider_manager.removeFromPreferencesWithCompletionHandler(None); - } - } else { - debug!( - "Couldn't find configuration in system settings for location {}", - self.name - ); - } - } - - /// Stop VPN tunnel for [`Location`]. - pub(crate) fn stop_vpn_tunnel(&self) -> bool { - manager_for_key_and_value(LOCATION_ID, self.id).map_or_else( - || { - debug!( - "Couldn't find configuration in system settings for location {}", - self.name - ); - false - }, - |provider_manager| { - unsafe { - provider_manager.connection().stopVPNTunnel(); - } - info!("VPN stopped"); - true - }, - ) - } -} - -impl Tunnel { - /// Build [`TunnelConfiguration`] from [`Tunnel`]. - pub(crate) fn tunnel_configurarion( - &self, - mtu: Option, - ) -> Result { - // prepare peer config - debug!("Decoding tunnel {self} public key: {}.", self.server_pubkey); - let peer_key = Key::from_str(&self.server_pubkey)?; - debug!("Tunnel {self} public key decoded."); - let mut peer = Peer::new(peer_key); - - debug!("Parsing tunnel {self} endpoint: {}", self.endpoint); - peer.set_endpoint(&self.endpoint)?; - peer.persistent_keepalive_interval = Some( - self.persistent_keep_alive - .try_into() - .expect("Failed to parse persistent keep alive"), - ); - debug!("Parsed tunnel {self} endpoint: {}", self.endpoint); - - if let Some(psk) = &self.preshared_key { - debug!("Decoding tunnel {self} preshared key."); - let peer_psk = Key::from_str(psk)?; - debug!("Preshared key for tunnel {self} decoded."); - peer.preshared_key = Some(peer_psk); - } - - debug!("Parsing tunnel {self} allowed ips: {:?}", self.allowed_ips); - let allowed_ips = if self.route_all_traffic { - debug!("Using all traffic routing for tunnel {self}"); - vec![DEFAULT_ROUTE_IPV4.into(), DEFAULT_ROUTE_IPV6.into()] - } else { - let msg = self.allowed_ips.as_ref().map_or_else( - || "No allowed IP addresses found in tunnel {self} configuration".to_string(), - |ips| format!("Using predefined location traffic for tunnel {self}: {ips}"), - ); - debug!("{msg}"); - self.allowed_ips - .as_ref() - .map(|ips| ips.split(',').map(str::to_string).collect()) - .unwrap_or_default() - }; - for allowed_ip in &allowed_ips { - match IpAddrMask::from_str(allowed_ip.trim()) { - Ok(addr) => { - peer.allowed_ips.push(addr); - } - Err(err) => { - // Handle the error from IpAddrMask::from_str, if needed - error!("Error parsing IP address {allowed_ip}: {err}"); - // Continue to the next iteration of the loop - } - } - } - debug!("Parsed tunnel {self} allowed IPs: {:?}", peer.allowed_ips); - - let addresses = self - .address - .split(',') - .map(str::trim) - .map(IpAddrMask::from_str) - .collect::>() - .map_err(|err| { - let msg = format!("Failed to parse IP addresses '{}': {err}", self.address); - error!("{msg}"); - Error::InternalError(msg) - })?; - let (dns, dns_search) = dns_owned(&self.dns); - Ok(TunnelConfiguration { - location_id: None, - tunnel_id: Some(self.id), - name: self.name.clone(), - private_key: self.prvkey.clone(), - addresses, - listen_port: Some(0), - peers: vec![peer], - mtu, - dns, - dns_search, - }) - } - - /// Check whether VPN tunnel is running for [`Tunnel`]. - pub(crate) fn status(&self) -> Option { - manager_for_key_and_value(TUNNEL_ID, self.id).map_or_else( - || { - debug!( - "Couldn't find configuration in system settings for tunnel {}", - self.name - ); - None - }, - |provider_manager| unsafe { - let connection = provider_manager.connection(); - Some(connection.status()) - }, - ) - } - - /// Remove configuration from system settings for [`Tunnel`]. - pub(crate) fn remove_config(&self) { - if let Some(provider_manager) = manager_for_key_and_value(TUNNEL_ID, self.id) { - unsafe { - provider_manager.removeFromPreferencesWithCompletionHandler(None); - } - } else { - debug!( - "Couldn't find configuration in system settings for tunnel {}", - self.name - ); - } - } - - /// Stop tunnel for [`Tunnel`]. - pub(crate) fn stop_vpn_tunnel(&self) -> bool { - manager_for_key_and_value(TUNNEL_ID, self.id).map_or_else( - || { - debug!( - "Couldn't find configuration in system settings for location {}", - self.name - ); - false - }, - |provider_manager| { - unsafe { - provider_manager.connection().stopVPNTunnel(); - } - info!("VPN stopped"); - true - }, - ) - } -} diff --git a/src-tauri/src/appstate.rs b/src-tauri/src/appstate.rs index 179b9a6b7..4e0416cc8 100644 --- a/src-tauri/src/appstate.rs +++ b/src-tauri/src/appstate.rs @@ -1,32 +1,47 @@ use std::{collections::HashMap, sync::Mutex}; -use tauri::async_runtime::{spawn, JoinHandle}; +use defguard_client_core::{ + connection::active_connections::ACTIVE_CONNECTIONS, enrollment::EnrollmentSession, +}; +use defguard_client_provisioning::ProvisioningConfig; +use tauri::{ + async_runtime::{spawn, JoinHandle}, + PhysicalPosition, +}; use tokio_util::sync::CancellationToken; +use uuid::Uuid; use crate::{ - active_connections::ACTIVE_CONNECTIONS, app_config::AppConfig, database::models::{connection::ActiveConnection, Id}, - enterprise::provisioning::ProvisioningConfig, + session_state::SessionState, utils::stats_handler, ConnectionType, }; pub struct AppState { + pub enrollment_sessions: Mutex>, pub log_watchers: Mutex>, + pub mfa_tasks: Mutex>, pub app_config: Mutex, + pub tray_click_position: Mutex>>, stat_threads: Mutex>>, // location ID is the key pub provisioning_config: Mutex>, + pub session_state: Mutex, } impl AppState { #[must_use] pub fn new(config: AppConfig, provisioning_config: Option) -> Self { Self { + enrollment_sessions: Mutex::new(HashMap::new()), log_watchers: Mutex::new(HashMap::new()), + mfa_tasks: Mutex::new(HashMap::new()), app_config: Mutex::new(config), + tray_click_position: Mutex::new(None), stat_threads: Mutex::new(HashMap::new()), provisioning_config: Mutex::new(provisioning_config), + session_state: Mutex::new(SessionState::default()), } } diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs index a01e6f994..6a9e00af5 100644 --- a/src-tauri/src/bin/defguard-client.rs +++ b/src-tauri/src/bin/defguard-client.rs @@ -1,435 +1,49 @@ -//! defguard desktop client +//! Defguard desktop client // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::env; #[cfg(target_os = "macos")] -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; -use std::{env, str::FromStr, sync::LazyLock}; - -#[cfg(unix)] -use defguard_client::set_perms; -#[cfg(windows)] -use defguard_client::utils::sync_connections; -use defguard_client::{ - active_connections::close_all_connections, - app_config::AppConfig, - appstate::AppState, - commands::*, - database::{ - handle_db_migrations, - models::{location_stats::LocationStats, tunnel::TunnelStats}, - DB_POOL, +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, }, - enterprise::provisioning::handle_client_initialization, - periodic::run_periodic_tasks, - service, - tray::{configure_tray_icon, setup_tray, show_main_window}, - utils::load_log_targets, - LOG_FILENAME, VERSION, + thread::spawn, }; -use log::{Level, LevelFilter}; -use tauri::{AppHandle, Builder, Manager, RunEvent, WindowEvent}; -use tauri_plugin_log::{Target, TargetKind}; - -#[macro_use] -extern crate log; - -// For tauri logging plugin: -// if found in metadata target name it will ignore the log if it was below info level. -const LOGGING_TARGET_IGNORE_LIST: [&str; 5] = ["tauri", "sqlx", "hyper", "h2", "tower"]; - -static LOG_INCLUDES: LazyLock> = LazyLock::new(load_log_targets); - -async fn startup(app_handle: &AppHandle) { - debug!("Purging old stats from the database."); - if let Err(err) = LocationStats::purge(&*DB_POOL).await { - error!("Failed to purge location stats: {err}"); - } else { - debug!("Old location stats have been purged successfully."); - } - if let Err(err) = TunnelStats::purge(&*DB_POOL).await { - error!("Failed to purge tunnel stats: {err}"); - } else { - debug!("Old tunnel stats have been purged successfully."); - } - - // Sync already active connections on windows. - // When windows is restarted, the app doesn't close the active connections - // and they are still running after the restart. We sync them here to - // reflect the real system's state. - // TODO: Find a way to intercept the shutdown event and close all connections - #[cfg(windows)] - { - match sync_connections(app_handle).await { - Ok(()) => { - info!( - "Synchronized application's active connections with the connections \ - already open on the system, if there were any." - ); - } - Err(err) => { - warn!( - "Failed to synchronize application's active connections with the connections \ - already open on the system. \ - The connections' state in the application may not reflect system's state. \ - Reconnect manually to reset them. Error: {err}" - ); - } - }; - } - #[cfg(target_os = "macos")] - { - use defguard_client::{ - apple::get_managers_for_tunnels_and_locations, utils::get_all_tunnels_locations, - }; - let semaphore = Arc::new(AtomicBool::new(false)); - let semaphore_clone = Arc::clone(&semaphore); - - // Retrieve MTU from `AppConfig`. - let app_state = app_handle.state::(); - let mtu = app_state - .app_config - .lock() - .expect("failed to lock app state") - .mtu(); - let handle = tauri::async_runtime::spawn(async move { - if let Err(err) = defguard_client::apple::sync_locations_and_tunnels(mtu).await { - error!("Failed to sync locations and tunnels: {err}"); - } - semaphore_clone.store(true, Ordering::Release); - }); - defguard_client::apple::spawn_runloop_and_wait_for(&semaphore); - let _ = handle.await; - - let (tunnels, locations) = get_all_tunnels_locations().await; - let handle = app_handle.clone(); - // Observer thread is blocking, so its better not to mess with the tauri runtime, - // hence std::thread::spawn. - std::thread::spawn(move || { - defguard_client::apple::observer_thread(get_managers_for_tunnels_and_locations( - &tunnels, &locations, - )); - error!("VPN observer thread has exited unexpectedly, quitting the app."); - handle.exit(0); - }); - - let handle = app_handle.clone(); - tauri::async_runtime::spawn(async move { - defguard_client::apple::connection_state_update_thread(&handle).await; - error!("Connection state update thread has exited unexpectedly, quitting the app."); - handle.exit(0); - }); - } - - // Run periodic tasks. - let periodic_tasks_handle = app_handle.clone(); - tauri::async_runtime::spawn(async move { - run_periodic_tasks(&periodic_tasks_handle).await; - // One of the tasks exited, so something went wrong, quit the app - error!("One of the periodic tasks has stopped unexpectedly. Exiting the application."); - periodic_tasks_handle.exit(0); - }); - debug!("Periodic tasks have been started."); - - // Load tray menu after database initialization, so all instance and locations can be shown. - debug!( - "Re-generating tray menu to show all available instances and locations as we have \ - connected to the database." - ); - if let Err(err) = setup_tray(app_handle).await { - error!("Failed to setup system tray: {err}"); - } - match configure_tray_icon(app_handle).await { - Ok(()) => info!("System tray configured."), - Err(err) => error!("Failed to configure system tray: {err}"), - } - debug!("Tray menu has been re-generated successfully."); -} +#[cfg(target_os = "macos")] +use defguard_client::connection::apple::spawn_runloop_and_wait_for; +#[cfg(target_os = "linux")] +use defguard_client::utils::set_webkitgtk_variables; +use defguard_client::{check_version_flag, gui::run_app}; +use tauri::async_runtime::block_on; fn main() { - let app = Builder::default() - .invoke_handler(tauri::generate_handler![ - all_locations, - save_device_config, - all_instances, - connect, - disconnect, - update_instance, - location_stats, - location_interface_details, - all_connections, - last_connection, - active_connection, - update_location_routing, - delete_instance, - parse_tunnel_config, - save_tunnel, - all_tunnels, - open_link, - tunnel_details, - update_tunnel, - delete_tunnel, - get_latest_app_version, - start_global_logwatcher, - stop_global_logwatcher, - command_get_app_config, - command_set_app_config, - get_provisioning_config, - get_platform_header - ]) - .on_window_event(|window, event| { - if let WindowEvent::CloseRequested { api, .. } = event { - #[cfg(not(target_os = "macos"))] - let _ = window.hide(); - - #[cfg(target_os = "macos")] - let _ = tauri::AppHandle::hide(window.app_handle()); - - api.prevent_close(); - } - }) - // Initialize plugins here, except for `tauri_plugin_log` which is handled in `setup()`. - // Single instance plugin should always be the first to register. - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - // Running instance might be hidden, so show it. - show_main_window(app); - })) - .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_notification::init()) - .plugin(tauri_plugin_window_state::Builder::new().build()) - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_os::init()) - .plugin(tauri_plugin_process::init()) - .setup(|app| { - // Create Help menu on macOS. - // https://github.com/tauri-apps/tauri/issues/9371 - #[cfg(target_os = "macos")] - { - use tauri_plugin_opener::OpenerExt; - - const DOC_ITEM_ID: &str = "doc"; - const REPORT_ITEM_ID: &str = "issue"; - const DOC_URL: &str = "https://docs.defguard.net/using-defguard-for-end-users/desktop-client"; - const REPORT_URL: &str = "https://github.com/DefGuard/client/issues/new?labels=bug&template=bug_report.md"; - if let Some(menu) = app.menu() { - if let Some(help_submenu) = menu.get(tauri::menu::HELP_SUBMENU_ID) { - let report_item = tauri::menu::MenuItem::with_id( - app, - REPORT_ITEM_ID, - "Report an issue", - true, - None::<&str>, - )?; - let _ = help_submenu.as_submenu_unchecked().append(&report_item); - let doc_item = tauri::menu::MenuItem::with_id( - app, - DOC_ITEM_ID, - "Defguard Desktop Client Help", - true, - None::<&str>, - )?; - let _ = help_submenu.as_submenu_unchecked().append(&doc_item); - } - } - app.on_menu_event(move |app, event| { - let id = event.id(); - if id == DOC_ITEM_ID { - let _ = app.opener().open_url(DOC_URL, None::<&str>); - } else if id == REPORT_ITEM_ID { - let _ = app.opener().open_url(REPORT_URL, None::<&str>); - } - }); - } - - // Register for Linux and debug Windows builds. - #[cfg(any(target_os = "linux", windows))] - { - use tauri_plugin_deep_link::DeepLinkExt; - app.deep_link().register_all()?; - } - - let app_handle = app.app_handle(); - - // Prepare `AppConfig`. - let config = AppConfig::new(app_handle); - - // Setup logging. - - // If deriving from env value fails, use config default (env overrides config file). - let config_log_level = config.log_level; - let log_level = match &env::var("DEFGUARD_CLIENT_LOG_LEVEL") { - Ok(env_value) => LevelFilter::from_str(env_value).unwrap_or(config_log_level), - Err(_) => config_log_level, - }; - app_handle.plugin( - tauri_plugin_log::Builder::new() - .format(move |out, message, record| { - out.finish(format_args!( - "{}[{}][{}] {}", - tauri_plugin_log::TimezoneStrategy::UseUtc - .get_now() - // Sets the time format. Service's logs have a subsecond part, so we - // also need to include it here, otherwise the logs couldn't be sorted - // correctly when displayed together in the UI. - .format(&time::macros::format_description!( - "[[[year]-[month]-[day]][[[hour]:[minute]:[second].[subsecond]]" - )) - .unwrap(), - record.level(), - record.target(), - message - )); - }) - .targets([ - Target::new(TargetKind::Stdout), - Target::new(TargetKind::LogDir { file_name: Some(LOG_FILENAME.to_string()) }), - ]) - .level(log_level) - .filter(|metadata| { - if metadata.level() == Level::Error { - return true; - } - if !LOG_INCLUDES.is_empty() { - for target in &*LOG_INCLUDES { - if metadata.target().contains(target) { - return true; - } - } - return false; - } - true - }) - .filter(|metadata| { - // Log all errors, warnings and infos. - let level = metadata.level(); - if level == LevelFilter::Error - || level == LevelFilter::Warn - || level == LevelFilter::Info - { - return true; - } - // Otherwise do not log these targets. - for target in &LOGGING_TARGET_IGNORE_LIST { - if metadata.target().contains(target) { - return false; - } - } - true - }) - .build(), - )?; - - // run DB migrations - tauri::async_runtime::block_on(handle_db_migrations()); - - // Check if client needs to be initialized - // and try to load provisioning config if necessary - let provisioning_config = - tauri::async_runtime::block_on(handle_client_initialization(app_handle)); - - let state = AppState::new(config, provisioning_config); - app.manage(state); - - info!("App setup completed, log level: {log_level}"); - Ok(()) - }) - .build(tauri::generate_context!()) - .expect("Failed to build Tauri application"); - - info!("Starting Defguard client version {VERSION}"); - - // Run application. - debug!("Starting the main application event loop."); - app.run(|app_handle, event| match event { - // Startup tasks - RunEvent::Ready => { - let data_dir = app_handle - .path() - .app_data_dir() - .unwrap_or_else(|_| "UNDEFINED DATA DIRECTORY".into()); - let log_dir = app_handle - .path() - .app_log_dir() - .unwrap_or_else(|_| "UNDEFINED LOG DIRECTORY".into()); - - // Ensure directories have appropriate permissions (dg25-28). - #[cfg(unix)] - { - set_perms(&data_dir); - set_perms(&log_dir); - } - - info!( - "Application data (database file) will be stored in: {} and application logs in: \ - {}. Logs of the background Defguard service responsible for managing VPN \ - connections at the network level will be stored in: {}.", - data_dir.display(), - log_dir.display(), - service::config::DEFAULT_LOG_DIR - ); - tauri::async_runtime::block_on(startup(app_handle)); - - // Handle Ctrl-C. - debug!("Setting up Ctrl-C handler."); - let app_handle_clone = app_handle.clone(); - tauri::async_runtime::spawn(async move { - tokio::signal::ctrl_c() - .await - .expect("Signal handler failure"); - debug!("Ctrl-C handler: quitting the app"); - app_handle_clone.exit(0); + // Handle --version / -V before starting the client. + check_version_flag("defguard-client"); + + // Without any arguments, launch the user interface. + if env::args().count() <= 1 { + #[cfg(target_os = "linux")] + set_webkitgtk_variables(); + run_app(); + } else { + #[cfg(target_os = "macos")] + { + // NetworkExtension completion handlers are delivered on the main queue, which is + // only serviced while a run loop is running on the main thread. + let done = Arc::new(AtomicBool::new(false)); + let done_clone = Arc::clone(&done); + let worker = spawn(move || { + let _code = block_on(defguard_cli::cli_main()); + done_clone.store(true, Ordering::Release); }); - debug!("Ctrl-C handler has been set up successfully"); + spawn_runloop_and_wait_for(&done); + let _ = worker.join(); } - RunEvent::ExitRequested { code, api, .. } => { - debug!("Received exit request"); - // `code` is `None` when the exit is requested by user interaction. - if code.is_none() { - // Prevent shutdown on window close. - api.prevent_exit(); - } - } - // Handle shutdown. - RunEvent::Exit => { - debug!("Exiting the application's main event loop."); - #[cfg(target_os = "macos")] - { - let semaphore = Arc::new(AtomicBool::new(false)); - let semaphore_clone = Arc::clone(&semaphore); - - let handle = tauri::async_runtime::spawn(async move { - let _ = close_all_connections().await; - // This will clean the database file, pruning write-ahead log. - DB_POOL.close().await; - semaphore_clone.store(true, Ordering::Release); - }); - // Obj-C API needs a runtime, but at this point Tauri has closed its runtime, so - // create a temporary one. - defguard_client::apple::spawn_runloop_and_wait_for(&semaphore); - tauri::async_runtime::block_on(async move { - let _ = handle.await; - }); - } - #[cfg(not(target_os = "macos"))] - { - tauri::async_runtime::block_on(async move { - let _ = close_all_connections().await; - // This will clean the database file, pruning write-ahead log. - DB_POOL.close().await; - }); - } - } - _ => { - trace!("Received event: {event:?}"); - } - }); + #[cfg(not(target_os = "macos"))] + block_on(defguard_cli::cli_main()); + } } diff --git a/src-tauri/src/bin/defguard-service.rs b/src-tauri/src/bin/defguard-service.rs deleted file mode 100644 index 7b4ec0cbb..000000000 --- a/src-tauri/src/bin/defguard-service.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Defguard interface management daemon -//! -//! This binary is meant to run as a daemon with root privileges -//! and communicate with the desktop client over HTTP. - -#[cfg(not(windows))] -#[tokio::main] -async fn main() -> anyhow::Result<()> { - use clap::Parser; - use defguard_client::service::{config::Config, daemon::run_server, utils::logging_setup}; - - // parse config - let config: Config = Config::parse(); - let _guard = logging_setup(&config.log_dir, &config.log_level); - - // run gRPC server - run_server(config).await?; - - Ok(()) -} - -#[cfg(windows)] -fn main() -> windows_service::Result<()> { - defguard_client::service::windows::run() -} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1fc1c55fd..8004bdd97 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,27 +1,48 @@ use core::fmt; -use std::{ - collections::{HashMap, HashSet}, - env, - str::FromStr, -}; +use std::{collections::HashMap, env, str::FromStr}; -use chrono::{DateTime, Duration, NaiveDateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; +#[cfg(not(target_os = "macos"))] +use defguard_client_core::connection::daemon_client::DAEMON_CLIENT; +use defguard_client_core::{ + connection::{ + active_connections::{find_connection, get_connection_id_by_type, ACTIVE_CONNECTIONS}, + disconnect_interface, ConnectionTarget, + }, + enrollment::{self}, + mfa, +}; +use defguard_client_posture::authorize_posture_session; +#[cfg(not(target_os = "macos"))] +use defguard_client_proto::defguard::client::v1::{ + DeleteServiceLocationsRequest, RemoveInterfaceRequest, +}; +use defguard_client_proto::defguard::{ + client_types::{ + AdminInfo, ClientMfaFinishRequest, ClientMfaFinishResponse, ClientMfaStartRequest, + CodeMfaSetupFinishResponse, CodeMfaSetupStartResponse, DeviceConfigResponse, + EnrollmentSettings, InitialUserInfo, InstanceInfo as ProtoInstanceInfo, MfaMethod, + }, + enterprise::posture::v2::DevicePostureData, +}; +use defguard_client_provisioning::ProvisioningConfig; +use reqwest::Url; use serde::{Deserialize, Serialize}; -use sqlx::{Sqlite, Transaction}; use struct_patch::Patch; use tauri::{AppHandle, Emitter, Manager, State}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; const UPDATE_URL: &str = "https://pkgs.defguard.net/api/update/check"; use crate::{ - active_connections::{find_connection, get_connection_id_by_type}, app_config::{AppConfig, AppConfigPatch}, appstate::AppState, database::{ models::{ connection::{ActiveConnection, Connection, ConnectionInfo}, - instance::{ClientTrafficPolicy, Instance, InstanceInfo}, - location::{Location, LocationMfaMode}, + instance::{Instance, InstanceInfo}, + location::{Location, LocationMfaMethod, LocationMfaMode}, location_stats::LocationStats, tunnel::{Tunnel, TunnelConnection, TunnelConnectionInfo, TunnelStats}, wireguard_keys::WireguardKeys, @@ -29,42 +50,104 @@ use crate::{ }, DB_POOL, }, - enterprise::{periodic::config::poll_instance, provisioning::ProvisioningConfig}, error::Error, - events::EventKey, + events::{EventKey, TunnelsDisabledPayload, TunnelsEnabledPayload}, + into_location, log_watcher::{ global_log_watcher::{spawn_global_log_watcher_task, stop_global_log_watcher_task}, service_log_watcher::stop_log_watcher_task, }, - proto::DeviceConfigResponse, + periodic::config::{ + do_update_instance, poll_instance_with_events, sync_service_locations_best_effort, + }, + proxy::construct_platform_header, + tauri_err_to_app_err, tray::{configure_tray_icon, reload_tray_menu}, utils::{ - construct_platform_header, disconnect_interface, get_location_interface_details, - get_tunnel_interface_details, get_tunnel_or_location_name, handle_connection_for_location, - handle_connection_for_tunnel, + get_location_interface_details, get_tunnel_interface_details, get_tunnel_or_location_name, + handle_connection_for_location, handle_connection_for_tunnel, }, wg_config::parse_wireguard_config, CommonConnection, CommonConnectionInfo, CommonLocationStats, ConnectionType, }; #[cfg(not(target_os = "macos"))] -use crate::{ - service::{ - client::DAEMON_CLIENT, - proto::{ - DeleteServiceLocationsRequest, RemoveInterfaceRequest, SaveServiceLocationsRequest, - }, - }, - utils::execute_command, -}; +use crate::{periodic::config::sync_service_locations, utils::execute_command}; + +#[derive(Debug, Serialize, thiserror::Error)] +#[serde(tag = "kind", content = "message", rename_all = "camelCase")] +pub enum ConnectError { + #[error("Posture check failed: {0}")] + PostureCheckFailed(String), + #[error("Service unavailable: {0}")] + ServiceUnavailable(String), + #[error("{0}")] + AllTrafficConflict(String), + #[error("{0}")] + Other(String), +} + +impl From for ConnectError { + fn from(error: Error) -> Self { + match error { + Error::PostureCheckFailed(message) => Self::PostureCheckFailed(message), + Error::ServiceUnavailable(message) => Self::ServiceUnavailable(message), + Error::AllTrafficConflict(message) => Self::AllTrafficConflict(message), + error => Self::Other(error.to_string()), + } + } +} + +impl From for ConnectError { + fn from(error: sqlx::Error) -> Self { + Error::from(error).into() + } +} + +/// Serialize a structured error (e.g. `MfaError`, `EnrollmentError`) to JSON so +/// the frontend can match on its tagged `type`, falling back to the Display +/// string if serialization somehow fails. +fn err_to_json(e: E) -> String { + serde_json::to_string(&e).unwrap_or_else(|_| e.to_string()) +} + +/// Look up a cloned enrollment session by its opaque string id. Used by the +/// enrollment commands that need read access to the in-memory session. +fn get_enrollment_session( + state: &AppState, + session_id: &str, +) -> Result { + let uid = Uuid::parse_str(session_id).map_err(|e| format!("Invalid session ID: {e}"))?; + state + .enrollment_sessions + .lock() + .expect("enrollment_sessions mutex poisoned") + .get(&uid) + .cloned() + .ok_or_else(|| "Enrollment session not found".to_string()) +} + +/// Bring up a location connection with an already-obtained preshared key and +/// refresh the tray. Shared by `connect` and the MFA finish flows so the +/// preshared key never has to cross back into the frontend. +async fn connect_location_with_psk( + location: Location, + preshared_key: Option, + handle: &AppHandle, +) -> Result<(), Error> { + handle_connection_for_location(location.clone(), preshared_key, handle).await?; + reload_tray_menu(handle).await; + info!("Connected to location {location}"); + configure_tray_icon(handle).await?; + Ok(()) +} /// Open new WireGuard connection. #[tauri::command(async)] pub async fn connect( location_id: Id, connection_type: ConnectionType, - preshared_key: Option, handle: AppHandle, -) -> Result<(), Error> { +) -> Result<(), ConnectError> { debug!("Received a command to connect to a {connection_type} with ID {location_id}"); if connection_type == ConnectionType::Location { if let Some(location) = Location::find_by_id(&*DB_POOL, location_id).await? { @@ -72,31 +155,50 @@ pub async fn connect( "Identified location with ID {location_id} as \"{}\", handling connection.", location.name ); - handle_connection_for_location(&location, preshared_key, &handle).await?; - reload_tray_menu(&handle).await; - info!("Connected to location {location}"); + + // Avoid connecting a service location - they should be managed by the background service. + if location.is_service_location() { + error!( + "Refusing to connect location {location} from the app: it is a service \ + location, managed by the background service" + ); + return Err(Error::InvalidInput(format!( + "Location \"{}\" is a service location and is managed by the defguard service", + location.name + )) + .into()); + } + // Connect-time MFA brings the tunnel up itself (keeping the preshared + // key backend-side), so the only preshared key resolved here is for + // posture-only locations. + let preshared_key = if location.posture_check_required { + authorize_posture_session(&location).await? + } else { + None + }; + connect_location_with_psk(location, preshared_key, &handle).await?; } else { error!( "Location with ID {location_id} not found in the database, aborting connection \ attempt" ); - return Err(Error::NotFound); + return Err(Error::NotFound.into()); } } else if let Some(tunnel) = Tunnel::find_by_id(&*DB_POOL, location_id).await? { + Instance::ensure_tunnels_enabled(&*DB_POOL).await?; debug!( "Identified tunnel with ID {location_id} as \"{}\", handling connection...", tunnel.name ); - handle_connection_for_tunnel(&tunnel, &handle).await?; + handle_connection_for_tunnel(tunnel.clone(), &handle).await?; info!("Successfully connected to tunnel {tunnel}"); + // Update tray icon to reflect connection state. + configure_tray_icon(&handle).await?; } else { error!("Tunnel {location_id} not found"); - return Err(Error::NotFound); + return Err(Error::NotFound.into()); } - // Update tray icon to reflect connection state. - configure_tray_icon(&handle).await?; - Ok(()) } @@ -139,7 +241,9 @@ pub async fn disconnect( "Emitting the event informing the frontend about the disconnection from \ {connection_type} {name}({location_id})" ); - handle.emit(EventKey::ConnectionChanged.into(), ())?; + handle + .emit(EventKey::ConnectionChanged.into(), ()) + .map_err(tauri_err_to_app_err)?; debug!("Event emitted successfully"); stop_log_watcher_task(&handle, &connection.interface_name)?; reload_tray_menu(&handle).await; @@ -185,6 +289,113 @@ pub async fn disconnect( } } +pub async fn disconnect_all_tunnels(handle: &AppHandle) -> Result<(), Error> { + let state = handle.state::(); + let tunnel_ids = get_connection_id_by_type(ConnectionType::Tunnel).await; + if tunnel_ids.is_empty() { + debug!("No active tunnels to disconnect, emitting TunnelsDisabled event anyway"); + TunnelsDisabledPayload::emit(handle, Vec::new()); + return Ok(()); + } + + let mut names = Vec::new(); + for tunnel_id in &tunnel_ids { + let name = get_tunnel_or_location_name(*tunnel_id, ConnectionType::Tunnel).await; + debug!("Tunnels are disabled, disconnecting tunnel {name}(ID: {tunnel_id})"); + if let Some(connection) = state + .remove_connection(*tunnel_id, ConnectionType::Tunnel) + .await + { + disconnect_interface(&connection).await?; + stop_log_watcher_task(handle, &connection.interface_name)?; + info!("Tunnel {name}(ID: {tunnel_id}) disconnected (disabled by server administrator)"); + names.push(name); + } + } + + TunnelsDisabledPayload::emit(handle, names); + handle + .emit(EventKey::ConnectionChanged.into(), ()) + .map_err(tauri_err_to_app_err)?; + reload_tray_menu(handle).await; + configure_tray_icon(handle).await?; + Ok(()) +} + +#[tauri::command(async)] +pub async fn disconnect_locations(location_ids: Vec, handle: AppHandle) -> Result<(), Error> { + debug!( + "Received a command to disconnect {} location(s): {location_ids:?}", + location_ids.len() + ); + let state = handle.state::(); + let mut any_disconnected = false; + + for location_id in location_ids { + match Location::find_by_id(&*DB_POOL, location_id).await? { + Some(location) if location.is_service_location() => { + debug!( + "Skipping service location {location}(ID: {location_id}) in \ + disconnect_locations" + ); + continue; + } + None => { + debug!("Location with ID {location_id} not found in the database, skipping."); + continue; + } + _ => {} + } + + let name = get_tunnel_or_location_name(location_id, ConnectionType::Location).await; + debug!("Disconnecting from location {name}(ID: {location_id})"); + + if let Some(connection) = state + .remove_connection(location_id, ConnectionType::Location) + .await + { + disconnect_interface(&connection).await?; + stop_log_watcher_task(&handle, &connection.interface_name)?; + if let Err(err) = maybe_update_instance_config(location_id, &handle).await { + match err { + Error::CoreNotEnterprise => { + debug!( + "Tried to fetch instance config from core after disconnecting from \ + {name}(ID: {location_id}), but the core is not enterprise." + ); + } + Error::NoToken => { + debug!( + "Tried to fetch instance config from core after disconnecting from \ + {name}(ID: {location_id}), but the instance has no polling token." + ); + } + _ => { + warn!( + "Error while trying to fetch instance config after disconnecting \ + from {name}(ID: {location_id}): {err}" + ); + } + } + } + info!("Disconnected from location {name}(ID: {location_id})"); + any_disconnected = true; + } else { + debug!("No active connection found for location {name}(ID: {location_id}), skipping."); + } + } + + if any_disconnected { + handle + .emit(EventKey::ConnectionChanged.into(), ()) + .map_err(tauri_err_to_app_err)?; + reload_tray_menu(&handle).await; + configure_tray_icon(&handle).await?; + } + + Ok(()) +} + /// Triggers poll on location's instance config. Config will be updated if there are no more active /// connections for this instance. async fn maybe_update_instance_config(location_id: Id, handle: &AppHandle) -> Result<(), Error> { @@ -201,27 +412,15 @@ async fn maybe_update_instance_config(location_id: Id, handle: &AppHandle) -> Re ); return Err(Error::NotFound); }; - poll_instance(&mut transaction, &mut instance, handle).await?; + poll_instance_with_events(&mut transaction, &mut instance, handle).await?; transaction.commit().await?; - handle.emit(EventKey::InstanceUpdate.into(), ())?; - Ok(()) -} -#[derive(Deserialize, Serialize)] -pub struct Device { - pub id: Id, - pub name: String, - pub pubkey: String, - pub user_id: Id, - pub created_at: i64, -} + sync_service_locations_best_effort(&DB_POOL, &instance).await; -#[derive(Deserialize, Serialize)] -pub struct InstanceResponse { - // uuid - pub id: String, - pub name: String, - pub url: String, + handle + .emit(EventKey::InstanceUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; + Ok(()) } #[derive(Serialize)] @@ -239,10 +438,10 @@ pub async fn save_device_config( debug!("Saving device configuration: {response:#?}."); let mut transaction = DB_POOL.begin().await?; - let instance_info = response - .instance - .expect("Missing instance info in device config response"); - let mut instance: Instance = instance_info.into(); + let instance_info = response.instance.ok_or_else(|| { + Error::ResourceNotFound("instance info in device config response".to_string()) + })?; + let mut instance = Instance::from(instance_info); if response.token.is_some() { debug!( "The newly saved device config has a polling token, automatic configuration polling \ @@ -250,7 +449,7 @@ pub async fn save_device_config( ); } else { warn!( - "Missing polling token for instance {}, core and/or proxy services may need an update, \ + "Missing polling token for instance {}, Core and/or Edge services may need an update, \ configuration polling won't work", instance.name, ); @@ -261,9 +460,9 @@ pub async fn save_device_config( let instance = instance.save(&mut *transaction).await?; debug!("Saved instance {}", instance.name); - let device = response - .device - .expect("Missing device info in device config response"); + let device = response.device.ok_or_else(|| { + Error::ResourceNotFound("device info in device config response".to_string()) + })?; let keys = WireguardKeys::new(instance.id, device.pubkey, private_key); debug!( "Saving wireguard key {} for instance {}({})", @@ -275,7 +474,7 @@ pub async fn save_device_config( keys.pubkey, instance.name, instance.id ); for dev_config in response.configs { - let new_location = dev_config.into_location(instance.id); + let new_location = into_location(dev_config, instance.id); debug!( "Saving location {} for instance {}({})", new_location.name, instance.name, instance.id @@ -290,9 +489,15 @@ pub async fn save_device_config( info!("New instance {instance} created."); trace!("Created following instance: {instance:#?}"); - let locations = push_service_locations(&instance, keys).await?; + if Instance::tunnels_disabled(&*DB_POOL).await? { + disconnect_all_tunnels(&handle).await?; + } + + let locations = push_service_locations(&instance).await?; - handle.emit(EventKey::InstanceUpdate.into(), ())?; + handle + .emit(EventKey::InstanceUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; let res = SaveDeviceConfigResponse { locations, instance, @@ -303,63 +508,22 @@ pub async fn save_device_config( } #[cfg(target_os = "macos")] -async fn push_service_locations( - _instance: &Instance, - _keys: WireguardKeys, -) -> Result>, Error> { +async fn push_service_locations(_instance: &Instance) -> Result>, Error> { // Nothing here... yet Ok(Vec::new()) } +/// Pushes the instance's service locations to the daemon and returns all of its locations. +/// +/// Delegates to [`sync_service_locations`] rather than building its own request, so the pushed +/// field set cannot drift from the config-sync path. #[cfg(not(target_os = "macos"))] -async fn push_service_locations( - instance: &Instance, - keys: WireguardKeys, -) -> Result>, Error> { +async fn push_service_locations(instance: &Instance) -> Result>, Error> { let locations = Location::find_by_instance_id(&*DB_POOL, instance.id, true).await?; trace!("Created following locations: {locations:#?}"); - let mut service_locations = Vec::new(); - - for saved_location in &locations { - if saved_location.is_service_location() { - debug!( - "Adding service location {}({}) for instance {}({}) to be saved to the daemon.", - saved_location.name, saved_location.id, instance.name, instance.id, - ); - service_locations.push(saved_location.to_service_location()?); - } - } - - if !service_locations.is_empty() { - let save_request = SaveServiceLocationsRequest { - service_locations: service_locations.clone(), - instance_id: instance.uuid.clone(), - private_key: keys.prvkey, - }; - debug!( - "Saving {} service locations to the daemon for instance {}({}).", - save_request.service_locations.len(), - instance.name, - instance.id, - ); - DAEMON_CLIENT - .clone() - .save_service_locations(save_request) - .await - .map_err(|err| { - error!( - "Error while saving service locations to the daemon for instance {}({}): {err}", - instance.name, instance.id, - ); - Error::InternalError(err.to_string()) - })?; - debug!( - "Saved service locations to the daemon for instance {}({}).", - instance.name, instance.id, - ); - } + sync_service_locations(&DB_POOL, instance).await?; Ok(locations) } @@ -377,7 +541,10 @@ pub async fn all_instances() -> Result>, Error> { let connection_ids = get_connection_id_by_type(ConnectionType::Location).await; for instance in instances { let locations = Location::find_by_instance_id(&*DB_POOL, instance.id, false).await?; - let location_ids: Vec = locations.iter().map(|location| location.id).collect(); + let location_ids = locations + .iter() + .map(|location| location.id) + .collect::>(); let connected = connection_ids .iter() .any(|item1| location_ids.iter().any(|item2| item1 == item2)); @@ -394,6 +561,7 @@ pub async fn all_instances() -> Result>, Error> { pubkey: keys.pubkey, client_traffic_policy: instance.client_traffic_policy, enterprise_enabled: instance.enterprise_enabled, + disable_tunnels: instance.disable_tunnels, openid_display_name: instance.openid_display_name, }); } @@ -405,7 +573,7 @@ pub async fn all_instances() -> Result>, Error> { Ok(instance_info) } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct LocationInfo { pub id: Id, pub instance_id: Id, @@ -418,6 +586,8 @@ pub struct LocationInfo { pub pubkey: String, pub network_id: Id, pub location_mfa_mode: LocationMfaMode, + pub posture_check_required: bool, + pub mfa_method: Option, } impl LocationInfo { @@ -470,6 +640,8 @@ pub async fn all_locations(instance_id: Id) -> Result, Error> pubkey: location.pubkey, network_id: location.network_id, location_mfa_mode: location.location_mfa_mode, + posture_check_required: location.posture_check_required, + mfa_method: location.mfa_method, }; location_info.push(info); } @@ -482,6 +654,26 @@ pub async fn all_locations(instance_id: Id) -> Result, Error> Ok(location_info) } +/// Returns `true` if there is at least one visible (non-service) location across all instances. +/// Shares the same visibility filter as [`all_locations`] (`include_service_locations = false`). +#[tauri::command(async)] +pub async fn has_any_visible_locations() -> Result { + trace!("Checking whether any visible locations exist."); + let instances = Instance::all(&*DB_POOL).await?; + for instance in &instances { + let locations = Location::find_by_instance_id(&*DB_POOL, instance.id, false).await?; + if !locations.is_empty() { + trace!( + "Found at least one visible location in instance {}.", + instance.name + ); + return Ok(true); + } + } + trace!("No visible locations found."); + Ok(false) +} + #[derive(Serialize, Debug)] pub struct LocationInterfaceDetails { pub location_id: Id, @@ -497,6 +689,7 @@ pub struct LocationInterfaceDetails { pub allowed_ips: String, pub persistent_keepalive_interval: Option, pub last_handshake: Option, + pub mfa_method: Option, } #[tauri::command(async)] @@ -521,10 +714,20 @@ pub async fn update_instance( if let Some(mut instance) = Instance::find_by_id(&*DB_POOL, instance_id).await? { debug!("The instance with id {instance_id} to update was found: {instance}"); let mut transaction = DB_POOL.begin().await?; - do_update_instance(&mut transaction, &mut instance, response).await?; + let locations_changed = + do_update_instance(&mut transaction, &mut instance, response).await?; transaction.commit().await?; - app_handle.emit(EventKey::InstanceUpdate.into(), ())?; + sync_service_locations_best_effort(&DB_POOL, &instance).await; + + if locations_changed { + if let Err(err) = app_handle.emit(EventKey::InstanceUpdated.into(), ()) { + error!("Failed to emit instance-updated event: {err}"); + } + } + app_handle + .emit(EventKey::InstanceUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; reload_tray_menu(&app_handle).await; Ok(()) } else { @@ -533,229 +736,6 @@ pub async fn update_instance( } } -/// Returns true if configuration in instance_info differs from current configuration -pub(crate) async fn locations_changed( - transaction: &mut Transaction<'_, Sqlite>, - instance: &Instance, - device_config: &DeviceConfigResponse, -) -> Result { - let db_locations: HashSet> = - Location::find_by_instance_id(transaction.as_mut(), instance.id, true) - .await? - .into_iter() - .map(|location| { - let mut new_location = Location::::from(location); - // Ignore `route_all_traffic` flag as Defguard core does not have it. - new_location.route_all_traffic = false; - new_location - }) - .collect(); - let core_locations: HashSet = device_config - .configs - .iter() - .map(|config| config.clone().into_location(instance.id)) - .collect(); - - Ok(db_locations != core_locations) -} - -pub(crate) async fn do_update_instance( - transaction: &mut Transaction<'_, Sqlite>, - instance: &mut Instance, - response: DeviceConfigResponse, -) -> Result<(), Error> { - // update instance - debug!("Updating instance {instance}"); - let locations_changed = locations_changed(transaction, instance, &response).await?; - let instance_info = response - .instance - .expect("Missing instance info in device config response"); - instance.name = instance_info.name; - instance.url = instance_info.url; - instance.proxy_url = instance_info.proxy_url; - instance.username = instance_info.username; - // Make sure to update the locations too if we are disabling all traffic - let policy = instance_info.client_traffic_policy.into(); - if instance.client_traffic_policy != policy && policy == ClientTrafficPolicy::DisableAllTraffic - { - debug!("Disabling all traffic for all locations of instance {instance}"); - Location::disable_all_traffic_for_all(transaction.as_mut(), instance.id).await?; - debug!("Disabled all traffic for all locations of instance {instance}"); - } - instance.client_traffic_policy = instance_info.client_traffic_policy.into(); - instance.openid_display_name = instance_info.openid_display_name; - instance.uuid = instance_info.id; - // Token may be empty if it was not issued - // This happens during polling, as core doesn't issue a new token for polling request - if response.token.is_some() { - instance.token = response.token; - debug!("Set polling token for instance {}", instance.name); - } else { - debug!( - "No polling token received for instance {}, not updating", - instance.name - ); - } - instance.save(transaction.as_mut()).await?; - debug!( - "A new base configuration has been applied to instance {instance}, even if nothing changed" - ); - - let mut service_locations = Vec::new(); - - // check if locations have changed - if locations_changed { - // process locations received in response - debug!( - "Updating locations for instance {}({}).", - instance.name, instance.id - ); - // Fetch existing locations for a given instance. - let mut current_locations = - Location::find_by_instance_id(transaction.as_mut(), instance.id, true).await?; - for dev_config in response.configs { - // parse device config - let new_location = dev_config.into_location(instance.id); - - // check if location is already present in current locations - let saved_location = if let Some(position) = current_locations - .iter() - .position(|loc| loc.network_id == new_location.network_id) - { - // remove from list of existing locations - let mut current_location = current_locations.remove(position); - debug!( - "Updating existing location {}({}) for instance {}({}).", - current_location.name, current_location.id, instance.name, instance.id, - ); - // update existing location - current_location.name = new_location.name; - current_location.address = new_location.address; - current_location.pubkey = new_location.pubkey; - current_location.endpoint = new_location.endpoint; - current_location.allowed_ips = new_location.allowed_ips; - current_location.keepalive_interval = new_location.keepalive_interval; - current_location.dns = new_location.dns; - current_location.location_mfa_mode = new_location.location_mfa_mode; - current_location.service_location_mode = new_location.service_location_mode; - current_location.save(transaction.as_mut()).await?; - info!("Location {current_location} configuration updated for instance {instance}"); - current_location - } else { - // create new location - debug!("Creating new location {new_location} for instance instance {instance}"); - let new_location = new_location.save(transaction.as_mut()).await?; - info!("New location {new_location} created for instance {instance}"); - new_location - }; - - if saved_location.is_service_location() { - debug!( - "Adding service location {}({}) for instance {}({}) to be saved to the daemon.", - saved_location.name, saved_location.id, instance.name, instance.id, - ); - service_locations.push(saved_location.to_service_location()?); - } - } - - // remove locations which were present in current locations - // but no longer found in core response - debug!("Removing locations for instance {instance}"); - for removed_location in current_locations { - removed_location.delete(transaction.as_mut()).await?; - info!( - "Removed location {removed_location} for instance {instance} during instance update" - ); - } - debug!("Finished updating locations for instance {instance}"); - } else { - info!("Locations for instance {instance} didn't change. Not updating them."); - } - - if service_locations.is_empty() { - debug!( - "No service locations for instance {}({}), removing all existing service locations connections if there are any.", - instance.name, instance.id - ); - - #[cfg(not(target_os = "macos"))] - { - let delete_request = DeleteServiceLocationsRequest { - instance_id: instance.uuid.clone(), - }; - DAEMON_CLIENT - .clone() - .delete_service_locations(delete_request) - .await - .map_err(|err| { - error!( - "Error while deleting service locations from the daemon for instance {}({}): {err}", - instance.name, instance.id, - ); - Error::InternalError(err.to_string()) - })?; - debug!( - "Successfully removed all service locations from daemon for instance {}({})", - instance.name, instance.id - ); - } - } else { - debug!( - "Processing {} service location(s) for instance {}({})", - service_locations.len(), - instance.name, - instance.id - ); - - #[cfg(not(target_os = "macos"))] - { - let private_key = WireguardKeys::find_by_instance_id(transaction.as_mut(), instance.id) - .await? - .ok_or(Error::NotFound)? - .prvkey; - - let save_request = SaveServiceLocationsRequest { - service_locations: service_locations.clone(), - instance_id: instance.uuid.clone(), - private_key, - }; - - debug!( - "Sending request to daemon to save {} service location(s) for instance {}({})", - save_request.service_locations.len(), - instance.name, - instance.id - ); - - DAEMON_CLIENT - .clone() - .save_service_locations(save_request) - .await - .map_err(|err| { - error!( - "Error while saving service locations to the daemon for instance {}({}): {err}", - instance.name, instance.id, - ); - Error::InternalError(err.to_string()) - })?; - - info!( - "Successfully saved {} service location(s) to daemon for instance {}({})", - service_locations.len(), - instance.name, - instance.id - ); - - debug!( - "Completed processing all service locations for instance {}({})", - instance.name, instance.id - ); - } - } - - Ok(()) -} - /// If `datetime` is Some, parses the date string, otherwise returns `DateTime` one hour ago. pub(crate) fn parse_timestamp(from: Option) -> Result, Error> { Ok(match from { @@ -764,35 +744,6 @@ pub(crate) fn parse_timestamp(from: Option) -> Result, Err }) } -pub(crate) enum DateTimeAggregation { - Hour, - Second, -} - -impl DateTimeAggregation { - /// Returns database format string for a given aggregation variant. - #[must_use] - pub(crate) fn fstring(&self) -> &'static str { - match self { - Self::Hour => "%Y-%m-%d %H:00:00", - Self::Second => "%Y-%m-%d %H:%M:%S", - } - } -} - -pub(crate) fn get_aggregation(from: NaiveDateTime) -> Result { - // Use hourly aggregation for longer periods - let aggregation = match Utc::now().naive_utc() - from { - duration if duration >= Duration::hours(8) => Ok(DateTimeAggregation::Hour), - duration if duration < Duration::zero() => Err(Error::InternalError(format!( - "Negative duration between dates: now ({}) and {from}", - Utc::now().naive_utc(), - ))), - _ => Ok(DateTimeAggregation::Second), - }?; - Ok(aggregation) -} - #[tauri::command(async)] pub async fn location_stats( location_id: Id, @@ -801,7 +752,7 @@ pub async fn location_stats( ) -> Result>, Error> { trace!("Location stats command received"); let from = parse_timestamp(from)?.naive_utc(); - let aggregation = get_aggregation(from)?; + let aggregation = crate::get_aggregation(from)?; let stats = match connection_type { ConnectionType::Location => { LocationStats::all_by_location_id(&*DB_POOL, location_id, &from, &aggregation, None) @@ -828,17 +779,17 @@ pub async fn all_connections( connection_type: ConnectionType, ) -> Result, Error> { debug!("Retrieving connections for location {location_id}"); - let connections: Vec = match connection_type { + let connections = match connection_type { ConnectionType::Location => ConnectionInfo::all_by_location_id(&*DB_POOL, location_id) .await? .into_iter() .map(Into::into) - .collect(), + .collect::>(), ConnectionType::Tunnel => TunnelConnectionInfo::all_by_tunnel_id(&*DB_POOL, location_id) .await? .into_iter() .map(Into::into) - .collect(), + .collect::>(), }; debug!("Connections retrieved({})", connections.len()); trace!("Connections found:\n{connections:#?}"); @@ -920,55 +871,21 @@ pub async fn update_location_routing( match connection_type { ConnectionType::Location => { - if let Some(mut location) = Location::find_by_id(&*DB_POOL, location_id).await? { - let instance = Instance::find_by_id(&*DB_POOL, location.instance_id) - .await? - .ok_or(Error::NotFound)?; - // Check if the instance has route_all_traffic disabled - if (instance.client_traffic_policy == ClientTrafficPolicy::DisableAllTraffic) - && route_all_traffic - { - error!( - "Couldn't update location routing: instance with id {} has \ - route_all_traffic disabled.", - instance.id - ); - return Err(Error::InternalError( - "Instance has route_all_traffic disabled".into(), - )); - } - // Check if the instance has route_all_traffic enforced - if (instance.client_traffic_policy == ClientTrafficPolicy::ForceAllTraffic) - && !route_all_traffic - { - error!( - "Couldn't update location routing: instance with id {} has \ - route_all_traffic enforced.", - instance.id - ); - return Err(Error::InternalError( - "Instance has route_all_traffic enforced".into(), - )); - } - - location.route_all_traffic = route_all_traffic; - location.save(&*DB_POOL).await?; - debug!("Location routing updated for location {name}(ID: {location_id})"); - handle.emit(EventKey::LocationUpdate.into(), ())?; - Ok(()) - } else { - error!( - "Couldn't update location routing: location with id {location_id} not found." - ); - Err(Error::NotFound) - } + Location::update_routing(&DB_POOL, location_id, route_all_traffic).await?; + debug!("Location routing updated for location {name}(ID: {location_id})"); + handle + .emit(EventKey::LocationUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; + Ok(()) } ConnectionType::Tunnel => { if let Some(mut tunnel) = Tunnel::find_by_id(&*DB_POOL, location_id).await? { tunnel.route_all_traffic = route_all_traffic; tunnel.save(&*DB_POOL).await?; info!("Tunnel routing updated for tunnel {location_id}"); - handle.emit(EventKey::LocationUpdate.into(), ())?; + handle + .emit(EventKey::LocationUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; Ok(()) } else { error!("Couldn't update tunnel routing: tunnel with id {location_id} not found."); @@ -978,6 +895,21 @@ pub async fn update_location_routing( } } +#[tauri::command(async)] +pub async fn set_location_mfa_method( + location_id: Id, + mfa_method: LocationMfaMethod, + handle: AppHandle, +) -> Result<(), Error> { + debug!("Received command to set MFA method for location {location_id}"); + Location::set_mfa_method(&DB_POOL, location_id, mfa_method).await?; + debug!("MFA method updated for location (ID: {location_id})"); + handle + .emit(EventKey::LocationUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; + Ok(()) +} + #[cfg(target_os = "macos")] #[tauri::command(async)] pub async fn delete_instance(instance_id: Id, handle: AppHandle) -> Result<(), Error> { @@ -1011,15 +943,22 @@ pub async fn delete_instance(instance_id: Id, handle: AppHandle) -> Result<(), E } } + let was_disabled = Instance::tunnels_disabled(&*DB_POOL).await?; instance.delete(&mut *transaction).await?; transaction.commit().await?; + if was_disabled && !Instance::tunnels_disabled(&*DB_POOL).await? { + TunnelsEnabledPayload::emit(&handle); + } + reload_tray_menu(&handle).await; configure_tray_icon(&handle).await?; - handle.emit(EventKey::InstanceUpdate.into(), ())?; + handle + .emit(EventKey::InstanceUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; info!("Successfully deleted instance {instance}."); Ok(()) } @@ -1073,10 +1012,15 @@ pub async fn delete_instance(instance_id: Id, handle: AppHandle) -> Result<(), E ); } } + let was_disabled = Instance::tunnels_disabled(&*DB_POOL).await?; instance.delete(&mut *transaction).await?; transaction.commit().await?; + if was_disabled && !Instance::tunnels_disabled(&*DB_POOL).await? { + TunnelsEnabledPayload::emit(&handle); + } + client .delete_service_locations(DeleteServiceLocationsRequest { instance_id: instance.uuid.clone(), @@ -1094,7 +1038,9 @@ pub async fn delete_instance(instance_id: Id, handle: AppHandle) -> Result<(), E configure_tray_icon(&handle).await?; - handle.emit(EventKey::InstanceUpdate.into(), ())?; + handle + .emit(EventKey::InstanceUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; info!("Successfully deleted instance {instance}."); Ok(()) } @@ -1112,23 +1058,29 @@ pub fn parse_tunnel_config(filename: &str, config: &str) -> Result, handle: AppHandle) -> Result<(), Error> { + Instance::ensure_tunnels_enabled(&*DB_POOL).await?; debug!("Received tunnel configuration to update: {tunnel}"); tunnel.save(&*DB_POOL).await?; info!("The tunnel {tunnel} configuration has been updated."); - handle.emit(EventKey::LocationUpdate.into(), ())?; + handle + .emit(EventKey::LocationUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; Ok(()) } #[tauri::command(async)] pub async fn save_tunnel(tunnel: Tunnel, handle: AppHandle) -> Result<(), Error> { + Instance::ensure_tunnels_enabled(&*DB_POOL).await?; debug!("Received tunnel configuration to save: {tunnel}"); let tunnel = tunnel.save(&*DB_POOL).await?; info!("The tunnel {tunnel} configuration has been saved."); - handle.emit(EventKey::LocationUpdate.into(), ())?; + handle + .emit(EventKey::LocationUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; Ok(()) } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct TunnelInfo { pub id: I, pub name: String, @@ -1141,6 +1093,11 @@ pub struct TunnelInfo { #[tauri::command(async)] pub async fn all_tunnels() -> Result>, Error> { + // Soft-hide: report no tunnels (rather than erroring) so callers render an empty + // list. Mutating/connecting commands hard-refuse via `ensure_tunnels_enabled`. + if Instance::tunnels_disabled(&*DB_POOL).await? { + return Ok(Vec::new()); + } trace!("Getting information about all tunnels"); let tunnels = Tunnel::all(&*DB_POOL).await?; @@ -1169,6 +1126,7 @@ pub async fn all_tunnels() -> Result>, Error> { #[tauri::command(async)] pub async fn tunnel_details(tunnel_id: Id) -> Result, Error> { + Instance::ensure_tunnels_enabled(&*DB_POOL).await?; debug!("Retrieving details about tunnel with ID {tunnel_id}."); if let Some(tunnel) = Tunnel::find_by_id(&*DB_POOL, tunnel_id).await? { @@ -1246,10 +1204,10 @@ pub async fn delete_tunnel(tunnel_id: Id, handle: AppHandle) -> Result<(), Error )) })?; info!( - "Network interface {} has been removed and the connection to tunnel {tunnel} has been \ + "Network interface {} has been removed and the connection to tunnel {tunnel} has been \ closed.", - connection.interface_name - ); + connection.interface_name + ); if let Some(post_down) = &tunnel.post_down { debug!( "Executing defined PostDown command after removing the interface {} for the \ @@ -1269,6 +1227,10 @@ pub async fn delete_tunnel(tunnel_id: Id, handle: AppHandle) -> Result<(), Error transaction.commit().await?; + handle + .emit(EventKey::LocationUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; + info!("Successfully deleted tunnel {tunnel}"); Ok(()) } @@ -1287,13 +1249,21 @@ pub struct AppVersionInfo { pub release_date: String, pub release_notes_url: String, pub update_url: String, + pub summary: Option, } const PRODUCT_NAME: &str = "defguard-client"; +fn reported_app_version(handle: &AppHandle) -> String { + defguard_client_core::version::select_reported_app_version( + &handle.package_info().version.to_string(), + option_env!("DEFGUARD_CLIENT_BUILD_VERSION"), + ) +} + #[tauri::command(async)] pub async fn get_latest_app_version(handle: AppHandle) -> Result { - let app_version = handle.package_info().version.to_string(); + let app_version = reported_app_version(&handle); let operating_system = env::consts::OS; let mut request_data = HashMap::new(); @@ -1347,21 +1317,17 @@ pub async fn command_set_app_config( let app_state = app_handle.state::(); debug!("Command set app config received."); trace!("Command payload: {config_patch:?}"); - let tray_changed = config_patch.tray_theme.is_some(); let res = { let mut app_config = app_state.app_config.lock().unwrap(); app_config.apply(config_patch); - app_config.save(&app_handle); + let config_dir = app_handle + .path() + .app_data_dir() + .expect("Failed to access app data"); + app_config.save(&config_dir); app_config.clone() }; info!("Config changed successfully"); - if tray_changed { - debug!("Tray theme included in config change, tray will be updated."); - match configure_tray_icon(&app_handle).await { - Ok(()) => debug!("Tray updated upon config change"), - Err(err) => error!("Tray change failed. Reason: {err}"), - } - } if emit_event { match app_handle.emit(EventKey::ApplicationConfigChanged.into(), ()) { Ok(()) => debug!("Config changed event emitted successfully"), @@ -1395,3 +1361,426 @@ pub fn get_provisioning_config( pub fn get_platform_header() -> String { construct_platform_header() } + +#[tauri::command(async)] +pub async fn get_posture_data() -> Result { + debug!("Received a command to prepare posture report"); + defguard_client_posture::get_posture_data().await +} + +#[derive(Debug, Serialize)] +pub struct ActiveConnectionSummary { + pub id: Id, + pub name: String, + pub connection_type: ConnectionType, +} + +#[tauri::command(async)] +pub async fn all_active_connections() -> Result, Error> { + debug!("Getting information about all active connections."); + let connections = ACTIVE_CONNECTIONS.lock().await; + let mut result = Vec::with_capacity(connections.len()); + for conn in connections.iter() { + if conn.connection_type == ConnectionType::Location { + match Location::find_by_id(&*DB_POOL, conn.location_id).await? { + Some(location) if location.is_service_location() => continue, + None => continue, + _ => {} + } + } + let name = get_tunnel_or_location_name(conn.location_id, conn.connection_type).await; + result.push(ActiveConnectionSummary { + id: conn.location_id, + name, + connection_type: conn.connection_type, + }); + } + debug!("Returning {} active connections.", result.len()); + Ok(result) +} + +/// Returned by the `enrollment_start` Tauri command. +#[derive(Clone, Debug, Serialize)] +pub struct EnrollmentStartResult { + pub session_id: String, + pub user: InitialUserInfo, + pub admin: AdminInfo, + pub settings: EnrollmentSettings, + pub instance: ProtoInstanceInfo, + pub deadline_timestamp: i64, + pub final_page_content: String, +} + +#[tauri::command(async)] +pub async fn enrollment_start( + proxy_url: String, + token: String, + state: State<'_, AppState>, +) -> Result { + debug!("Starting enrollment at {proxy_url}"); + let url = Url::parse(&proxy_url).map_err(|e| format!("Invalid proxy URL: {e}"))?; + let (session, response) = enrollment::enrollment_start(url, token) + .await + .map_err(err_to_json)?; + let session_uuid = Uuid::new_v4(); + let session_id = session_uuid.to_string(); + state + .enrollment_sessions + .lock() + .expect("enrollment_sessions mutex poisoned") + .insert(session_uuid, session); + let login = response + .user + .as_ref() + .map_or("", |u| u.login.as_str()); + info!("Enrollment started for user {login}, session {session_id}"); + Ok(EnrollmentStartResult { + session_id, + user: response + .user + .ok_or_else(|| "Proxy did not return user info".to_string())?, + admin: response + .admin + .ok_or_else(|| "Proxy did not return admin info".to_string())?, + settings: response + .settings + .ok_or_else(|| "Proxy did not return enrollment settings".to_string())?, + instance: response + .instance + .ok_or_else(|| "Proxy did not return instance info".to_string())?, + deadline_timestamp: response.deadline_timestamp, + final_page_content: response.final_page_content, + }) +} + +#[tauri::command(async)] +pub async fn enrollment_create_device( + session_id: String, + name: String, + pubkey: String, + state: State<'_, AppState>, +) -> Result { + debug!("Creating device \"{name}\""); + let session = get_enrollment_session(&state, &session_id)?; + let result = enrollment::enrollment_create_device(session, name, pubkey) + .await + .map_err(err_to_json)?; + info!("Device created"); + Ok(result) +} + +#[tauri::command(async)] +pub async fn enrollment_activate_user( + session_id: String, + password: Option, + phone_number: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + debug!("Activating user"); + let session = get_enrollment_session(&state, &session_id)?; + enrollment::enrollment_activate_user(session, password, phone_number) + .await + .map_err(err_to_json)?; + info!("User activated"); + Ok(()) +} + +#[tauri::command(async)] +pub async fn enrollment_register_mfa_start( + session_id: String, + method: String, + state: State<'_, AppState>, +) -> Result { + debug!("Starting MFA setup"); + let session = get_enrollment_session(&state, &session_id)?; + enrollment::enrollment_register_mfa_start(session, method) + .await + .map_err(err_to_json) +} + +#[tauri::command(async)] +pub async fn enrollment_register_mfa_finish( + session_id: String, + code: String, + method: String, + state: State<'_, AppState>, +) -> Result { + debug!("Finishing MFA setup"); + let session = get_enrollment_session(&state, &session_id)?; + enrollment::enrollment_register_mfa_finish(session, code, method) + .await + .map_err(err_to_json) +} + +#[tauri::command(async)] +pub async fn enrollment_network_info( + session_id: String, + pubkey: String, + state: State<'_, AppState>, +) -> Result { + debug!("Fetching network info"); + let session = get_enrollment_session(&state, &session_id)?; + enrollment::enrollment_network_info(session, pubkey) + .await + .map_err(err_to_json) +} + +#[tauri::command(async)] +pub async fn enrollment_finish( + session_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + debug!("Finishing enrollment"); + let session = { + let uid = Uuid::parse_str(&session_id).map_err(|e| format!("Invalid session ID: {e}"))?; + let mut sessions = state + .enrollment_sessions + .lock() + .expect("enrollment_sessions mutex poisoned"); + sessions + .remove(&uid) + .ok_or_else(|| "Enrollment session not found".to_string())? + }; + enrollment::enrollment_finish(session); + info!("Enrollment finished, session {session_id} removed"); + Ok(()) +} + +#[derive(Clone, Serialize)] +pub struct MfaErrorPayload { + pub error: String, +} + +/// Bring up a location connection with a preshared key obtained from a +/// completed MFA handshake. Keeps the preshared key inside the backend - it is +/// never returned to or emitted at the frontend. +async fn connect_after_mfa( + location_id: Id, + preshared_key: String, + handle: &AppHandle, +) -> Result<(), String> { + let location = Location::find_by_id(&*DB_POOL, location_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Location not found".to_string())?; + connect_location_with_psk(location, Some(preshared_key), handle) + .await + // Distinct prefix so the frontend can tell a post-MFA connection + // failure apart from an MFA/auth failure. + .map_err(|e| format!("VPN connection failed: {e}")) +} + +/// Map the frontend MFA method string to the proto `MfaMethod` enum the proxy +/// expects on the wire (a numeric enum, not a string). +fn parse_mfa_method(method: &str) -> Result { + match method { + "totp" => Ok(MfaMethod::Totp), + "email" => Ok(MfaMethod::Email), + "oidc" => Ok(MfaMethod::Oidc), + "biometric" => Ok(MfaMethod::Biometric), + "mobileapprove" => Ok(MfaMethod::MobileApprove), + other => Err(format!("Unsupported MFA method: {other}")), + } +} + +#[tauri::command(async)] +pub async fn mfa_start( + instance_id: Id, + location_id: Id, + method: String, +) -> Result { + debug!("Starting MFA session for location {location_id}"); + let method = parse_mfa_method(&method)?; + let instance = Instance::find_by_id(&*DB_POOL, instance_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Instance not found".to_string())?; + let keys = WireguardKeys::find_by_instance_id(&*DB_POOL, instance_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "WireGuard keys not found".to_string())?; + let proxy_url = + Url::parse(&instance.proxy_url).map_err(|e| format!("Invalid proxy URL: {e}"))?; + let location = Location::find_by_id(&*DB_POOL, location_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Location not found".to_string())?; + // FIXME: ugly struct + ConnectionTarget::Location(location.clone()) + .ensure_single_all_traffic_connection(&DB_POOL, None) + .await + .map_err(|err| err.to_string())?; + let posture_data = if location.posture_check_required { + Some( + defguard_client_posture::get_posture_data() + .await + .map_err(|e| format!("Failed to collect posture data: {e}"))?, + ) + } else { + None + }; + let request = ClientMfaStartRequest { + location_id: location.network_id, + pubkey: keys.pubkey, + method: method as i32, + posture_data, + }; + mfa::mfa_start(proxy_url, request) + .await + .map_err(err_to_json) +} + +#[tauri::command(async)] +pub async fn mfa_finish_code( + instance_id: Id, + location_id: Id, + token: String, + code: String, + handle: AppHandle, +) -> Result<(), String> { + debug!("Finishing MFA with code for instance {instance_id}"); + let instance = Instance::find_by_id(&*DB_POOL, instance_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Instance not found".to_string())?; + let proxy_url = + Url::parse(&instance.proxy_url).map_err(|e| format!("Invalid proxy URL: {e}"))?; + let request = ClientMfaFinishRequest { + token, + code: Some(code), + auth_pub_key: None, + }; + let response = mfa::mfa_finish_code(proxy_url, request) + .await + .map_err(err_to_json)?; + connect_after_mfa(location_id, response.preshared_key, &handle).await +} + +/// Register a long-running MFA task, run its future in the background, and on +/// success bring up the connection Rust-side before emitting a payload-free +/// completion event (or an error event). Shared by the OpenID poll and mobile +/// approve flows so the preshared key never leaves the backend. Returns the +/// task id the frontend uses to cancel. +fn spawn_mfa_task( + handle: &AppHandle, + location_id: Id, + complete_event: EventKey, + error_event: EventKey, + run: R, +) -> String +where + R: FnOnce(CancellationToken) -> F + Send + 'static, + F: std::future::Future> + + Send + + 'static, +{ + let cancel = CancellationToken::new(); + let task_id = Uuid::new_v4().to_string(); + handle + .state::() + .mfa_tasks + .lock() + .expect("mfa_tasks mutex poisoned") + .insert(task_id.clone(), cancel.clone()); + + let task_id_for_task = task_id.clone(); + let listen_handle = handle.clone(); + tokio::spawn(async move { + let result = run(cancel).await; + listen_handle + .state::() + .mfa_tasks + .lock() + .expect("mfa_tasks mutex poisoned") + .remove(&task_id_for_task); + match result { + Ok(response) => { + info!("MFA completed for task {task_id_for_task}"); + match connect_after_mfa(location_id, response.preshared_key, &listen_handle).await { + Ok(()) => { + let _ = listen_handle.emit(complete_event.into(), ()); + } + Err(err) => { + warn!("Connect after MFA failed for task {task_id_for_task}: {err}"); + let _ = + listen_handle.emit(error_event.into(), MfaErrorPayload { error: err }); + } + } + } + Err(err) => { + warn!("MFA task {task_id_for_task} failed: {err}"); + // Emit the structured error as JSON so the frontend classifies + // it the same way as command errors. + let _ = listen_handle.emit( + error_event.into(), + MfaErrorPayload { + error: err_to_json(err), + }, + ); + } + } + }); + + task_id +} + +#[tauri::command(async)] +pub async fn mfa_poll_openid( + instance_id: Id, + location_id: Id, + token: String, + handle: AppHandle, +) -> Result { + debug!("Starting OpenID MFA poll for instance {instance_id}"); + let instance = Instance::find_by_id(&*DB_POOL, instance_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Instance not found".to_string())?; + let proxy_url = + Url::parse(&instance.proxy_url).map_err(|e| format!("Invalid proxy URL: {e}"))?; + Ok(spawn_mfa_task( + &handle, + location_id, + EventKey::MfaOpenIdComplete, + EventKey::MfaOpenIdError, + move |cancel| mfa::poll_openid_mfa(proxy_url, token, cancel), + )) +} + +#[tauri::command(async)] +pub async fn mfa_connect_mobile_approve( + instance_id: Id, + location_id: Id, + token: String, + handle: AppHandle, +) -> Result { + debug!("Starting mobile approve MFA for instance {instance_id}"); + let instance = Instance::find_by_id(&*DB_POOL, instance_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Instance not found".to_string())?; + let proxy_url = + Url::parse(&instance.proxy_url).map_err(|e| format!("Invalid proxy URL: {e}"))?; + let ws_url = mfa::derive_ws_url(&proxy_url, &token).map_err(|e| e.to_string())?; + Ok(spawn_mfa_task( + &handle, + location_id, + EventKey::MfaMobileComplete, + EventKey::MfaMobileError, + move |cancel| async move { mfa::connect_mobile_approve(&ws_url, cancel).await }, + )) +} + +#[tauri::command(async)] +pub async fn cancel_mfa(task_id: String, state: State<'_, AppState>) -> Result<(), String> { + debug!("Cancelling MFA task {task_id}"); + let cancel = { + let tasks = state.mfa_tasks.lock().expect("mfa_tasks mutex poisoned"); + tasks + .get(&task_id) + .cloned() + .ok_or_else(|| "MFA task not found".to_string())? + }; + cancel.cancel(); + Ok(()) +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs deleted file mode 100644 index 99866016a..000000000 --- a/src-tauri/src/database/mod.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::{ - env, - fs::{create_dir_all, File}, - str::FromStr, - sync::LazyLock, -}; - -use sqlx::sqlite::{SqliteAutoVacuum, SqliteConnectOptions, SqliteJournalMode, SqlitePool}; - -#[cfg(unix)] -use crate::set_perms; -use crate::{app_data_dir, error::Error}; - -const DB_NAME: &str = "defguard.db"; - -pub mod models; - -pub(crate) type DbPool = SqlitePool; - -pub static DB_POOL: LazyLock = LazyLock::new(|| { - let db_url = prepare_db_url().expect("Wrong database URL."); - let opts = SqliteConnectOptions::from_str(&db_url) - .expect("Failed to set database connenction options.") - .create_if_missing(true) - .auto_vacuum(SqliteAutoVacuum::Incremental) - .journal_mode(SqliteJournalMode::Wal); - debug!("Connecting to database: {db_url} with options: {opts:?}"); - SqlitePool::connect_lazy_with(opts) -}); - -/// Returns database URL. Checks for custom URL in `DATABASE_URL` environment variable. -/// Handles creating appropriate directories if they don't exist. -fn prepare_db_url() -> Result { - if let Ok(url) = env::var("DATABASE_URL") { - info!( - "The default database location has been just overridden by the DATABASE_URL \ - environment variable. The application will use the database located at: {url}" - ); - Ok(url) - } else { - debug!("A production database will be used as no custom DATABASE_URL was provided."); - // Check if database directory and file exists, create if they don't. - let app_dir = app_data_dir().ok_or(Error::Config( - "Application data directory is not defined. Cannot proceed. Is the application \ - running on a supported platform?" - .to_string(), - ))?; - if app_dir.exists() { - debug!( - "Application data directory already exists at: {}, skipping its creation.", - app_dir.to_string_lossy() - ); - } else { - debug!( - "Creating application data directory at: {}", - app_dir.to_string_lossy() - ); - create_dir_all(&app_dir)?; - debug!( - "Created application data directory at: {}", - app_dir.to_string_lossy() - ); - } - #[cfg(unix)] - set_perms(&app_dir); - let db_path = app_dir.join(DB_NAME); - if db_path.exists() { - debug!( - "Database file already exists at: {}. Skipping its creation.", - db_path.to_string_lossy() - ); - } else { - debug!( - "Database file not found at {}. Creating a new one.", - db_path.to_string_lossy() - ); - File::create(&db_path)?; - info!( - "A new, empty database file has been created at: {} as no previous database file \ - was found. This file will be used to store application data.", - db_path.to_string_lossy() - ); - } - #[cfg(unix)] - set_perms(&db_path); - debug!( - "Application's database file is located at: {}", - db_path.to_string_lossy() - ); - Ok(format!( - "sqlite://{}", - db_path.to_str().expect("Failed to format DB path") - )) - } -} - -pub async fn handle_db_migrations() { - debug!("Running database migrations, if there are any."); - sqlx::migrate!() - .run(&*DB_POOL) - .await - .expect("Failed to apply database migrations."); - debug!("Applied all database migrations that were pending. If any."); - debug!("Database setup has been completed successfully."); -} diff --git a/src-tauri/src/database/models/connection.rs b/src-tauri/src/database/models/connection.rs deleted file mode 100644 index f816c226b..000000000 --- a/src-tauri/src/database/models/connection.rs +++ /dev/null @@ -1,169 +0,0 @@ -use chrono::{NaiveDateTime, Utc}; -use serde::Serialize; -use sqlx::{query_as, query_scalar, SqliteExecutor}; - -use super::{Id, NoId}; -use crate::{error::Error, CommonConnection, CommonConnectionInfo, ConnectionType}; - -#[derive(Debug, Serialize, Clone)] -pub struct Connection { - pub id: I, - pub location_id: Id, - pub start: NaiveDateTime, - pub end: NaiveDateTime, -} - -impl Connection { - pub(crate) async fn save<'e, E>(self, executor: E) -> Result, Error> - where - E: SqliteExecutor<'e>, - { - let id = query_scalar!( - "INSERT INTO connection (location_id, start, end) \ - VALUES ($1, $2, $3) RETURNING id \"id!\"", - self.location_id, - self.start, - self.end, - ) - .fetch_one(executor) - .await?; - - Ok(Connection:: { - id, - location_id: self.location_id, - start: self.start, - end: self.end, - }) - } - - pub(crate) async fn latest_by_location_id<'e, E>( - executor: E, - location_id: Id, - ) -> Result>, Error> - where - E: SqliteExecutor<'e>, - { - let connection = query_as!( - Connection, - "SELECT id, location_id, start, end \ - FROM connection WHERE location_id = $1 \ - ORDER BY end DESC LIMIT 1", - location_id - ) - .fetch_optional(executor) - .await?; - Ok(connection) - } -} - -/// Historical connection -#[derive(Debug, Serialize)] -pub struct ConnectionInfo { - pub id: Id, - pub location_id: Id, - pub start: NaiveDateTime, - pub end: NaiveDateTime, - pub upload: Option, - pub download: Option, -} - -impl From for CommonConnectionInfo { - fn from(val: ConnectionInfo) -> Self { - CommonConnectionInfo { - id: val.id, - location_id: val.location_id, - start: val.start, - end: val.end, - upload: val.upload, - download: val.download, - } - } -} - -impl ConnectionInfo { - pub(crate) async fn all_by_location_id<'e, E>( - executor: E, - location_id: Id, - ) -> Result, Error> - where - E: SqliteExecutor<'e>, - { - // Because we store interface information for given timestamp, - // select last upload and download before connection ended. - // FIXME: Optimize query - let connections = query_as!( - ConnectionInfo, - "SELECT c.id, c.location_id, c.start, c.end, \ - COALESCE((\ - SELECT ls.upload \ - FROM location_stats ls \ - WHERE ls.location_id = c.location_id \ - AND ls.collected_at BETWEEN c.start AND c.end \ - ORDER BY ls.collected_at DESC LIMIT 1 \ - ), 0) \"upload: _\", \ - COALESCE((\ - SELECT ls.download \ - FROM location_stats ls \ - WHERE ls.location_id = c.location_id \ - AND ls.collected_at BETWEEN c.start AND c.end \ - ORDER BY ls.collected_at DESC LIMIT 1 \ - ), 0) \"download: _\" \ - FROM connection c WHERE location_id = $1 \ - ORDER BY start DESC", - location_id - ) - .fetch_all(executor) - .await?; - - Ok(connections) - } -} - -/// Connections stored in memory after creating a network interface. -#[derive(Clone, Debug, Serialize)] -pub struct ActiveConnection { - pub location_id: Id, - pub start: NaiveDateTime, - pub interface_name: String, - pub connection_type: ConnectionType, -} - -impl ActiveConnection { - #[must_use] - pub(crate) fn new( - location_id: Id, - interface_name: String, - connection_type: ConnectionType, - ) -> Self { - let start = Utc::now().naive_utc(); - Self { - location_id, - start, - interface_name, - connection_type, - } - } -} - -impl From<&ActiveConnection> for Connection { - fn from(active_connection: &ActiveConnection) -> Self { - Connection { - id: NoId, - location_id: active_connection.location_id, - start: active_connection.start, - end: Utc::now().naive_utc(), - } - } -} - -impl From> for CommonConnection { - fn from(connection: Connection) -> Self { - CommonConnection { - id: connection.id, - location_id: connection.location_id, - start: connection.start, - end: connection.end, - connection_type: ConnectionType::Location, - } - } -} diff --git a/src-tauri/src/database/models/instance.rs b/src-tauri/src/database/models/instance.rs deleted file mode 100644 index 0227afcee..000000000 --- a/src-tauri/src/database/models/instance.rs +++ /dev/null @@ -1,263 +0,0 @@ -use std::fmt; - -use serde::{Deserialize, Serialize}; -use sqlx::{prelude::Type, query, query_as, SqliteExecutor}; - -use super::{Id, NoId}; -use crate::proto; - -#[derive(Serialize, Deserialize, Debug)] -pub struct Instance { - pub id: I, - pub name: String, - pub uuid: String, - pub url: String, - pub proxy_url: String, - pub username: String, - pub token: Option, - pub client_traffic_policy: ClientTrafficPolicy, - pub enterprise_enabled: bool, - pub openid_display_name: Option, -} - -impl fmt::Display for Instance { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}(ID: {})", self.name, self.id) - } -} - -impl From for Instance { - fn from(instance_info: proto::InstanceInfo) -> Self { - let client_traffic_policy = ClientTrafficPolicy::from(&instance_info); - Self { - id: NoId, - name: instance_info.name, - uuid: instance_info.id, - url: instance_info.url, - proxy_url: instance_info.proxy_url, - username: instance_info.username, - token: None, - client_traffic_policy, - enterprise_enabled: instance_info.enterprise_enabled, - openid_display_name: instance_info.openid_display_name, - } - } -} - -impl Instance { - pub(crate) async fn save<'e, E>(&mut self, executor: E) -> Result<(), sqlx::Error> - where - E: SqliteExecutor<'e>, - { - query!( - "UPDATE instance SET name = $1, uuid = $2, url = $3, proxy_url = $4, username = $5, \ - client_traffic_policy = $6, enterprise_enabled = $7, token = $8, \ - openid_display_name = $9 \ - WHERE id = $10;", - self.name, - self.uuid, - self.url, - self.proxy_url, - self.username, - self.client_traffic_policy, - self.enterprise_enabled, - self.token, - self.openid_display_name, - self.id - ) - .execute(executor) - .await?; - Ok(()) - } - - pub async fn all<'e, E>(executor: E) -> Result, sqlx::Error> - where - E: SqliteExecutor<'e>, - { - let instances = query_as!( - Self, - "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", \ - client_traffic_policy, enterprise_enabled, openid_display_name \ - FROM instance ORDER BY name ASC;" - ) - .fetch_all(executor) - .await?; - Ok(instances) - } - - pub(crate) async fn find_by_id<'e, E>(executor: E, id: Id) -> Result, sqlx::Error> - where - E: SqliteExecutor<'e>, - { - let instance = query_as!( - Self, - "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token \"token?\", \ - client_traffic_policy, enterprise_enabled, openid_display_name \ - FROM instance WHERE id = $1;", - id - ) - .fetch_optional(executor) - .await?; - Ok(instance) - } - - pub(crate) async fn delete_by_id<'e, E>(executor: E, id: Id) -> Result<(), sqlx::Error> - where - E: SqliteExecutor<'e>, - { - // delete instance - query!("DELETE FROM instance WHERE id = $1", id) - .execute(executor) - .await?; - Ok(()) - } - - pub(crate) async fn delete<'e, E>(&self, executor: E) -> Result<(), sqlx::Error> - where - E: SqliteExecutor<'e>, - { - Instance::delete_by_id(executor, self.id).await?; - Ok(()) - } - - pub(crate) async fn all_with_token<'e, E>(executor: E) -> Result, sqlx::Error> - where - E: SqliteExecutor<'e>, - { - let instances = query_as!( - Self, - "SELECT id \"id: _\", name, uuid, url, proxy_url, username, token, \ - client_traffic_policy, enterprise_enabled, openid_display_name \ - FROM instance \ - WHERE token IS NOT NULL ORDER BY name ASC;" - ) - .fetch_all(executor) - .await?; - Ok(instances) - } -} - -// This compares proto::InstanceInfo, not to be confused with regular InstanceInfo defined below -impl PartialEq for Instance { - fn eq(&self, other: &proto::InstanceInfo) -> bool { - let other_policy = ClientTrafficPolicy::from(other); - self.name == other.name - && self.uuid == other.id - && self.url == other.url - && self.proxy_url == other.proxy_url - && self.username == other.username - && self.client_traffic_policy == other_policy - && self.enterprise_enabled == other.enterprise_enabled - && self.openid_display_name == other.openid_display_name - } -} - -impl Instance { - pub async fn save<'e, E>(self, executor: E) -> Result, sqlx::Error> - where - E: SqliteExecutor<'e>, - { - let url = self.url.clone(); - let proxy_url = self.proxy_url.clone(); - let result = query!( - "INSERT INTO instance (name, uuid, url, proxy_url, username, token, \ - client_traffic_policy , enterprise_enabled) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id;", - self.name, - self.uuid, - url, - proxy_url, - self.username, - self.token, - self.client_traffic_policy, - self.enterprise_enabled - ) - .fetch_one(executor) - .await?; - Ok(Instance:: { - id: result.id, - name: self.name, - uuid: self.uuid, - url: self.url, - proxy_url: self.proxy_url, - username: self.username, - token: self.token, - client_traffic_policy: self.client_traffic_policy, - enterprise_enabled: self.enterprise_enabled, - openid_display_name: self.openid_display_name, - }) - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct InstanceInfo { - pub id: I, - pub name: String, - pub uuid: String, - pub url: String, - pub proxy_url: String, - pub active: bool, - pub pubkey: String, - pub client_traffic_policy: ClientTrafficPolicy, - pub enterprise_enabled: bool, - pub openid_display_name: Option, -} - -impl fmt::Display for InstanceInfo { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}(ID: {})", self.name, self.id) - } -} - -/// Describes allowed traffic options for clients connecting to an instance. -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Type)] -#[repr(u32)] -#[serde(rename_all = "snake_case")] -pub enum ClientTrafficPolicy { - /// No restrictions - None = 0, - /// Clients are not allowed to route all traffic through the VPN. - DisableAllTraffic = 1, - /// Clients are forced to route all traffic through the VPN. - ForceAllTraffic = 2, -} - -/// Retrieves `ClientTrafficPolicy` from `proto::InstanceInfo` while ensuring backwards compatibility -impl From<&proto::InstanceInfo> for ClientTrafficPolicy { - fn from(instance: &proto::InstanceInfo) -> Self { - match ( - instance.client_traffic_policy, - #[allow(deprecated)] - instance.disable_all_traffic, - ) { - (Some(policy), _) => ClientTrafficPolicy::from(policy), - (None, true) => ClientTrafficPolicy::DisableAllTraffic, - (None, false) => ClientTrafficPolicy::None, - } - } -} - -impl From for ClientTrafficPolicy { - fn from(value: i32) -> Self { - match value { - 1 => ClientTrafficPolicy::DisableAllTraffic, - 2 => ClientTrafficPolicy::ForceAllTraffic, - _ => ClientTrafficPolicy::None, - } - } -} - -impl From> for ClientTrafficPolicy { - fn from(value: Option) -> Self { - match value { - None => Self::None, - Some(v) => Self::from(v), - } - } -} - -impl From for ClientTrafficPolicy { - fn from(value: i64) -> Self { - Self::from(value as i32) - } -} diff --git a/src-tauri/src/database/models/location.rs b/src-tauri/src/database/models/location.rs deleted file mode 100644 index 7381bc298..000000000 --- a/src-tauri/src/database/models/location.rs +++ /dev/null @@ -1,429 +0,0 @@ -use std::fmt; -#[cfg(not(target_os = "macos"))] -use std::str::FromStr; - -#[cfg(not(target_os = "macos"))] -use defguard_wireguard_rs::{key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration}; -use serde::{Deserialize, Serialize}; -use sqlx::{prelude::Type, query, query_as, query_scalar, Error as SqlxError, SqliteExecutor}; - -#[cfg(not(target_os = "macos"))] -use super::wireguard_keys::WireguardKeys; -use super::{Id, NoId}; -#[cfg(not(target_os = "macos"))] -use crate::{ - database::DbPool, - utils::{DEFAULT_ROUTE_IPV4, DEFAULT_ROUTE_IPV6}, -}; -use crate::{ - error::Error, - proto::{ - LocationMfaMode as ProtoLocationMfaMode, ServiceLocationMode as ProtoServiceLocationMode, - }, -}; - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Type)] -#[repr(u32)] -#[serde(rename_all = "lowercase")] -pub enum LocationMfaMode { - Disabled = 1, - Internal = 2, - External = 3, -} - -impl From for LocationMfaMode { - fn from(value: ProtoLocationMfaMode) -> Self { - match value { - ProtoLocationMfaMode::Unspecified | ProtoLocationMfaMode::Disabled => { - LocationMfaMode::Disabled - } - ProtoLocationMfaMode::Internal => LocationMfaMode::Internal, - ProtoLocationMfaMode::External => LocationMfaMode::External, - } - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Type)] -#[repr(u32)] -#[serde(rename_all = "lowercase")] -pub enum ServiceLocationMode { - Disabled = 1, - PreLogon = 2, - AlwaysOn = 3, -} - -impl From for ServiceLocationMode { - fn from(value: ProtoServiceLocationMode) -> Self { - match value { - ProtoServiceLocationMode::Unspecified | ProtoServiceLocationMode::Disabled => { - ServiceLocationMode::Disabled - } - ProtoServiceLocationMode::Prelogon => ServiceLocationMode::PreLogon, - ProtoServiceLocationMode::Alwayson => ServiceLocationMode::AlwaysOn, - } - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub struct Location { - pub id: I, - pub instance_id: Id, - // Native ID of network from Defguard - pub network_id: Id, - pub name: String, - pub address: String, - pub pubkey: String, // Remote - pub endpoint: String, - pub allowed_ips: String, - pub dns: Option, - pub route_all_traffic: bool, - pub keepalive_interval: i64, - pub location_mfa_mode: LocationMfaMode, - pub service_location_mode: ServiceLocationMode, -} - -impl fmt::Display for Location { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}(ID: {})", self.name, self.id) - } -} - -impl fmt::Display for Location { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.name) - } -} - -impl Location { - /// Ignores service locations - #[cfg(any(windows, target_os = "macos"))] - pub(crate) async fn all<'e, E>( - executor: E, - include_service_locations: bool, - ) -> Result, SqlxError> - where - E: SqliteExecutor<'e>, - { - let max_service_location_mode = - Self::get_service_location_mode_filter(include_service_locations); - query_as!( - Self, - "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id,\ - route_all_traffic, keepalive_interval, \ - location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\" \ - FROM location WHERE service_location_mode <= $1 \ - ORDER BY name ASC;", - max_service_location_mode - ) - .fetch_all(executor) - .await - } - - pub(crate) async fn save<'e, E>(&mut self, executor: E) -> Result<(), SqlxError> - where - E: SqliteExecutor<'e>, - { - // Update the existing record when there is an ID - query!( - "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, \ - endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, \ - keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12 WHERE id = $13", - self.instance_id, - self.name, - self.address, - self.pubkey, - self.endpoint, - self.allowed_ips, - self.dns, - self.network_id, - self.route_all_traffic, - self.keepalive_interval, - self.location_mfa_mode, - self.service_location_mode, - self.id, - ) - .execute(executor) - .await?; - - Ok(()) - } - - pub(crate) async fn find_by_id<'e, E>( - executor: E, - location_id: Id, - ) -> Result, SqlxError> - where - E: SqliteExecutor<'e>, - { - query_as!( - Self, - "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, \ - network_id, route_all_traffic, keepalive_interval, \ - location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\" \ - FROM location WHERE id = $1", - location_id - ) - .fetch_optional(executor) - .await - } - - pub(crate) async fn find_by_instance_id<'e, E>( - executor: E, - instance_id: Id, - include_service_locations: bool, - ) -> Result, SqlxError> - where - E: SqliteExecutor<'e>, - { - let max_service_location_mode = - Self::get_service_location_mode_filter(include_service_locations); - query_as!( - Self, - "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, \ - network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\" \ - FROM location WHERE instance_id = $1 AND service_location_mode <= $2 \ - ORDER BY name ASC", - instance_id, - max_service_location_mode - ) - .fetch_all(executor) - .await - } - - pub(crate) async fn find_by_public_key<'e, E>( - executor: E, - pubkey: &str, - ) -> Result - where - E: SqliteExecutor<'e>, - { - query_as!( - Self, - "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, \ - network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\" \ - FROM location WHERE pubkey = $1;", - pubkey - ) - .fetch_one(executor) - .await - } - - pub(crate) async fn delete<'e, E>(&self, executor: E) -> Result<(), SqlxError> - where - E: SqliteExecutor<'e>, - { - query!("DELETE FROM location WHERE id = $1;", self.id) - .execute(executor) - .await?; - Ok(()) - } - - /// Disables all traffic for locations related to the given instance - pub(crate) async fn disable_all_traffic_for_all<'e, E>( - executor: E, - instance_id: Id, - ) -> Result<(), Error> - where - E: SqliteExecutor<'e>, - { - query!( - "UPDATE location SET route_all_traffic = 0 WHERE instance_id = $1;", - instance_id - ) - .execute(executor) - .await?; - Ok(()) - } - - pub(crate) fn mfa_enabled(&self) -> bool { - match self.location_mfa_mode { - LocationMfaMode::Disabled => false, - LocationMfaMode::Internal | LocationMfaMode::External => true, - } - } - - #[cfg(not(target_os = "macos"))] - pub(crate) async fn interface_configuration( - &self, - pool: &DbPool, - interface_name: String, - preshared_key: Option, - mtu: Option, - ) -> Result { - use crate::database::models::instance::{ClientTrafficPolicy, Instance}; - - debug!("Looking for WireGuard keys for location {self} instance"); - let Some(keys) = WireguardKeys::find_by_instance_id(pool, self.instance_id).await? else { - error!("No keys found for instance: {}", self.instance_id); - return Err(Error::InternalError( - "No keys found for instance".to_string(), - )); - }; - debug!("WireGuard keys found for location {self} instance"); - - // prepare peer config - debug!("Decoding location {self} public key: {}.", self.pubkey); - let peer_key = Key::from_str(&self.pubkey)?; - debug!("Location {self} public key decoded: {peer_key}"); - let mut peer = Peer::new(peer_key); - - debug!("Parsing location {self} endpoint: {}", self.endpoint); - peer.set_endpoint(&self.endpoint)?; - peer.persistent_keepalive_interval = Some(25); - debug!("Parsed location {self} endpoint: {}", self.endpoint); - - if let Some(psk) = preshared_key { - debug!("Decoding location {self} preshared key."); - let peer_psk = Key::from_str(&psk)?; - info!("Location {self} preshared key decoded."); - peer.preshared_key = Some(peer_psk); - } - - debug!("Parsing location {self} allowed IPs: {}", self.allowed_ips); - let Some(instance) = Instance::find_by_id(pool, self.instance_id).await? else { - error!("Instance {} not found", self.instance_id); - return Err(Error::InternalError(format!( - "Instance {} not found", - self.instance_id - ))); - }; - let route_all_traffic = match instance.client_traffic_policy { - ClientTrafficPolicy::ForceAllTraffic => true, - ClientTrafficPolicy::DisableAllTraffic => false, - ClientTrafficPolicy::None => self.route_all_traffic, - }; - let allowed_ips = if route_all_traffic { - debug!("Using all traffic routing for location {self}"); - vec![DEFAULT_ROUTE_IPV4.into(), DEFAULT_ROUTE_IPV6.into()] - } else { - debug!( - "Using predefined location {self} traffic: {}", - self.allowed_ips - ); - self.allowed_ips.split(',').map(str::to_string).collect() - }; - for allowed_ip in &allowed_ips { - match IpAddrMask::from_str(allowed_ip) { - Ok(addr) => { - peer.allowed_ips.push(addr); - } - Err(err) => { - // Handle the error from IpAddrMask::from_str, if needed - error!( - "Error parsing IP address {allowed_ip} while setting up interface for \ - location {self}, error details: {err}" - ); - } - } - } - debug!( - "Parsed allowed IPs for location {self}: {:?}", - peer.allowed_ips - ); - - let addresses = self - .address - .split(',') - .map(str::trim) - .map(IpAddrMask::from_str) - .collect::>() - .map_err(|err| { - let msg = format!("Failed to parse IP addresses '{}': {err}", self.address); - error!("{msg}"); - Error::InternalError(msg) - })?; - let interface_config = InterfaceConfiguration { - name: interface_name, - prvkey: keys.prvkey, - addresses, - port: 0, - peers: vec![peer], - mtu, - fwmark: None, // TODO: add - }; - - Ok(interface_config) - } - - /// Returns a filter value that can be used in SQL queries like `service_location_mode <= ?` when querying locations - /// to exclude (<= 1) or include service locations (all service locations modes). - fn get_service_location_mode_filter(include_service_locations: bool) -> i32 { - if include_service_locations { - i32::MAX - } else { - ServiceLocationMode::Disabled as i32 - } - } -} - -impl Location { - pub(crate) async fn save<'e, E>(self, executor: E) -> Result, SqlxError> - where - E: SqliteExecutor<'e>, - { - // Insert a new record when there is no ID - let id = query_scalar!( - "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, \ - dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, service_location_mode) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \ - RETURNING id \"id!\"", - self.instance_id, - self.name, - self.address, - self.pubkey, - self.endpoint, - self.allowed_ips, - self.dns, - self.network_id, - self.route_all_traffic, - self.keepalive_interval, - self.location_mfa_mode, - self.service_location_mode, - ) - .fetch_one(executor) - .await?; - - Ok(Location:: { - id, - instance_id: self.instance_id, - name: self.name, - address: self.address, - pubkey: self.pubkey, - endpoint: self.endpoint, - allowed_ips: self.allowed_ips, - dns: self.dns, - network_id: self.network_id, - route_all_traffic: self.route_all_traffic, - keepalive_interval: self.keepalive_interval, - location_mfa_mode: self.location_mfa_mode, - service_location_mode: self.service_location_mode, - }) - } -} - -impl Location { - pub fn is_service_location(&self) -> bool { - self.service_location_mode != ServiceLocationMode::Disabled - && self.location_mfa_mode == LocationMfaMode::Disabled - } -} - -impl From> for Location { - fn from(location: Location) -> Self { - Self { - id: NoId, - instance_id: location.instance_id, - network_id: location.network_id, - name: location.name, - address: location.address, - pubkey: location.pubkey, - endpoint: location.endpoint, - allowed_ips: location.allowed_ips, - dns: location.dns, - route_all_traffic: location.route_all_traffic, - keepalive_interval: location.keepalive_interval, - location_mfa_mode: location.location_mfa_mode, - service_location_mode: location.service_location_mode, - } - } -} diff --git a/src-tauri/src/database/models/location_stats.rs b/src-tauri/src/database/models/location_stats.rs deleted file mode 100644 index 7966cdde0..000000000 --- a/src-tauri/src/database/models/location_stats.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::time::SystemTime; - -use chrono::{NaiveDateTime, Utc}; -use defguard_wireguard_rs::peer::Peer; -use serde::{Deserialize, Serialize}; -use sqlx::{query, query_as, query_scalar, SqliteExecutor}; - -use super::{location::Location, Id, NoId, PURGE_DURATION}; -use crate::{commands::DateTimeAggregation, error::Error, CommonLocationStats, ConnectionType}; - -#[derive(Debug, Serialize, Deserialize)] -pub struct LocationStats { - id: I, - pub(crate) location_id: Id, - upload: i64, - download: i64, - pub(crate) last_handshake: i64, - pub(crate) collected_at: NaiveDateTime, - listen_port: u32, - pub(crate) persistent_keepalive_interval: Option, -} - -impl From> for CommonLocationStats { - fn from(location_stats: LocationStats) -> Self { - CommonLocationStats { - id: location_stats.id, - location_id: location_stats.location_id, - upload: location_stats.upload, - download: location_stats.download, - last_handshake: location_stats.last_handshake, - collected_at: location_stats.collected_at, - listen_port: location_stats.listen_port, - persistent_keepalive_interval: location_stats.persistent_keepalive_interval, - connection_type: ConnectionType::Location, - } - } -} - -pub async fn peer_to_location_stats<'e, E>( - peer: &Peer, - listen_port: u32, - executor: E, -) -> Result, Error> -where - E: SqliteExecutor<'e>, -{ - let location = Location::find_by_public_key(executor, &peer.public_key.to_string()).await?; - Ok(LocationStats::new( - location.id, - peer.tx_bytes.cast_signed(), - peer.rx_bytes.cast_signed(), - peer.last_handshake.map_or(0, |ts| { - ts.duration_since(SystemTime::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs().cast_signed()) - }), - listen_port, - peer.persistent_keepalive_interval, - )) -} - -impl LocationStats { - #[cfg(not(target_os = "macos"))] - pub(crate) async fn get_name<'e, E>(&self, executor: E) -> Result - where - E: SqliteExecutor<'e>, - { - query_scalar!("SELECT name FROM location WHERE id = $1", self.location_id) - .fetch_one(executor) - .await - } -} - -impl LocationStats { - #[must_use] - pub(crate) fn new( - location_id: Id, - upload: i64, - download: i64, - last_handshake: i64, - listen_port: u32, - persistent_keepalive_interval: Option, - ) -> Self { - LocationStats { - id: NoId, - location_id, - upload, - download, - last_handshake, - collected_at: Utc::now().naive_utc(), - listen_port, - persistent_keepalive_interval, - } - } - - pub(crate) async fn save<'e, E>(self, executor: E) -> Result, Error> - where - E: SqliteExecutor<'e>, - { - let id = query_scalar!( - "INSERT INTO location_stats (location_id, upload, download, last_handshake, \ - collected_at, listen_port, persistent_keepalive_interval) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) \ - RETURNING id \"id!\"", - self.location_id, - self.upload, - self.download, - self.last_handshake, - self.collected_at, - self.listen_port, - self.persistent_keepalive_interval, - ) - .fetch_one(executor) - .await?; - - Ok(LocationStats:: { - id, - location_id: self.location_id, - upload: self.upload, - download: self.download, - last_handshake: self.last_handshake, - collected_at: self.collected_at, - listen_port: self.listen_port, - persistent_keepalive_interval: self.persistent_keepalive_interval, - }) - } -} - -impl LocationStats { - pub(crate) async fn all_by_location_id<'e, E>( - executor: E, - location_id: Id, - from: &NaiveDateTime, - aggregation: &DateTimeAggregation, - limit: Option, - ) -> Result, Error> - where - E: SqliteExecutor<'e>, - { - let aggregation = aggregation.fstring(); - // SQLite: If the LIMIT expression evaluates to a negative value, - // then there is no upper bound on the number of rows returned - let query_limit = limit.unwrap_or(-1); - let stats = query_as!( - LocationStats, - "WITH cte AS (\ - SELECT id, location_id, \ - COALESCE(upload - LAG(upload) OVER (PARTITION BY location_id ORDER BY collected_at), 0) upload, \ - COALESCE(download - LAG(download) OVER (PARTITION BY location_id ORDER BY collected_at), 0) download, \ - last_handshake, strftime($1, collected_at) collected_at, listen_port, persistent_keepalive_interval \ - FROM location_stats ORDER BY collected_at LIMIT -1 OFFSET 1) \ - SELECT id, location_id, \ - SUM(MAX(upload, 0)) \"upload!: i64\", \ - SUM(MAX(download, 0)) \"download!: i64\", \ - last_handshake, \ - collected_at \"collected_at!: NaiveDateTime\", \ - listen_port \"listen_port!: u32\", \ - persistent_keepalive_interval \"persistent_keepalive_interval?: u16\" \ - FROM cte WHERE location_id = $2 AND collected_at >= $3 \ - GROUP BY collected_at ORDER BY collected_at LIMIT $4", - aggregation, - location_id, - from, - query_limit - ) - .fetch_all(executor) - .await?; - Ok(stats) - } - - pub(crate) async fn latest_by_download_change<'e, E>( - executor: E, - location_id: Id, - ) -> Result, Error> - where - E: SqliteExecutor<'e>, - { - let res = query_as!( - LocationStats::, - "WITH prev_download AS ( - SELECT download - FROM location_stats - WHERE location_id = $1 - ORDER BY collected_at DESC - LIMIT 1 OFFSET 1 - ) - SELECT ls.id \"id!: i64\", - ls.location_id, - ls.upload \"upload!: i64\", - ls.download \"download!: i64\", - ls.last_handshake, - ls.collected_at \"collected_at!: NaiveDateTime\", - ls.listen_port \"listen_port!: u32\", - ls.persistent_keepalive_interval \"persistent_keepalive_interval?: u16\" - FROM location_stats ls - LEFT JOIN prev_download pd - WHERE ls.location_id = $1 - AND (pd.download IS NULL OR ls.download != pd.download) - ORDER BY ls.collected_at DESC - LIMIT 1", - location_id - ) - .fetch_optional(executor) - .await?; - Ok(res) - } - - /// Purge old statistics. - pub async fn purge<'e, E>(executor: E) -> Result<(), Error> - where - E: SqliteExecutor<'e>, - { - debug!("Purging location statistics."); - - let past = (Utc::now() - PURGE_DURATION).naive_utc(); - query!("DELETE FROM location_stats WHERE collected_at < $1", past) - .execute(executor) - .await?; - - Ok(()) - } -} diff --git a/src-tauri/src/database/models/mod.rs b/src-tauri/src/database/models/mod.rs deleted file mode 100644 index ca953e3e5..000000000 --- a/src-tauri/src/database/models/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -use serde::{Deserialize, Serialize}; - -pub mod connection; -pub mod instance; -pub mod location; -pub mod location_stats; -pub mod tunnel; -pub mod wireguard_keys; - -// Typestate structs to make working with optional IDs easier -pub type Id = i64; -#[derive(Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -pub struct NoId; - -const PURGE_DURATION: chrono::Duration = chrono::Duration::days(30); diff --git a/src-tauri/src/database/models/tunnel.rs b/src-tauri/src/database/models/tunnel.rs deleted file mode 100644 index 5e92c9b52..000000000 --- a/src-tauri/src/database/models/tunnel.rs +++ /dev/null @@ -1,683 +0,0 @@ -use std::{fmt, time::SystemTime}; - -use chrono::{NaiveDateTime, Utc}; -use defguard_wireguard_rs::peer::Peer; -use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, NoneAsEmptyString}; -use sqlx::{query, query_as, query_scalar, Error as SqlxError, SqliteExecutor}; - -use super::{connection::ActiveConnection, Id, NoId, PURGE_DURATION}; -use crate::{ - commands::DateTimeAggregation, error::Error, CommonConnection, CommonConnectionInfo, - CommonLocationStats, ConnectionType, -}; - -#[serde_as] -#[derive(Serialize, Deserialize)] -pub struct Tunnel { - pub id: I, - pub name: String, - // encryption keys - pub pubkey: String, // Remote - pub prvkey: String, // Local - // server config - pub address: String, - pub server_pubkey: String, - #[serde_as(as = "NoneAsEmptyString")] - pub preshared_key: Option, - #[serde_as(as = "NoneAsEmptyString")] - pub allowed_ips: Option, - // server_address:port - pub endpoint: String, - #[serde_as(as = "NoneAsEmptyString")] - pub dns: Option, - pub persistent_keep_alive: i64, - pub route_all_traffic: bool, - // additional commands - #[serde_as(as = "NoneAsEmptyString")] - pub pre_up: Option, - #[serde_as(as = "NoneAsEmptyString")] - pub post_up: Option, - #[serde_as(as = "NoneAsEmptyString")] - pub pre_down: Option, - #[serde_as(as = "NoneAsEmptyString")] - pub post_down: Option, -} - -impl fmt::Display for Tunnel { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}(ID: {})", self.name, self.id) - } -} - -impl fmt::Display for Tunnel { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.name) - } -} - -impl Tunnel { - pub(crate) async fn save<'e, E>(&mut self, executor: E) -> Result<(), SqlxError> - where - E: SqliteExecutor<'e>, - { - query!( - "UPDATE tunnel SET name = $1, pubkey = $2, prvkey = $3, address = $4, \ - server_pubkey = $5, preshared_key = $6, allowed_ips = $7, endpoint = $8, dns = $9, \ - persistent_keep_alive = $10, route_all_traffic = $11, pre_up = $12, post_up = $13, \ - pre_down = $14, post_down = $15 \ - WHERE id = $16;", - self.name, - self.pubkey, - self.prvkey, - self.address, - self.server_pubkey, - self.preshared_key, - self.allowed_ips, - self.endpoint, - self.dns, - self.persistent_keep_alive, - self.route_all_traffic, - self.pre_up, - self.post_up, - self.pre_down, - self.post_down, - self.id, - ) - .execute(executor) - .await?; - - Ok(()) - } - - pub(crate) async fn delete<'e, E>(&self, executor: E) -> Result<(), Error> - where - E: SqliteExecutor<'e>, - { - Tunnel::delete_by_id(executor, self.id).await?; - Ok(()) - } - - pub(crate) async fn find_by_id<'e, E>( - executor: E, - tunnel_id: Id, - ) -> Result, SqlxError> - where - E: SqliteExecutor<'e>, - { - query_as!( - Self, - "SELECT id \"id: _\", name, pubkey, prvkey, address, server_pubkey, preshared_key, \ - allowed_ips, endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, \ - post_up, pre_down, post_down FROM tunnel WHERE id = $1;", - tunnel_id - ) - .fetch_optional(executor) - .await - } - - pub(crate) async fn all<'e, E>(executor: E) -> Result, SqlxError> - where - E: SqliteExecutor<'e>, - { - let tunnels = query_as!( - Self, - "SELECT id \"id: _\", name, pubkey, prvkey, address, server_pubkey, preshared_key, \ - allowed_ips, endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, \ - post_up, pre_down, post_down \ - FROM tunnel ORDER BY name ASC;" - ) - .fetch_all(executor) - .await?; - Ok(tunnels) - } - - pub(crate) async fn find_by_server_public_key<'e, E>( - executor: E, - pubkey: &str, - ) -> Result - where - E: SqliteExecutor<'e>, - { - query_as!( - Self, - "SELECT id \"id: _\", name, pubkey, prvkey, address, server_pubkey, preshared_key, \ - allowed_ips, endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, \ - post_up, pre_down, post_down \ - FROM tunnel WHERE server_pubkey = $1;", - pubkey - ) - .fetch_one(executor) - .await - } - - pub(crate) async fn delete_by_id<'e, E>(executor: E, id: Id) -> Result<(), Error> - where - E: SqliteExecutor<'e>, - { - // delete instance - query!("DELETE FROM tunnel WHERE id = $1", id) - .execute(executor) - .await?; - Ok(()) - } -} - -impl Tunnel { - #[allow(clippy::too_many_arguments)] - #[must_use] - pub(crate) fn new( - name: String, - pubkey: String, - prvkey: String, - address: String, - server_pubkey: String, - preshared_key: Option, - allowed_ips: Option, - endpoint: String, - dns: Option, - persistent_keep_alive: i64, - route_all_traffic: bool, - pre_up: Option, - post_up: Option, - pre_down: Option, - post_down: Option, - ) -> Self { - Tunnel { - id: NoId, - name, - pubkey, - prvkey, - address, - server_pubkey, - preshared_key, - allowed_ips, - endpoint, - dns, - persistent_keep_alive, - route_all_traffic, - pre_up, - post_up, - pre_down, - post_down, - } - } - - pub(crate) async fn save<'e, E>(self, executor: E) -> Result, SqlxError> - where - E: SqliteExecutor<'e>, - { - // Insert a new record when there is no ID - let result = query!( - "INSERT INTO tunnel (name, pubkey, prvkey, address, server_pubkey, allowed_ips, preshared_key, \ - endpoint, dns, persistent_keep_alive, route_all_traffic, pre_up, post_up, pre_down, post_down) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id;", - self.name, - self.pubkey, - self.prvkey, - self.address, - self.server_pubkey, - self.allowed_ips, - self.preshared_key, - self.endpoint, - self.dns, - self.persistent_keep_alive, - self.route_all_traffic, - self.pre_up, - self.post_up, - self.pre_down, - self.post_down, - ) - .fetch_one(executor) - .await?; - - Ok(Tunnel:: { - id: result.id, - name: self.name, - pubkey: self.pubkey, - prvkey: self.prvkey, - address: self.address, - server_pubkey: self.server_pubkey, - allowed_ips: self.allowed_ips, - preshared_key: self.preshared_key, - endpoint: self.endpoint, - dns: self.dns, - persistent_keep_alive: self.persistent_keep_alive, - route_all_traffic: self.route_all_traffic, - pre_up: self.pre_up, - post_up: self.post_up, - pre_down: self.pre_down, - post_down: self.post_down, - }) - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct TunnelStats { - id: I, - pub(crate) tunnel_id: Id, - upload: i64, - download: i64, - pub(crate) last_handshake: i64, - pub(crate) collected_at: NaiveDateTime, - listen_port: u32, - pub(crate) persistent_keepalive_interval: u16, -} - -impl TunnelStats { - pub async fn get_name<'e, E>(&self, executor: E) -> Result - where - E: SqliteExecutor<'e>, - { - query_scalar!("SELECT name FROM tunnel WHERE id = $1;", self.tunnel_id) - .fetch_one(executor) - .await - } -} - -impl TunnelStats { - #[must_use] - pub fn new( - tunnel_id: Id, - upload: i64, - download: i64, - last_handshake: i64, - collected_at: NaiveDateTime, - listen_port: u32, - persistent_keepalive_interval: u16, - ) -> Self { - TunnelStats { - id: NoId, - tunnel_id, - upload, - download, - last_handshake, - collected_at, - listen_port, - persistent_keepalive_interval, - } - } - - pub async fn save<'e, E>(self, executor: E) -> Result, SqlxError> - where - E: SqliteExecutor<'e>, - { - let id = query_scalar!( - "INSERT INTO tunnel_stats (tunnel_id, upload, download, last_handshake, collected_at, \ - listen_port, persistent_keepalive_interval) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id \"id!\"", - self.tunnel_id, - self.upload, - self.download, - self.last_handshake, - self.collected_at, - self.listen_port, - self.persistent_keepalive_interval, - ) - .fetch_one(executor) - .await?; - - Ok(TunnelStats:: { - id, - tunnel_id: self.tunnel_id, - upload: self.upload, - download: self.download, - last_handshake: self.last_handshake, - collected_at: self.collected_at, - listen_port: self.listen_port, - persistent_keepalive_interval: self.persistent_keepalive_interval, - }) - } -} - -impl TunnelStats { - pub(crate) async fn all_by_tunnel_id<'e, E>( - executor: E, - tunnel_id: Id, - from: &NaiveDateTime, - aggregation: &DateTimeAggregation, - ) -> Result, SqlxError> - where - E: SqliteExecutor<'e>, - { - let aggregation = aggregation.fstring(); - let stats = query_as!( - TunnelStats, - "WITH cte AS (\ - SELECT id, tunnel_id, \ - COALESCE(upload - LAG(upload) OVER (PARTITION BY tunnel_id ORDER BY collected_at), 0) upload, \ - COALESCE(download - LAG(download) OVER (PARTITION BY tunnel_id ORDER BY collected_at), 0) download, \ - last_handshake, strftime($1, collected_at) collected_at, listen_port, persistent_keepalive_interval \ - FROM tunnel_stats ORDER BY collected_at LIMIT -1 OFFSET 1) \ - SELECT id, tunnel_id, \ - SUM(MAX(upload, 0)) \"upload!: i64\", \ - SUM(MAX(download, 0)) \"download!: i64\", \ - last_handshake, collected_at \"collected_at!: NaiveDateTime\", \ - listen_port \"listen_port!: u32\", \ - persistent_keepalive_interval \"persistent_keepalive_interval!: u16\" \ - FROM cte WHERE tunnel_id = $2 AND collected_at >= $3 \ - GROUP BY collected_at ORDER BY collected_at", - aggregation, - tunnel_id, - from - ) - .fetch_all(executor) - .await?; - Ok(stats) - } - - pub(crate) async fn latest_by_download_change<'e, E>( - executor: E, - tunnel_id: Id, - ) -> Result, Error> - where - E: SqliteExecutor<'e>, - { - let res = query_as!( - TunnelStats::, - "WITH prev_download AS ( - SELECT download - FROM tunnel_stats - WHERE tunnel_id = $1 - ORDER BY collected_at DESC - LIMIT 1 OFFSET 1 - ) - SELECT ts.id \"id!: i64\", - ts.tunnel_id, - ts.upload \"upload!: i64\", - ts.download \"download!: i64\", - ts.last_handshake, - ts.collected_at \"collected_at!: NaiveDateTime\", - ts.listen_port \"listen_port!: u32\", - ts.persistent_keepalive_interval \"persistent_keepalive_interval!: u16\" - FROM tunnel_stats ts - LEFT JOIN prev_download pd - WHERE ts.tunnel_id = $1 - AND (pd.download IS NULL OR ts.download != pd.download) - ORDER BY ts.collected_at DESC - LIMIT 1", - tunnel_id - ) - .fetch_optional(executor) - .await?; - Ok(res) - } - - /// Purge old statistics. - pub async fn purge<'e, E>(executor: E) -> Result<(), Error> - where - E: SqliteExecutor<'e>, - { - debug!("Purging tunnel statistics."); - - let past = (Utc::now() - PURGE_DURATION).naive_utc(); - query!("DELETE FROM tunnel_stats WHERE collected_at < $1", past) - .execute(executor) - .await?; - - Ok(()) - } -} - -pub async fn peer_to_tunnel_stats<'e, E>( - peer: &Peer, - listen_port: u32, - executor: E, -) -> Result, Error> -where - E: SqliteExecutor<'e>, -{ - let tunnel = Tunnel::find_by_server_public_key(executor, &peer.public_key.to_string()).await?; - Ok(TunnelStats { - id: NoId, - tunnel_id: tunnel.id, - upload: peer.tx_bytes.cast_signed(), - download: peer.rx_bytes.cast_signed(), - last_handshake: peer.last_handshake.map_or(0, |ts| { - ts.duration_since(SystemTime::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs().cast_signed()) - }), - collected_at: Utc::now().naive_utc(), - listen_port, - persistent_keepalive_interval: peer.persistent_keepalive_interval.unwrap_or_default(), - }) -} - -#[derive(Debug, Serialize, Clone)] -pub struct TunnelConnection { - pub id: I, - pub tunnel_id: Id, - pub start: NaiveDateTime, - pub end: NaiveDateTime, -} - -impl From for CommonConnectionInfo { - fn from(val: TunnelConnectionInfo) -> Self { - CommonConnectionInfo { - id: val.id, - location_id: val.tunnel_id, - start: val.start, - end: val.end, - upload: val.upload, - download: val.download, - } - } -} - -impl TunnelConnection { - pub async fn all_by_tunnel_id<'e, E>( - executor: E, - tunnel_id: Id, - ) -> Result>, Error> - where - E: SqliteExecutor<'e>, - { - let connections = query_as!( - TunnelConnection, - "SELECT id, tunnel_id, start, end \ - FROM tunnel_connection WHERE tunnel_id = $1", - tunnel_id - ) - .fetch_all(executor) - .await?; - Ok(connections) - } - - pub async fn latest_by_tunnel_id<'e, E>( - executor: E, - tunnel_id: Id, - ) -> Result>, Error> - where - E: SqliteExecutor<'e>, - { - let connection = query_as!( - TunnelConnection, - "SELECT id, tunnel_id, start, end \ - FROM tunnel_connection WHERE tunnel_id = $1 \ - ORDER BY end DESC LIMIT 1", - tunnel_id - ) - .fetch_optional(executor) - .await?; - Ok(connection) - } -} - -impl TunnelConnection { - pub async fn save<'e, E>(self, executor: E) -> Result, Error> - where - E: SqliteExecutor<'e>, - { - let id = query_scalar!( - "INSERT INTO tunnel_connection (tunnel_id, start, end) \ - VALUES ($1, $2, $3) RETURNING id \"id!\"", - self.tunnel_id, - self.start, - self.end, - ) - .fetch_one(executor) - .await?; - - Ok(TunnelConnection:: { - id, - tunnel_id: self.tunnel_id, - start: self.start, - end: self.end, - }) - } -} - -/// Historical connection -#[derive(Debug, Serialize)] -pub struct TunnelConnectionInfo { - pub id: Id, - pub tunnel_id: Id, - pub start: NaiveDateTime, - pub end: NaiveDateTime, - pub upload: Option, - pub download: Option, -} - -impl TunnelConnectionInfo { - pub async fn all_by_tunnel_id<'e, E>(executor: E, tunnel_id: Id) -> Result, Error> - where - E: SqliteExecutor<'e>, - { - // Because we store interface information for given timestamp, - // select last upload and download before connection ended. - // FIXME: Optimize query - let connections = query_as!( - TunnelConnectionInfo, - "SELECT c.id, c.tunnel_id, c.start, c.end, \ - COALESCE((\ - SELECT ls.upload \ - FROM tunnel_stats ls \ - WHERE ls.tunnel_id = c.tunnel_id \ - AND ls.collected_at BETWEEN c.start AND c.end \ - ORDER BY ls.collected_at DESC LIMIT 1 \ - ), 0) \"upload: _\", \ - COALESCE((\ - SELECT ls.download \ - FROM tunnel_stats ls \ - WHERE ls.tunnel_id = c.tunnel_id \ - AND ls.collected_at BETWEEN c.start AND c.end \ - ORDER BY ls.collected_at DESC LIMIT 1 \ - ), 0) \"download: _\" \ - FROM tunnel_connection c WHERE tunnel_id = $1 \ - ORDER BY start DESC", - tunnel_id - ) - .fetch_all(executor) - .await?; - - Ok(connections) - } -} - -impl From<&ActiveConnection> for TunnelConnection { - fn from(active_connection: &ActiveConnection) -> Self { - Self { - id: NoId, - tunnel_id: active_connection.location_id, - start: active_connection.start, - end: Utc::now().naive_utc(), - } - } -} - -impl From> for CommonConnection { - fn from(tunnel_connection: TunnelConnection) -> Self { - Self { - id: tunnel_connection.id, - location_id: tunnel_connection.tunnel_id, // Assuming you want to map tunnel_id to location_id - start: tunnel_connection.start, - end: tunnel_connection.end, - connection_type: ConnectionType::Tunnel, // You need to set the connection_type appropriately based on your logic, - } - } -} - -impl From> for CommonLocationStats { - fn from(tunnel_stats: TunnelStats) -> Self { - Self { - id: tunnel_stats.id, - location_id: tunnel_stats.tunnel_id, - upload: tunnel_stats.upload, - download: tunnel_stats.download, - last_handshake: tunnel_stats.last_handshake, - collected_at: tunnel_stats.collected_at, - listen_port: tunnel_stats.listen_port, - persistent_keepalive_interval: Some(tunnel_stats.persistent_keepalive_interval), // Set the appropriate value - connection_type: ConnectionType::Tunnel, - } - } -} - -#[cfg(test)] -mod tests { - use chrono::Duration; - use sqlx::SqlitePool; - - use super::*; - - impl TunnelStats { - async fn count<'e, E>(executor: E) -> Result - where - E: SqliteExecutor<'e>, - { - let count = query_scalar!("SELECT count(*) FROM tunnel_stats") - .fetch_one(executor) - .await?; - Ok(count) - } - } - - #[sqlx::test] - async fn purge_stats(pool: SqlitePool) { - let tunnel = Tunnel::new( - "test".into(), - String::new(), - String::new(), - String::new(), - String::new(), - None, - None, - String::new(), - None, - 0, - false, - None, - None, - None, - None, - ) - .save(&pool) - .await - .unwrap(); - - let delta = Duration::days(60); - assert!(delta > PURGE_DURATION); - - let now = Utc::now(); - TunnelStats::new(tunnel.id, 0, 0, 0, now.naive_utc(), 0, 0) - .save(&pool) - .await - .unwrap(); - TunnelStats::new(tunnel.id, 0, 0, 0, (now - delta).naive_utc(), 0, 0) - .save(&pool) - .await - .unwrap(); - TunnelStats::new(tunnel.id, 0, 0, 0, (now + delta).naive_utc(), 0, 0) - .save(&pool) - .await - .unwrap(); - - let count = TunnelStats::::count(&pool).await.unwrap(); - assert_eq!(count, 3); - - TunnelStats::purge(&pool).await.unwrap(); - - let count = TunnelStats::::count(&pool).await.unwrap(); - assert_eq!(count, 2); - } -} diff --git a/src-tauri/src/enterprise/mod.rs b/src-tauri/src/enterprise/mod.rs deleted file mode 100644 index 8e1f8e8ad..000000000 --- a/src-tauri/src/enterprise/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod models; -pub mod periodic; -pub mod provisioning; -pub mod service_locations; diff --git a/src-tauri/src/enterprise/models/instance.rs b/src-tauri/src/enterprise/models/instance.rs deleted file mode 100644 index b0a034379..000000000 --- a/src-tauri/src/enterprise/models/instance.rs +++ /dev/null @@ -1,28 +0,0 @@ -use sqlx::SqliteExecutor; - -use crate::{ - database::models::{ - instance::{ClientTrafficPolicy, Instance}, - Id, - }, - error::Error, -}; - -impl Instance { - pub async fn disable_enterprise_features<'e, E>(&mut self, executor: E) -> Result<(), Error> - where - E: SqliteExecutor<'e>, - { - debug!( - "Disabling enterprise features for instance {}({})", - self.name, self.id - ); - self.client_traffic_policy = ClientTrafficPolicy::None; - self.save(executor).await?; - debug!( - "Disabled enterprise features for instance {}({})", - self.name, self.id - ); - Ok(()) - } -} diff --git a/src-tauri/src/enterprise/models/mod.rs b/src-tauri/src/enterprise/models/mod.rs deleted file mode 100644 index 1d5ea9909..000000000 --- a/src-tauri/src/enterprise/models/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod instance; diff --git a/src-tauri/src/enterprise/periodic/config.rs b/src-tauri/src/enterprise/periodic/config.rs deleted file mode 100644 index 6153c8222..000000000 --- a/src-tauri/src/enterprise/periodic/config.rs +++ /dev/null @@ -1,434 +0,0 @@ -use std::{ - cmp::Ordering, - collections::HashSet, - str::FromStr, - sync::{LazyLock, Mutex}, - time::Duration, -}; - -use reqwest::{Client, StatusCode}; -use serde::Serialize; -use sqlx::{Sqlite, Transaction}; -use tauri::{AppHandle, Emitter, Url}; -use tokio::time::sleep; - -use crate::{ - active_connections::active_connections, - commands::{do_update_instance, locations_changed}, - database::{ - models::{instance::Instance, Id}, - DB_POOL, - }, - error::Error, - events::EventKey, - proto::{DeviceConfigResponse, InstanceInfoRequest, InstanceInfoResponse}, - utils::construct_platform_header, - CLIENT_PLATFORM_HEADER, CLIENT_VERSION_HEADER, MIN_CORE_VERSION, MIN_PROXY_VERSION, - PKG_VERSION, -}; - -const INTERVAL_SECONDS: Duration = Duration::from_secs(30); -const HTTP_REQ_TIMEOUT: Duration = Duration::from_secs(5); -static POLLING_ENDPOINT: &str = "/api/v1/poll"; - -/// Periodically retrieves and updates configuration for all [`Instance`]s. -/// Updates are only performed if no connections are established to the [`Instance`], -/// otherwise event is emmited and UI message is displayed. -pub async fn poll_config(handle: AppHandle) { - debug!("Starting the configuration polling loop."); - // Polling starts sooner than app's frontend may load in dev builds, causing events (toasts) to be lost, - // you may want to wait here before starting if you want to debug it. - loop { - let Ok(mut transaction) = DB_POOL.begin().await else { - error!( - "Failed to begin database transaction for config polling, retrying in {}s", - INTERVAL_SECONDS.as_secs() - ); - sleep(INTERVAL_SECONDS).await; - continue; - }; - let Ok(mut instances) = Instance::all_with_token(&mut *transaction).await else { - error!( - "Failed to retireve instances for config polling, retrying in {}s", - INTERVAL_SECONDS.as_secs() - ); - let _ = transaction.rollback().await; - sleep(INTERVAL_SECONDS).await; - continue; - }; - debug!( - "Found {} instances with a config polling token, proceeding with polling their \ - configuration.", - instances.len() - ); - let mut config_retrieved = 0; - for instance in &mut instances { - if instance.token.is_some() { - if let Err(err) = poll_instance(&mut transaction, instance, &handle).await { - match err { - Error::CoreNotEnterprise => { - debug!( - "Tried to contact core for instance {instance} config but it's not \ - enterprise, can't retrieve config" - ); - } - Error::NoToken => { - debug!( - "Instance {instance} has no token, can't retrieve its config from \ - the core", - ); - } - _ => { - error!( - "Failed to retrieve instance {instance} config from core: {err}" - ); - } - } - } else { - config_retrieved += 1; - debug!( - "Finished processing configuration polling request for instance {instance}" - ); - } - } - } - if let Err(err) = transaction.commit().await { - error!( - "Failed to commit config polling transaction, configuration won't be updated: \ - {err}" - ); - } - if let Err(err) = handle.emit(EventKey::InstanceUpdate.into(), ()) { - error!("Failed to emit instance update event to the frontend: {err}"); - } - if config_retrieved > 0 { - info!( - "Automatically retrieved the newest instance configuration from core for \ - {config_retrieved} instances, sleeping for {}s", - INTERVAL_SECONDS.as_secs(), - ); - debug!("Instances for which configuration was retrieved from core: {instances:?}"); - } else { - debug!( - "No configuration updates retrieved, sleeping {}s", - INTERVAL_SECONDS.as_secs(), - ); - } - sleep(INTERVAL_SECONDS).await; - } -} - -/// Retrieves configuration for given [`Instance`]. -/// Updates the instance if there aren't any active connections, otherwise displays UI message. -pub async fn poll_instance( - transaction: &mut Transaction<'_, Sqlite>, - instance: &mut Instance, - handle: &AppHandle, -) -> Result<(), Error> { - debug!("Getting config from core for instance {}", instance.name); - // Query proxy api - let request = build_request(instance)?; - let url = Url::from_str(&instance.proxy_url) - .and_then(|url| url.join(POLLING_ENDPOINT)) - .map_err(|_| { - Error::InternalError(format!( - "Can't build polling url: {}/{POLLING_ENDPOINT}", - instance.proxy_url - )) - })?; - let response = Client::new() - .post(url) - .json(&request) - .header(CLIENT_VERSION_HEADER, PKG_VERSION) - .header(CLIENT_PLATFORM_HEADER, construct_platform_header()) - .timeout(HTTP_REQ_TIMEOUT) - .send() - .await; - let response = response.map_err(|err| { - Error::InternalError(format!( - "HTTP request failed for instance {}({}), url: {}, {err}", - instance.name, instance.id, instance.proxy_url - )) - })?; - debug!( - "Got the following config response for instance {} from core: {response:?}", - instance.name - ); - - check_min_version(&response, instance, handle); - - // Return early if the enterprise features are disabled in the core - if response.status() == StatusCode::PAYMENT_REQUIRED { - debug!( - "Instance {}({}) has enterprise features disabled, checking if this state is reflected \ - on our end.", - instance.name, instance.id - ); - if instance.enterprise_enabled { - info!( - "Instance {}({}) has enterprise features disabled, but we have them enabled, \ - disabling.", - instance.name, instance.id - ); - instance - .disable_enterprise_features(transaction.as_mut()) - .await?; - } else { - debug!( - "Instance {}({}) has enterprise features disabled, and we have them disabled as \ - well, no action needed", - instance.name, instance.id - ); - } - return Err(Error::CoreNotEnterprise); - } - - // Parse the response - debug!( - "Parsing the config response for instance {}.", - instance.name - ); - let response: InstanceInfoResponse = response.json().await.map_err(|err| { - Error::InternalError(format!( - "Failed to parse InstanceInfoResponse for instance {}({}): {err}", - instance.name, instance.id, - )) - })?; - let device_config = response - .device_config - .as_ref() - .ok_or_else(|| Error::InternalError("Device config not present in response".to_string()))?; - debug!("Parsed the config for instance {}", instance.name); - trace!("Parsed config: {device_config:?}"); - - // Early return if config didn't change - if !config_changed(transaction, instance, device_config).await? { - debug!( - "Config for instance {}({}) didn't change", - instance.name, instance.id - ); - return Ok(()); - } - - debug!( - "Config for instance {}({}) changed", - instance.name, instance.id - ); - - // Config changed. If there are no active connections for this instance, update the database. - // Otherwise just display a message to reconnect. - // - if active_connections(instance).await?.is_empty() { - debug!( - "Updating instance {}({}) configuration: {device_config:?}", - instance.name, instance.id, - ); - do_update_instance(transaction, instance, device_config.clone()).await?; - info!( - "Updated instance {}({}) configuration based on core's response", - instance.name, instance.id - ); - } else { - debug!( - "Emitting config-changed event for instance {}({})", - instance.name, instance.id, - ); - let _ = handle.emit(EventKey::ConfigChanged.into(), &instance.name); - info!( - "Emitted config-changed event for instance {}({})", - instance.name, instance.id, - ); - } - - Ok(()) -} - -async fn config_changed( - transaction: &mut Transaction<'_, Sqlite>, - instance: &Instance, - device_config: &DeviceConfigResponse, -) -> Result { - debug!( - "Checking if config and any of the locations changed for instance {}({})", - instance.name, instance.id - ); - let locations_changed = locations_changed(transaction, instance, device_config).await?; - let info_changed = match &device_config.instance { - Some(info) => instance != info, - None => false, - }; - debug!( - "Did the locations change?: {locations_changed}. Did the instance information change?: \ - {info_changed}" - ); - Ok(locations_changed || info_changed) -} - -/// Retrieves token to build InstanceInfoRequest -fn build_request(instance: &Instance) -> Result { - let token = instance.token.as_ref().ok_or_else(|| Error::NoToken)?; - - Ok(InstanceInfoRequest { - token: (*token).clone(), - }) -} - -/// Tracks instance IDs that for which we already sent notification about version mismatches -/// to prevent duplicate notifications in the app's lifetime. -static NOTIFIED_INSTANCES: LazyLock>> = - LazyLock::new(|| Mutex::new(HashSet::new())); - -const CORE_VERSION_HEADER: &str = "defguard-core-version"; -const CORE_CONNECTED_HEADER: &str = "defguard-core-connected"; -const PROXY_VERSION_HEADER: &str = "defguard-component-version"; - -#[derive(Clone, Serialize)] -struct VersionMismatchPayload { - instance_name: String, - instance_id: Id, - core_version: String, - proxy_version: String, - core_required_version: String, - proxy_required_version: String, - core_compatible: bool, - proxy_compatible: bool, -} - -fn check_min_version(response: &reqwest::Response, instance: &Instance, handle: &AppHandle) { - let mut notified_instances = NOTIFIED_INSTANCES.lock().unwrap(); - if notified_instances.contains(&instance.id) { - debug!( - "Instance {}({}) already notified about version mismatch, skipping", - instance.name, instance.id - ); - return; - } - - let detected_core_version: String; - let detected_proxy_version: String; - let defguard_core_connected: Option = response - .headers() - .get(CORE_CONNECTED_HEADER) - .and_then(|v| { - debug!( - "Defguard core connection status header for instance {}({}): {v:?}", - instance.name, instance.id - ); - v.to_str().ok() - }) - .and_then(|s| s.parse().ok()); - - let core_compatible = if let Some(core_version) = response.headers().get(CORE_VERSION_HEADER) { - if let Ok(core_version) = core_version.to_str() { - if let Ok(core_version) = semver::Version::from_str(core_version) { - detected_core_version = core_version.to_string(); - core_version.cmp_precedence(&MIN_CORE_VERSION) != Ordering::Less - } else { - warn!( - "Core version header: invalid semver string in response for instance {}({}): \ - '{core_version}'", - instance.name, instance.id - ); - detected_core_version = core_version.to_string(); - false - } - } else { - warn!( - "Core version header: invalid string in response for instance {}({}): \ - '{core_version:?}'", - instance.name, instance.id - ); - detected_core_version = "unknown".to_string(); - false - } - } else { - warn!( - "Core version header not present in response for instance {}({})", - instance.name, instance.id - ); - detected_core_version = "unknown".to_string(); - false - }; - - let proxy_compatible = if let Some(proxy_version) = response.headers().get(PROXY_VERSION_HEADER) - { - if let Ok(proxy_version) = proxy_version.to_str() { - if let Ok(proxy_version) = semver::Version::from_str(proxy_version) { - detected_proxy_version = proxy_version.to_string(); - proxy_version.cmp_precedence(&MIN_PROXY_VERSION) != Ordering::Less - } else { - warn!( - "Proxy version header not a valid semver string in response for instance {}({}): \ - '{proxy_version}'", - instance.name, instance.id - ); - detected_proxy_version = proxy_version.to_string(); - false - } - } else { - warn!( - "Proxy version header not a valid string in response for instance {}({}): \ - '{proxy_version:?}'", - instance.name, instance.id - ); - detected_proxy_version = "unknown".to_string(); - false - } - } else { - warn!( - "Proxy version header not present in response for instance {}({})", - instance.name, instance.id - ); - detected_proxy_version = "unknown".to_string(); - false - }; - - let should_inform = match defguard_core_connected { - Some(true) => { - debug!( - "Defguard core is connected for instance {}({})", - instance.name, instance.id - ); - true - } - Some(false) => { - info!( - "Defguard core is not connected for instance {}({})", - instance.name, instance.id - ); - false - } - None => { - debug!( - "Defguard core connection status unknown for instance {}({})", - instance.name, instance.id - ); - true - } - }; - - if should_inform && (!core_compatible || !proxy_compatible) { - warn!( - "Instance {} is running incompatible versions: core {detected_core_version}, proxy \ - {detected_proxy_version}. Required versions: core >= {MIN_CORE_VERSION}, proxy >= \ - {MIN_PROXY_VERSION}", - instance.name, - ); - - let payload = VersionMismatchPayload { - instance_name: instance.name.clone(), - instance_id: instance.id, - core_version: detected_core_version, - proxy_version: detected_proxy_version, - core_required_version: MIN_CORE_VERSION.to_string(), - proxy_required_version: MIN_PROXY_VERSION.to_string(), - core_compatible, - proxy_compatible, - }; - if let Err(err) = handle.emit(EventKey::VersionMismatch.into(), payload) { - error!("Failed to emit version mismatch event to the frontend: {err}"); - } else { - notified_instances.insert(instance.id); - } - } -} diff --git a/src-tauri/src/enterprise/periodic/mod.rs b/src-tauri/src/enterprise/periodic/mod.rs deleted file mode 100644 index ef68c3694..000000000 --- a/src-tauri/src/enterprise/periodic/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod config; diff --git a/src-tauri/src/enterprise/provisioning/mod.rs b/src-tauri/src/enterprise/provisioning/mod.rs deleted file mode 100644 index 21a359314..000000000 --- a/src-tauri/src/enterprise/provisioning/mod.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::{fmt, fs, path::Path}; - -use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Manager}; - -use crate::database::{models::instance::Instance, DB_POOL}; - -const CONFIG_FILE_NAME: &str = "provisioning.json"; - -#[derive(Clone, Deserialize, Serialize)] -pub struct ProvisioningConfig { - pub enrollment_url: String, - pub enrollment_token: String, -} - -impl fmt::Debug for ProvisioningConfig { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let Self { - enrollment_url, - enrollment_token: _, - } = self; - - f.debug_struct("ProvisioningConfig") - .field("enrollment_url", enrollment_url) - .field("enrollment_token", &"***") - .finish() - } -} - -impl ProvisioningConfig { - /// Load configuration from a file at `path`. - fn load(path: &Path) -> Option { - // read content to string first to handle Windows encoding issues - let file_content = match fs::read_to_string(path) { - Ok(content) => content, - Err(err) => { - warn!( - "Failed to open provisioning configuration file at {}. Error details: \ - {err}", - path.display() - ); - return None; - } - }; - - // strip Windows BOM manually - let file_content = file_content.trim_start_matches('\u{FEFF}'); - - match serde_json::from_str::(file_content) { - Ok(config) => Some(config), - Err(err) => { - warn!( - "Failed to parse provisioning configuration file at {}. Error details: \ - {err}", - path.display() - ); - None - } - } - } -} - -#[must_use] -pub fn try_get_provisioning_config(app_data_dir: &Path) -> Option { - debug!( - "Trying to find provisioning config in {}", - app_data_dir.display() - ); - - let config_file_path = app_data_dir.join(CONFIG_FILE_NAME); - ProvisioningConfig::load(&config_file_path) -} - -/// Checks if the client has already been initialized -/// and tries to load provisioning config from file if necessary -pub async fn handle_client_initialization(app_handle: &AppHandle) -> Option { - // check if client has already been initialized - // we assume that if any instances exist the client has been initialized - match Instance::all(&*DB_POOL).await { - Ok(instances) => { - if instances.is_empty() { - debug!( - "Client has not been initialized yet. Checking if provisioning config exists" - ); - let data_dir = app_handle - .path() - .app_data_dir() - .unwrap_or_else(|_| "UNDEFINED DATA DIRECTORY".into()); - match try_get_provisioning_config(&data_dir) { - Some(config) => { - info!( - "Provisioning config found in {}: {config:?}", - data_dir.display() - ); - return Some(config); - } - None => { - debug!( - "Provisioning config not found in {}. Proceeding with normal startup.", - data_dir.display() - ); - } - } - } - } - Err(err) => { - error!("Failed to verify if the client has already been initialized: {err}"); - } - } - - None -} diff --git a/src-tauri/src/enterprise/service_locations/mod.rs b/src-tauri/src/enterprise/service_locations/mod.rs deleted file mode 100644 index f120d5522..000000000 --- a/src-tauri/src/enterprise/service_locations/mod.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::{collections::HashMap, fmt}; - -use defguard_wireguard_rs::{error::WireguardInterfaceError, WGApi}; -use serde::{Deserialize, Serialize}; - -use crate::{ - database::models::{ - location::{Location, ServiceLocationMode}, - Id, - }, - service::proto::ServiceLocation, -}; - -#[cfg(windows)] -pub mod windows; - -#[derive(Debug, thiserror::Error)] -pub enum ServiceLocationError { - #[error("Error occurred while initializing service location API: {0}")] - InitError(String), - #[error("Failed to load service location storage: {0}")] - LoadError(String), - #[error(transparent)] - Io(#[from] std::io::Error), - #[error(transparent)] - DecodeError(#[from] base64::DecodeError), - #[error(transparent)] - WireGuardError(#[from] WireguardInterfaceError), - #[error(transparent)] - AddrParseError(#[from] defguard_wireguard_rs::net::IpAddrParseError), - #[error("WireGuard interface error: {0}")] - InterfaceError(String), - #[error(transparent)] - JsonError(#[from] serde_json::Error), - #[error(transparent)] - ProtoEnumError(#[from] prost::UnknownEnumValue), - #[cfg(windows)] - #[error(transparent)] - WindowsServiceError(#[from] windows_service::Error), -} - -#[allow(dead_code)] -#[derive(Default)] -pub(crate) struct ServiceLocationManager { - // Interface name: WireGuard API instance - wgapis: HashMap, - // Instance ID: Service locations connected under that instance - connected_service_locations: HashMap>, -} - -#[allow(dead_code)] -#[derive(Serialize, Deserialize)] -pub(crate) struct ServiceLocationData { - pub service_locations: Vec, - pub instance_id: String, - pub private_key: String, -} - -#[allow(dead_code)] -pub(crate) struct SingleServiceLocationData { - pub service_location: ServiceLocation, - pub instance_id: String, - pub private_key: String, -} - -impl fmt::Debug for ServiceLocationData { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ServiceLocationData") - .field("service_locations", &self.service_locations) - .field("instance_id", &self.instance_id) - .field("private_key", &"***") - .finish() - } -} - -impl fmt::Debug for SingleServiceLocationData { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SingleServiceLocationData") - .field("service_locations", &self.service_location) - .field("instance_id", &self.instance_id) - .field("private_key", &"***") - .finish() - } -} - -impl Location { - pub fn to_service_location(&self) -> Result { - if !self.is_service_location() { - warn!("Location {self} is not a service location, so it can't be converted to one."); - return Err(crate::error::Error::ConversionError(format!( - "Failed to convert location {self} to a service location as it's either not marked \ - as one or has MFA enabled." - ))); - } - - let mode = match self.service_location_mode { - ServiceLocationMode::Disabled => { - warn!( - "Location {self} has an invalid service location mode, so it can't be converted to \ - one." - ); - return Err(crate::error::Error::ConversionError(format!( - "Location {self} has an invalid service location mode ({:?}), so it can't be \ - converted to one.", - self.service_location_mode - ))); - } - ServiceLocationMode::PreLogon => 0, - ServiceLocationMode::AlwaysOn => 1, - }; - - Ok(ServiceLocation { - name: self.name.clone(), - address: self.address.clone(), - pubkey: self.pubkey.clone(), - endpoint: self.endpoint.clone(), - allowed_ips: self.allowed_ips.clone(), - dns: self.dns.clone().unwrap_or_default(), - keepalive_interval: self.keepalive_interval.try_into().unwrap_or(0), - mode, - }) - } -} diff --git a/src-tauri/src/enterprise/service_locations/windows.rs b/src-tauri/src/enterprise/service_locations/windows.rs deleted file mode 100644 index e6aa14d6d..000000000 --- a/src-tauri/src/enterprise/service_locations/windows.rs +++ /dev/null @@ -1,968 +0,0 @@ -use std::{ - collections::HashMap, - ffi::OsStr, - fs::{self, create_dir_all}, - path::PathBuf, - result::Result, - str::FromStr, - sync::{Arc, RwLock}, - time::Duration, -}; - -use common::{dns_borrow, find_free_tcp_port, get_interface_name}; -use defguard_wireguard_rs::{ - key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, WireguardInterfaceApi, -}; -use known_folders::get_known_folder_path; -use log::{debug, error, warn}; -use windows::{ - core::PSTR, - Win32::System::RemoteDesktop::{ - self, WTSQuerySessionInformationA, WTSWaitSystemEvent, WTS_CURRENT_SERVER_HANDLE, - WTS_EVENT_LOGOFF, WTS_EVENT_LOGON, WTS_SESSION_INFOA, - }, -}; -use windows_acl::acl::ACL; -use windows_sys::Win32::NetworkManagement::IpHelper::NotifyAddrChange; - -use crate::{ - enterprise::service_locations::{ - ServiceLocationData, ServiceLocationError, ServiceLocationManager, - SingleServiceLocationData, - }, - service::{ - daemon::setup_wgapi, - proto::{ServiceLocation, ServiceLocationMode}, - }, -}; - -const LOGIN_LOGOFF_EVENT_RETRY_DELAY_SECS: u64 = 5; -// How long to wait after a network change before attempting to connect. -// Gives DHCP time to complete and DNS to become available. -const NETWORK_STABILIZATION_DELAY: Duration = Duration::from_secs(3); -// How long to wait before restarting the network change watcher on error. -const NETWORK_CHANGE_MONITOR_RESTART_DELAY: Duration = Duration::from_secs(5); -const DEFAULT_WIREGUARD_PORT: u16 = 51820; -const DEFGUARD_DIR: &str = "Defguard"; -const SERVICE_LOCATIONS_SUBDIR: &str = "service_locations"; - -/// Watches for IP address changes on any network interface and attempts to connect to any -/// service locations that are not yet connected. This handles the case where the endpoint -/// hostname cannot be resolved at service startup because the network (e.g. Wi-Fi) is not -/// yet available. When the network comes up and an IP is assigned, this watcher fires and -/// retries the connection. -/// -/// Note: `NotifyAddrChange` also fires when WireGuard interfaces are created. This is -/// harmless because `connect_to_service_locations` skips already-connected locations. -/// -/// Runs on a dedicated OS thread because `NotifyAddrChange` is a blocking syscall. -pub(crate) fn watch_for_network_change( - service_location_manager: Arc>, -) { - loop { - // NotifyAddrChange blocks until any IP address is added or removed on any interface. - // Passing NULL for both handle and overlapped selects the synchronous (blocking) mode. - let result = unsafe { NotifyAddrChange(std::ptr::null_mut(), std::ptr::null()) }; - - if result != 0 { - error!("NotifyAddrChange failed with error code: {result}"); - std::thread::sleep(NETWORK_CHANGE_MONITOR_RESTART_DELAY); - continue; - } - - debug!( - "Network address change detected, waiting {NETWORK_STABILIZATION_DELAY:?}s for \ - network to stabilize before attempting service location connections..." - ); - std::thread::sleep(NETWORK_STABILIZATION_DELAY); - - debug!("Attempting to connect to service locations after network change"); - let connect_result = service_location_manager - .write() - .unwrap() - .connect_to_service_locations(); - match connect_result { - Ok(_) => { - debug!("Service location connect attempt after network change completed"); - } - Err(err) => { - warn!("Failed to connect to service locations after network change: {err}"); - } - } - } -} - -/// Watches for user logon/logoff events and connects/disconnects pre-logon service locations -/// accordingly. -/// -/// Runs on a dedicated OS thread because `WTSWaitSystemEvent` is a blocking syscall. -pub(crate) fn watch_for_login_logoff( - service_location_manager: Arc>, -) -> Result<(), ServiceLocationError> { - loop { - let mut event_flags: u32 = 0; - let success = unsafe { - WTSWaitSystemEvent( - Some(WTS_CURRENT_SERVER_HANDLE), - WTS_EVENT_LOGON | WTS_EVENT_LOGOFF, - &mut event_flags, - ) - }; - - match success { - Ok(_) => { - debug!("Waiting for system event returned with event_flags: 0x{event_flags:x}"); - } - Err(err) => { - error!("Failed waiting for login/logoff event: {err:?}"); - std::thread::sleep(Duration::from_secs(LOGIN_LOGOFF_EVENT_RETRY_DELAY_SECS)); - continue; - } - }; - - if event_flags & WTS_EVENT_LOGON != 0 { - debug!("Detected user logon, attempting to auto-disconnect from service locations."); - service_location_manager - .write() - .unwrap() - .disconnect_service_locations(Some(ServiceLocationMode::PreLogon))?; - } - if event_flags & WTS_EVENT_LOGOFF != 0 { - debug!("Detected user logoff, attempting to auto-connect to service locations."); - service_location_manager - .write() - .unwrap() - .connect_to_service_locations()?; - } - } -} - -fn get_shared_directory() -> Result { - match get_known_folder_path(known_folders::KnownFolder::ProgramData) { - Some(mut path) => { - path.push(DEFGUARD_DIR); - path.push(SERVICE_LOCATIONS_SUBDIR); - Ok(path) - } - None => Err(ServiceLocationError::LoadError( - "Could not find ProgramData known folder".to_string(), - )), - } -} - -fn set_protected_acls(path: &str) -> Result<(), ServiceLocationError> { - debug!("Setting secure ACLs on: {path}"); - - const SYSTEM_SID: &str = "S-1-5-18"; // NT AUTHORITY\SYSTEM - const ADMINISTRATORS_SID: &str = "S-1-5-32-544"; // BUILTIN\Administrators - - const FILE_ALL_ACCESS: u32 = 0x001F_01FF; - - match ACL::from_file_path(path, false) { - Ok(mut acl) => { - // Remove everything else from access - debug!("Removing all existing ACL entries for {path}"); - let all_entries = acl.all().map_err(|e| { - ServiceLocationError::LoadError(format!("Failed to get ACL entries: {e}")) - })?; - - for entry in all_entries { - if let Some(sid) = entry.sid { - if let Err(e) = acl.remove(sid.as_ptr() as *mut _, None, None) { - debug!("Note: Could not remove ACL entry (might be expected): {e}"); - } - } - } - - debug!("Cleared existing ACL entries, now adding secure entries"); - - // Add SYSTEM with full control - debug!("Adding SYSTEM with full control"); - let system_sid_result = windows_acl::helper::string_to_sid(SYSTEM_SID); - match system_sid_result { - Ok(system_sid) => { - acl.allow(system_sid.as_ptr() as *mut _, true, FILE_ALL_ACCESS) - .map_err(|e| { - ServiceLocationError::LoadError(format!( - "Failed to add SYSTEM ACL: {e}" - )) - })?; - } - Err(e) => { - return Err(ServiceLocationError::LoadError(format!( - "Failed to convert SYSTEM SID: {e}" - ))); - } - } - - // Add Administrators with full control - debug!("Adding Administrators with full control"); - let admin_sid_result = windows_acl::helper::string_to_sid(ADMINISTRATORS_SID); - match admin_sid_result { - Ok(admin_sid) => { - acl.allow(admin_sid.as_ptr() as *mut _, true, FILE_ALL_ACCESS) - .map_err(|e| { - ServiceLocationError::LoadError(format!( - "Failed to add Administrators ACL: {e}" - )) - })?; - } - Err(e) => { - return Err(ServiceLocationError::LoadError(format!( - "Failed to convert Administrators SID: {e}" - ))); - } - } - - debug!("Successfully set secure ACLs on {path} for SYSTEM and Administrators"); - Ok(()) - } - Err(e) => { - error!("Failed to get ACL for {path}: {e}"); - Err(ServiceLocationError::LoadError(format!( - "Failed to get ACL for {path}: {e}" - ))) - } - } -} - -fn get_instance_file_path(instance_id: &str) -> Result { - let mut path = get_shared_directory()?; - path.push(format!("{instance_id}.json")); - Ok(path) -} - -pub(crate) fn is_user_logged_in() -> bool { - debug!("Starting checking if user is logged in..."); - - unsafe { - let mut pp_sessions: *mut WTS_SESSION_INFOA = std::ptr::null_mut(); - let mut count: u32 = 0; - - debug!("Calling WTSEnumerateSessionsA..."); - let ret = RemoteDesktop::WTSEnumerateSessionsA(None, 0, 1, &mut pp_sessions, &mut count); - - match ret { - Ok(_) => { - debug!("WTSEnumerateSessionsA succeeded, found {count} sessions"); - let sessions = std::slice::from_raw_parts(pp_sessions, count as usize); - - for (index, session) in sessions.iter().enumerate() { - debug!( - "Session {index}: SessionId={}, State={:?}, WinStationName={:?}", - session.SessionId, - session.State, - std::ffi::CStr::from_ptr(session.pWinStationName.0 as *const i8) - .to_string_lossy() - ); - - if session.State == windows::Win32::System::RemoteDesktop::WTSActive { - let mut buffer = PSTR::null(); - let mut bytes_returned: u32 = 0; - - let result = WTSQuerySessionInformationA( - None, - session.SessionId, - windows::Win32::System::RemoteDesktop::WTSUserName, - &mut buffer, - &mut bytes_returned, - ); - - match result { - Ok(_) => { - if !buffer.is_null() { - let username = std::ffi::CStr::from_ptr(buffer.0 as *const i8) - .to_string_lossy() - .into_owned(); - - debug!( - "Found session {} username: {username}", - session.SessionId - ); - - windows::Win32::System::RemoteDesktop::WTSFreeMemory( - buffer.0 as *mut _, - ); - - // We found an active session with a username. - // Free the session list before returning to avoid a leak. - windows::Win32::System::RemoteDesktop::WTSFreeMemory( - pp_sessions as _, - ); - return true; - } - } - Err(err) => { - debug!( - "Failed to get username for session {}: {err:?}", - session.SessionId - ); - } - } - } - } - windows::Win32::System::RemoteDesktop::WTSFreeMemory(pp_sessions as _); - debug!("No active sessions found"); - } - Err(err) => { - error!("Failed to enumerate user sessions: {err:?}"); - debug!("WTSEnumerateSessionsA failed: {err:?}"); - } - } - } - - debug!("User is not logged in."); - false -} - -impl ServiceLocationManager { - pub fn init() -> Result { - debug!("Initializing ServiceLocationApi"); - let path = get_shared_directory()?; - - debug!("Creating directory: {path:?}"); - create_dir_all(&path)?; - - if let Some(path_str) = path.to_str() { - debug!("Setting ACLs on service locations directory"); - if let Err(e) = set_protected_acls(path_str) { - warn!("Failed to set ACLs on service locations directory: {e}. Continuing anyway."); - } - } else { - warn!("Failed to convert path to string for ACL setting"); - } - - let manager = Self { - wgapis: HashMap::new(), - connected_service_locations: HashMap::new(), - }; - - debug!("ServiceLocationApi initialized successfully"); - Ok(manager) - } - - /// Check if a specific service location is already connected - fn is_service_location_connected(&self, instance_id: &str, location_pubkey: &str) -> bool { - if let Some(locations) = self.connected_service_locations.get(instance_id) { - for location in locations { - if location.pubkey == location_pubkey { - return true; - } - } - } - false - } - - /// Add a connected service location - fn add_connected_service_location( - &mut self, - instance_id: &str, - location: &ServiceLocation, - ) -> Result<(), ServiceLocationError> { - self.connected_service_locations - .entry(instance_id.to_string()) - .or_default() - .push(location.clone()); - - debug!( - "Added connected service location for instance '{instance_id}', location '{}'", - location.name - ); - Ok(()) - } - - /// Remove connected service locations by filter (write disk-first, then memory) - fn remove_connected_service_locations( - &mut self, - filter: F, - ) -> Result<(), ServiceLocationError> - where - F: Fn(&str, &ServiceLocation) -> bool, - { - // Iterate through connected_service_locations and remove matching locations - let mut instances_to_remove = Vec::new(); - - for (instance_id, locations) in self.connected_service_locations.iter_mut() { - locations.retain(|location| !filter(instance_id, location)); - - // Mark instance for removal if it has no more locations - if locations.is_empty() { - instances_to_remove.push(instance_id.clone()); - } - } - - // Remove instances with no locations - for instance_id in instances_to_remove { - self.connected_service_locations.remove(&instance_id); - } - - debug!("Removed connected service locations matching filter"); - Ok(()) - } - - // Resets the state of the service location: - // 1. If it's an always on location, disconnects and reconnects it. - // 2. Otherwise, just disconnects it if the user is not logged in. - pub(crate) fn reset_service_location_state( - &mut self, - instance_id: &str, - location_pubkey: &str, - ) -> Result<(), ServiceLocationError> { - debug!( - "Reseting the state of service location for instance_id: {instance_id}, \ - location_pubkey: {location_pubkey}" - ); - - let service_location_data = self - .load_service_location(instance_id, location_pubkey)? - .ok_or_else(|| { - ServiceLocationError::LoadError(format!( - "Service location with pubkey {} for instance {} not found", - location_pubkey, instance_id - )) - })?; - - debug!( - "Disconnecting service location for instance_id: {instance_id}, location_pubkey: \ - {location_pubkey} ({})", - service_location_data.service_location.name - ); - - self.disconnect_service_location(instance_id, location_pubkey)?; - - debug!( - "Disconnected service location for instance_id: {instance_id}, \ - location_pubkey: {location_pubkey} ({})", - service_location_data.service_location.name - ); - - debug!( - "Reconnecting service location if needed for instance_id: {instance_id}, \ - location_pubkey: {location_pubkey} ({})", - service_location_data.service_location.name - ); - - // We should reconnect only if: - // 1. It's an always on location - // 2. It's a pre-logon location and the user is not logged in - if service_location_data.service_location.mode == ServiceLocationMode::AlwaysOn as i32 - || (service_location_data.service_location.mode == ServiceLocationMode::PreLogon as i32 - && !is_user_logged_in()) - { - debug!( - "Reconnecting service location for instance_id: {instance_id}, location_pubkey: \ - {location_pubkey} ({})", - service_location_data.service_location.name - ); - self.connect_to_service_location(&service_location_data)?; - } - - debug!("Service location state reset completed."); - - Ok(()) - } - - pub(crate) fn disconnect_service_locations_by_instance( - &mut self, - instance_id: &str, - ) -> Result<(), ServiceLocationError> { - debug!("Disconnecting all service locations for instance_id: {instance_id}"); - - if let Some(locations) = self.connected_service_locations.get(instance_id) { - // Collect locations to disconnect to avoid borrowing issues - let locations_to_disconnect = locations.to_vec(); - - for location in locations_to_disconnect { - let ifname = get_interface_name(&location.name); - debug!("Tearing down interface: {ifname}"); - if let Some(mut wgapi) = self.wgapis.remove(&ifname) { - if let Err(err) = wgapi.remove_interface() { - error!("Failed to remove interface {ifname}: {err}"); - } else { - debug!("Interface {ifname} removed successfully"); - } - debug!( - "Removing connected service location for instance_id: {instance_id}, \ - location_pubkey: {}", - location.pubkey - ); - debug!( - "Disconnected service location for instance_id: {instance_id}, \ - location_pubkey: {}", - location.pubkey - ); - } else { - error!("Failed to find WireGuard API for interface {ifname}"); - } - } - - self.connected_service_locations.remove(instance_id); - } else { - debug!( - "No connected service locations found for instance_id: {instance_id}. Skipping disconnect" - ); - return Ok(()); - } - - debug!("Disconnected all service locations for instance_id: {instance_id}"); - - Ok(()) - } - - pub(crate) fn disconnect_service_location( - &mut self, - instance_id: &str, - location_pubkey: &str, - ) -> Result<(), ServiceLocationError> { - debug!( - "Disconnecting service location for instance_id: {instance_id}, location_pubkey: \ - {location_pubkey}" - ); - - if let Some(locations) = self.connected_service_locations.get_mut(instance_id) { - if let Some(pos) = locations - .iter() - .position(|loc| loc.pubkey == location_pubkey) - { - let location = locations.remove(pos); - let ifname = get_interface_name(&location.name); - debug!("Tearing down interface: {ifname}"); - if let Some(mut wgapi) = self.wgapis.remove(&ifname) { - if let Err(err) = wgapi.remove_interface() { - error!("Failed to remove interface {ifname}: {err}"); - } else { - debug!("Interface {ifname} removed successfully."); - } - } else { - error!("Failed to find WireGuard API for interface {ifname}. "); - } - } else { - debug!( - "Service location with pubkey {location_pubkey} for instance {instance_id} is \ - not connected, skipping disconnect" - ); - return Ok(()); - } - } else { - debug!( - "No connected service locations found for instance_id: {instance_id}, skipping \ - disconnect" - ); - return Ok(()); - } - - debug!( - "Disconnected service location for instance_id: {instance_id}, location_pubkey: \ - {location_pubkey}" - ); - - Ok(()) - } - - /// Helper function to setup a WireGuard interface for a service location - fn setup_service_location_interface( - &mut self, - location: &ServiceLocation, - private_key: &str, - ) -> Result<(), ServiceLocationError> { - let peer_key = Key::from_str(&location.pubkey)?; - - let mut peer = Peer::new(peer_key.clone()); - peer.set_endpoint(&location.endpoint)?; - - peer.persistent_keepalive_interval = location.keepalive_interval.try_into().ok(); - - let allowed_ips = location - .allowed_ips - .split(',') - .map(str::to_string) - .collect::>(); - - for allowed_ip in &allowed_ips { - match IpAddrMask::from_str(allowed_ip) { - Ok(addr) => { - peer.allowed_ips.push(addr); - } - Err(err) => { - error!( - "Error parsing IP address {allowed_ip} while setting up interface for \ - location {location:?}, error details: {err}" - ); - } - } - } - - let mut addresses = Vec::new(); - - for address in location.address.split(',') { - addresses.push(IpAddrMask::from_str(address.trim())?); - } - - let config = InterfaceConfiguration { - name: location.name.clone(), - prvkey: private_key.to_string(), - addresses, - port: find_free_tcp_port().unwrap_or(DEFAULT_WIREGUARD_PORT), - peers: vec![peer.clone()], - mtu: None, - fwmark: None, // TODO: add - }; - - let ifname = location.name.clone(); - let ifname = get_interface_name(&ifname); - let mut wgapi = match setup_wgapi(&ifname) { - Ok(api) => api, - Err(err) => { - let msg = format!("Failed to setup WireGuard API for interface {ifname}: {err:?}"); - debug!("{msg}"); - return Err(ServiceLocationError::InterfaceError(msg)); - } - }; - - wgapi.create_interface()?; - - // Extract DNS configuration if available - let dns_config = Some(location.dns.clone()); - let (dns, search_domains) = dns_borrow(&dns_config); - debug!( - "Configuring interface {ifname} with DNS: {dns:?} and search domains: \ - {search_domains:?}", - ); - debug!("Interface Configuration: {config:?}"); - - wgapi.configure_interface(&config)?; - wgapi.configure_dns(&dns, &search_domains)?; - - self.wgapis.insert(ifname.clone(), wgapi); - - debug!("Interface {ifname} configured successfully."); - Ok(()) - } - - pub(crate) fn connect_to_service_location( - &mut self, - location_data: &SingleServiceLocationData, - ) -> Result<(), ServiceLocationError> { - let instance_id = &location_data.instance_id; - let location_pubkey = &location_data.service_location.pubkey; - debug!( - "Connecting to service location for instance_id: {instance_id}, location_pubkey: \ - {location_pubkey}" - ); - - // Check if already connected to this service location - if self.is_service_location_connected(instance_id, location_pubkey) { - debug!( - "Service location with pubkey {location_pubkey} for instance {instance_id} is \ - already connected, skipping" - ); - return Ok(()); - } - - let location_data = self - .load_service_location(instance_id, location_pubkey)? - .ok_or_else(|| { - ServiceLocationError::LoadError(format!( - "Service location with pubkey {location_pubkey} for instance {instance_id} not \ - found", - )) - })?; - - self.setup_service_location_interface( - &location_data.service_location, - &location_data.private_key, - )?; - self.add_connected_service_location( - &location_data.instance_id, - &location_data.service_location, - )?; - let ifname = get_interface_name(&location_data.service_location.name); - debug!("Successfully connected to service location '{ifname}'"); - - Ok(()) - } - - pub(crate) fn disconnect_service_locations( - &mut self, - mode: Option, - ) -> Result<(), ServiceLocationError> { - debug!("Disconnecting service locations with mode: {mode:?}"); - - for (instance, locations) in &self.connected_service_locations { - for location in locations { - debug!( - "Found connected service location for instance_id: {instance}, \ - location_pubkey: {}", - location.pubkey - ); - if let Some(m) = mode { - let location_mode: ServiceLocationMode = location.mode.try_into()?; - if location_mode != m { - debug!( - "Skipping interface {} due to the service location mode doesn't match the \ - requested mode (expected {m:?}, found {:?})", - location.name, location.mode - ); - continue; - } - } - - let ifname = get_interface_name(&location.name); - debug!("Tearing down interface: {ifname}"); - if let Some(mut wgapi) = self.wgapis.remove(&ifname) { - if let Err(err) = wgapi.remove_interface() { - error!("Failed to remove interface {ifname}: {err}"); - } else { - debug!("Interface {ifname} removed successfully."); - } - } else { - error!("Failed to find WireGuard API for interface {ifname}"); - } - } - } - - self.remove_connected_service_locations(|_, location| { - if let Some(m) = mode { - let location_mode: ServiceLocationMode = location - .mode - .try_into() - .unwrap_or(ServiceLocationMode::AlwaysOn); - location_mode == m - } else { - true - } - })?; - - debug!("Service locations disconnected."); - - Ok(()) - } - - /// Attempts to connect to all service locations that are not already connected. - /// - /// Returns `Ok(true)` if every location is now connected (either it was already connected or - /// it was successfully connected during this call), and `Ok(false)` if at least one location - /// failed to connect (indicating that a retry may be worthwhile). - pub(crate) fn connect_to_service_locations(&mut self) -> Result { - debug!("Attempting to auto-connect to VPN..."); - - let data = self.load_service_locations()?; - debug!("Loaded {} instance(s) from ServiceLocationApi", data.len()); - - let mut all_connected = true; - - for instance_data in data { - debug!( - "Found service locations for instance ID: {}", - instance_data.instance_id - ); - debug!( - "Instance has {} service location(s)", - instance_data.service_locations.len() - ); - for location in instance_data.service_locations { - debug!("Service Location: {location:?}"); - - if location.mode == ServiceLocationMode::PreLogon as i32 { - if is_user_logged_in() { - debug!( - "Skipping pre-logon service location '{}' because user is logged in", - location.name - ); - continue; - } - debug!( - "Proceeding to connect pre-logon service location '{}' because no user \ - is logged in", - location.name - ); - } - - if self.is_service_location_connected(&instance_data.instance_id, &location.pubkey) - { - debug!( - "Skipping service location '{}' because it's already connected", - location.name - ); - continue; - } - - if let Err(err) = - self.setup_service_location_interface(&location, &instance_data.private_key) - { - warn!( - "Failed to setup service location interface for '{}': {err:?}", - location.name - ); - all_connected = false; - continue; - } - - if let Err(err) = - self.add_connected_service_location(&instance_data.instance_id, &location) - { - debug!( - "Failed to persist connected service location after auto-connect: {err:?}" - ); - } - - debug!( - "Successfully connected to service location '{}'", - location.name - ); - } - } - - debug!("Auto-connect attempt completed"); - - Ok(all_connected) - } - - pub fn save_service_locations( - &self, - service_locations: &[ServiceLocation], - instance_id: &str, - private_key: &str, - ) -> Result<(), ServiceLocationError> { - debug!( - "Received a request to save {} service location(s) for instance {instance_id}", - service_locations.len(), - ); - - debug!("Service locations to save: {service_locations:?}"); - - create_dir_all(get_shared_directory()?)?; - - let instance_file_path = get_instance_file_path(instance_id)?; - - let service_location_data = ServiceLocationData { - service_locations: service_locations.to_vec(), - instance_id: instance_id.to_string(), - private_key: private_key.to_string(), - }; - - let json = serde_json::to_string_pretty(&service_location_data)?; - - debug!( - "Writing service location data to file: {}", - instance_file_path.display() - ); - - fs::write(&instance_file_path, &json)?; - - if let Some(file_path_str) = instance_file_path.to_str() { - debug!("Setting ACLs on service location file: {file_path_str}"); - if let Err(err) = set_protected_acls(file_path_str) { - warn!( - "Failed to set ACLs on service location file {file_path_str}: {err}. \ - File saved but may have insecure permissions." - ); - } else { - debug!("Successfully set ACLs on service location file"); - } - } else { - warn!("Failed to convert file path to string for ACL setting"); - } - - debug!( - "Service locations saved successfully for instance {instance_id} to {}", - instance_file_path.display() - ); - Ok(()) - } - - fn load_service_locations(&self) -> Result, ServiceLocationError> { - let base_dir = get_shared_directory()?; - let mut all_locations_data = Vec::new(); - - if base_dir.exists() { - for entry in fs::read_dir(base_dir)? { - let entry = entry?; - let file_path = entry.path(); - - if file_path.is_file() && file_path.extension() == Some(OsStr::new("json")) { - match fs::read_to_string(&file_path) { - Ok(data) => match serde_json::from_str::(&data) { - Ok(locations_data) => { - all_locations_data.push(locations_data); - } - Err(err) => { - error!( - "Failed to parse service locations from file {}: {err}", - file_path.display() - ); - } - }, - Err(err) => { - error!( - "Failed to read service locations file {}: {err}", - file_path.display() - ); - } - } - } - } - } - - debug!( - "Loaded service locations data for {} instances", - all_locations_data.len() - ); - Ok(all_locations_data) - } - - fn load_service_location( - &self, - instance_id: &str, - location_pubkey: &str, - ) -> Result, ServiceLocationError> { - debug!("Loading service location for instance {instance_id} and pubkey {location_pubkey}"); - - let instance_file_path = get_instance_file_path(instance_id)?; - - if instance_file_path.exists() { - let data = fs::read_to_string(&instance_file_path)?; - let service_location_data = serde_json::from_str::(&data)?; - - for location in service_location_data.service_locations { - if location.pubkey == location_pubkey { - debug!( - "Successfully loaded service location for instance {instance_id} and \ - pubkey {location_pubkey}" - ); - return Ok(Some(SingleServiceLocationData { - service_location: location, - instance_id: service_location_data.instance_id, - private_key: service_location_data.private_key, - })); - } - } - - debug!( - "No service location found for instance {instance_id} with pubkey {location_pubkey}" - ); - Ok(None) - } else { - debug!("No service location file found for instance {instance_id}"); - Ok(None) - } - } - - pub(crate) fn delete_all_service_locations_for_instance( - &self, - instance_id: &str, - ) -> Result<(), ServiceLocationError> { - debug!("Deleting all service locations for instance {instance_id}"); - - let instance_file_path = get_instance_file_path(instance_id)?; - - if instance_file_path.exists() { - fs::remove_file(&instance_file_path)?; - debug!("Successfully deleted all service locations for instance {instance_id}"); - } else { - debug!("No service location file found for instance {instance_id}"); - } - - Ok(()) - } -} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs deleted file mode 100644 index 176710cb8..000000000 --- a/src-tauri/src/error.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::net::AddrParseError; - -use defguard_wireguard_rs::{error::WireguardInterfaceError, net::IpAddrParseError}; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error(transparent)] - Io(#[from] std::io::Error), - #[error("Application config directory error: {0}")] - Config(String), - #[error("Database error: {0}")] - Database(#[from] sqlx::Error), - #[error("Migrate error: {0}")] - Migration(#[from] sqlx::migrate::MigrateError), - #[error("Wireguard error: {0}")] - WireguardError(#[from] WireguardInterfaceError), - #[error("WireGuard key error: {0}")] - KeyDecode(#[from] base64::DecodeError), - #[error("IP address/mask error: {0}")] - IpAddrMask(#[from] IpAddrParseError), - #[error("IP address parse error: {0}")] - AddrParse(#[from] AddrParseError), - #[error("Internal error: {0}")] - InternalError(String), - #[error("Failed to parse timestamp")] - Datetime, - #[error("Object not found")] - NotFound, - #[error("Tauri error: {0}")] - Tauri(#[from] tauri::Error), - #[error("Failed to parse str to enum")] - StrumError(#[from] strum::ParseError), - #[error("Required resource not found {0}")] - ResourceNotFound(String), - #[error("Config parse error {0}")] - ConfigParseError(String), - #[error("Command failed: {0}")] - CommandError(String), - #[error("Core is not enterprise")] - CoreNotEnterprise, - #[error("Instance has no config polling token")] - NoToken, - #[error("Failed to lock app state member.")] - StateLockFail, - #[error("Failed to convert value. {0}")] - ConversionError(String), - #[error("JSON error: {0}")] - JsonError(#[from] serde_json::Error), -} - -// we must manually implement serde::Serialize -impl serde::Serialize for Error { - fn serialize(&self, serializer: S) -> Result - where - S: serde::ser::Serializer, - { - serializer.serialize_str(self.to_string().as_ref()) - } -} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index e7267d084..2ff829ef6 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -1,44 +1,12 @@ +pub use defguard_client_core::events::EventKey; use serde::Serialize; -use tauri::{AppHandle, Emitter, Url}; +use tauri::{AppHandle, Emitter, Manager, Url}; use tauri_plugin_notification::NotificationExt; -use crate::{tray::show_main_window, ConnectionType}; - -// Match src/pages/client/types.ts. -#[non_exhaustive] -pub enum EventKey { - ConnectionChanged, - InstanceUpdate, - LocationUpdate, - AppVersionFetch, - ConfigChanged, - DeadConnectionDropped, - DeadConnectionReconnected, - ApplicationConfigChanged, - AddInstance, - MfaTrigger, - VersionMismatch, - UuidMismatch, -} - -impl From for &'static str { - fn from(key: EventKey) -> &'static str { - match key { - EventKey::ConnectionChanged => "connection-changed", - EventKey::InstanceUpdate => "instance-update", - EventKey::LocationUpdate => "location-update", - EventKey::AppVersionFetch => "app-version-fetch", - EventKey::ConfigChanged => "config-changed", - EventKey::DeadConnectionDropped => "dead-connection-dropped", - EventKey::DeadConnectionReconnected => "dead-connection-reconnected", - EventKey::ApplicationConfigChanged => "application-config-changed", - EventKey::AddInstance => "add-instance", - EventKey::MfaTrigger => "mfa-trigger", - EventKey::VersionMismatch => "version-mismatch", - EventKey::UuidMismatch => "uuid-mismatch", - } - } -} +use crate::{ + window_manager::{WindowManager, COMPACT_WINDOW_ID}, + ConnectionType, +}; /// Used as payload for [`DEAD_CONNECTION_DROPPED`] event #[derive(Clone, Serialize)] @@ -100,10 +68,47 @@ pub struct AddInstancePayload<'a> { pub url: &'a str, } +#[derive(Clone, Serialize)] +pub struct TunnelsDisabledPayload { + pub names: Vec, +} + +impl TunnelsDisabledPayload { + pub fn emit(app_handle: &AppHandle, names: Vec) { + let payload = Self { names }; + for name in &payload.names { + if let Err(err) = app_handle + .notification() + .builder() + .title(format!("Tunnel {name} disconnected")) + .body("WireGuard tunnels have been disabled by the administrator.") + .show() + { + warn!("Tunnels disabled notification not shown. Reason: {err}"); + } + } + if let Err(err) = app_handle.emit(EventKey::TunnelsDisabled.into(), payload) { + error!("Event TunnelsDisabled was not emitted. Reason: {err}"); + } + } +} + +#[derive(Clone, Serialize)] +pub struct TunnelsEnabledPayload; + +impl TunnelsEnabledPayload { + pub fn emit(app_handle: &AppHandle) { + if let Err(err) = app_handle.emit(EventKey::TunnelsEnabled.into(), Self) { + error!("Event TunnelsEnabled was not emitted. Reason: {err}"); + } + } +} + /// Handle deep-link URLs. pub fn handle_deep_link(app_handle: &AppHandle, urls: &[Url]) { + debug!("Deep link received."); for link in urls { - if link.path() == "/addinstance" { + if link.host_str() == Some("addinstance") { let mut token = None; let mut url = None; for (key, value) in link.query_pairs() { @@ -115,7 +120,13 @@ pub fn handle_deep_link(app_handle: &AppHandle, urls: &[Url]) { } } if let (Some(token), Some(url)) = (token, url) { - show_main_window(app_handle); + info!("Valid Deep link received."); + if let Some(tray_win) = app_handle.get_webview_window(COMPACT_WINDOW_ID) { + let _ = tray_win.hide(); + } + if let Err(e) = WindowManager::open_full_view(app_handle) { + warn!("Deep link: failed to open main window: {e}"); + } let _ = app_handle.emit( EventKey::AddInstance.into(), AddInstancePayload { diff --git a/src-tauri/src/gui.rs b/src-tauri/src/gui.rs new file mode 100644 index 000000000..ba9d13973 --- /dev/null +++ b/src-tauri/src/gui.rs @@ -0,0 +1,549 @@ +use std::{env, str::FromStr, sync::LazyLock}; +#[cfg(target_os = "macos")] +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::spawn, +}; + +#[cfg(target_os = "macos")] +use defguard_client_core::connection::sync_locations_and_tunnels; +use defguard_client_core::{ + connection::active_connections::close_all_connections, + version::{check_app_version, should_show_welcome, VersionCheckResult}, +}; +use log::{Level, LevelFilter}; +use tauri::{async_runtime, AppHandle, Builder, Manager, RunEvent, WindowEvent}; +use tauri_plugin_deep_link::DeepLinkExt; +use tauri_plugin_log::{Target, TargetKind}; + +#[cfg(unix)] +use crate::set_perms; +#[cfg(windows)] +use crate::utils::sync_connections; +use crate::{ + app_config::AppConfig, + appstate::AppState, + commands::*, + database::{ + handle_db_migrations, + models::{location_stats::LocationStats, tunnel::TunnelStats}, + DB_POOL, + }, + events::handle_deep_link, + periodic::run_periodic_tasks, + provisioning::handle_client_initialization, + session_state, + tray::{configure_tray_icon, setup_tray}, + utils::{load_log_targets, DEFAULT_SERVICE_LOG_DIR}, + window_manager::*, + LOG_FILENAME, VERSION, +}; +#[cfg(target_os = "macos")] +use crate::{ + apple::{connection_state_update_thread, get_managers_for_tunnels_and_locations}, + connection::apple::{observer_thread, spawn_runloop_and_wait_for}, + database::models::get_all_tunnels_locations, +}; +#[cfg(all(target_os = "macos", feature = "macos_installer"))] +use crate::{connection::apple::PLUGIN_BUNDLE_ID, system_extension::activate_system_extension}; + +// For tauri logging plugin: +// if found in metadata target name it will ignore the log if it was below info level. +const LOGGING_TARGET_IGNORE_LIST: [&str; 5] = ["tauri", "sqlx", "hyper", "h2", "tower"]; + +static LOG_INCLUDES: LazyLock> = LazyLock::new(load_log_targets); + +async fn startup(app_handle: &AppHandle) { + debug!("Purging old stats from the database."); + if let Err(err) = LocationStats::purge(&*DB_POOL).await { + error!("Failed to purge location stats: {err}"); + } else { + debug!("Old location stats have been purged successfully."); + } + if let Err(err) = TunnelStats::purge(&*DB_POOL).await { + error!("Failed to purge tunnel stats: {err}"); + } else { + debug!("Old tunnel stats have been purged successfully."); + } + + // Sync already active connections on windows. + // When windows is restarted, the app doesn't close the active connections + // and they are still running after the restart. We sync them here to + // reflect the real system's state. + // TODO: Find a way to intercept the shutdown event and close all connections + #[cfg(windows)] + { + match sync_connections(app_handle).await { + Ok(()) => { + info!( + "Synchronized application's active connections with the connections \ + already open on the system, if there were any." + ); + } + Err(err) => { + warn!( + "Failed to synchronize application's active connections with the connections \ + already open on the system. \ + The connections' state in the application may not reflect system's state. \ + Reconnect manually to reset them. Error: {err}" + ); + } + }; + } + #[cfg(all(target_os = "macos", feature = "macos_installer"))] + activate_system_extension(PLUGIN_BUNDLE_ID); + + #[cfg(target_os = "macos")] + { + let semaphore = Arc::new(AtomicBool::new(false)); + let semaphore_clone = Arc::clone(&semaphore); + + // Retrieve MTU from `AppConfig`. + let app_state = app_handle.state::(); + let mtu = app_state + .app_config + .lock() + .expect("failed to lock app state") + .mtu(); + let handle = async_runtime::spawn(async move { + if let Err(err) = sync_locations_and_tunnels(mtu).await { + error!("Failed to sync locations and tunnels: {err}"); + } + semaphore_clone.store(true, Ordering::Release); + }); + spawn_runloop_and_wait_for(&semaphore); + let _ = handle.await; + + let (tunnels, locations) = get_all_tunnels_locations().await; + let handle = app_handle.clone(); + // Observer thread is blocking, so its better not to mess with the tauri runtime. + spawn(move || { + observer_thread(get_managers_for_tunnels_and_locations(&tunnels, &locations)); + error!("VPN observer thread has exited unexpectedly, quitting the app."); + handle.exit(0); + }); + + let handle = app_handle.clone(); + async_runtime::spawn(async move { + connection_state_update_thread(&handle).await; + error!("Connection state update thread has exited unexpectedly, quitting the app."); + handle.exit(0); + }); + } + + // Run periodic tasks. + let periodic_tasks_handle = app_handle.clone(); + async_runtime::spawn(async move { + run_periodic_tasks(&periodic_tasks_handle).await; + // One of the tasks exited, so something went wrong, quit the app + error!("One of the periodic tasks has stopped unexpectedly. Exiting the application."); + periodic_tasks_handle.exit(0); + }); + debug!("Periodic tasks have been started."); + + // Load tray menu after database initialization, so all instance and locations can be shown. + debug!( + "Re-generating tray menu to show all available instances and locations as we have \ + connected to the database." + ); + if let Err(err) = setup_tray(app_handle).await { + error!("Failed to setup system tray: {err}"); + } + match configure_tray_icon(app_handle).await { + Ok(()) => info!("System tray configured."), + Err(err) => error!("Failed to configure system tray: {err}"), + } + debug!("Tray menu has been re-generated successfully."); +} + +pub fn run_app() { + info!("Starting Defguard client version {VERSION}"); + + let app = Builder::default() + .invoke_handler(tauri::generate_handler![ + all_locations, + has_any_visible_locations, + save_device_config, + all_instances, + connect, + disconnect, + update_instance, + location_stats, + location_interface_details, + all_connections, + last_connection, + active_connection, + update_location_routing, + delete_instance, + parse_tunnel_config, + save_tunnel, + all_tunnels, + open_link, + tunnel_details, + update_tunnel, + delete_tunnel, + get_latest_app_version, + start_global_logwatcher, + stop_global_logwatcher, + command_get_app_config, + command_set_app_config, + get_provisioning_config, + get_platform_header, + get_posture_data, + set_location_mfa_method, + open_tray_window, + open_full_view_window, + swap_to_tray, + swap_to_full_view, + close_tray_window, + close_welcome_window, + all_active_connections, + disconnect_locations, + enrollment_start, + enrollment_create_device, + enrollment_activate_user, + enrollment_register_mfa_start, + enrollment_register_mfa_finish, + enrollment_network_info, + enrollment_finish, + mfa_start, + mfa_finish_code, + mfa_poll_openid, + mfa_connect_mobile_approve, + cancel_mfa, + session_state::get_session_state, + session_state::patch_session_state, + ]) + .on_window_event(|window, event| { + if let WindowEvent::ThemeChanged(_theme) = event { + let app = window.app_handle().clone(); + async_runtime::spawn(async move { + if let Err(err) = configure_tray_icon(&app).await { + error!("Failed to reconfigure tray icon on theme change: {err}"); + } + }); + } + if let WindowEvent::CloseRequested { api, .. } = event { + let label = window.label(); + if label == COMPACT_WINDOW_ID || label == FULL_VIEW_WINDOW_ID { + let _ = window.hide(); + api.prevent_close(); + } + } + }) + // Initialize plugins here, except for `tauri_plugin_log` which is handled in `setup()`. + // Single instance plugin should always be the first to register. + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + let is_deep_link = argv.iter().any(|a| a.starts_with("defguard://")); + // User tried to spawn second instance, mirror tray left click path. + if !is_deep_link { + show_tray_or_full_view(app); + } + })) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_process::init()) + .setup(|app| { + // Create Help menu on macOS. + // https://github.com/tauri-apps/tauri/issues/9371 + #[cfg(target_os = "macos")] + { + use tauri_plugin_opener::OpenerExt; + + const DOC_ITEM_ID: &str = "doc"; + const REPORT_ITEM_ID: &str = "issue"; + const DOC_URL: &str = "https://docs.defguard.net/using-defguard-for-end-users/desktop-client"; + const REPORT_URL: &str = "https://github.com/DefGuard/client/issues/new?labels=bug&template=bug_report.md"; + if let Some(menu) = app.menu() { + if let Some(help_submenu) = menu.get(tauri::menu::HELP_SUBMENU_ID) { + let report_item = tauri::menu::MenuItem::with_id( + app, + REPORT_ITEM_ID, + "Report an issue", + true, + None::<&str>, + )?; + let _ = help_submenu.as_submenu_unchecked().append(&report_item); + let doc_item = tauri::menu::MenuItem::with_id( + app, + DOC_ITEM_ID, + "Defguard Desktop Client Help", + true, + None::<&str>, + )?; + let _ = help_submenu.as_submenu_unchecked().append(&doc_item); + } + } + app.on_menu_event(move |app, event| { + let id = event.id(); + if id == DOC_ITEM_ID { + let _ = app.opener().open_url(DOC_URL, None::<&str>); + } else if id == REPORT_ITEM_ID { + let _ = app.opener().open_url(REPORT_URL, None::<&str>); + } + }); + + app.set_dock_visibility(false); + } + + // Register for Linux and debug Windows builds. + #[cfg(any(target_os = "linux", windows))] + { + use tauri_plugin_deep_link::DeepLinkExt; + app.deep_link().register_all()?; + } + + let app_handle = app.app_handle(); + + // Single Rust-side entry point for all deep link events (runtime). + { + let handle = app_handle.clone(); + app.deep_link().on_open_url(move |event| { + handle_deep_link(&handle, &event.urls()); + }); + } + + // Prepare `AppConfig`. + let config_dir = app_handle + .path() + .app_data_dir() + .expect("Failed to access app data"); + let config = AppConfig::new(&config_dir); + let current_version = app_handle.package_info().version.clone(); + let version_check = check_app_version(&config_dir, ¤t_version); + match &version_check { + VersionCheckResult::Init => { + debug!("No previous version recorded; initializing at {current_version}."); + } + VersionCheckResult::Unchanged => { + debug!("Application version unchanged ({current_version})."); + } + VersionCheckResult::Upgraded { previous, current } => { + info!("Application upgraded from {previous} to {current}."); + } + } + let open_welcome_view = should_show_welcome(&config_dir); + // Setup logging. + + // If deriving from env value fails, use config default (env overrides config file). + let config_log_level = config.log_level; + let log_level = match &env::var("DEFGUARD_CLIENT_LOG_LEVEL") { + Ok(env_value) => LevelFilter::from_str(env_value).unwrap_or(config_log_level), + Err(_) => config_log_level, + }; + app_handle.plugin( + tauri_plugin_log::Builder::new() + .format(move |out, message, record| { + out.finish(format_args!( + "{}[{}][{}] {}", + tauri_plugin_log::TimezoneStrategy::UseUtc + .get_now() + // Sets the time format. Service's logs have a subsecond part, so we + // also need to include it here, otherwise the logs couldn't be sorted + // correctly when displayed together in the UI. + .format(&time::macros::format_description!( + "[[[year]-[month]-[day]][[[hour]:[minute]:[second].[subsecond]]" + )) + .unwrap(), + record.level(), + record.target(), + message + )); + }) + .targets([ + Target::new(TargetKind::Stdout), + Target::new(TargetKind::LogDir { file_name: Some(LOG_FILENAME.to_string()) }), + ]) + .level(log_level) + .filter(|metadata| { + if metadata.level() == Level::Error { + return true; + } + if !LOG_INCLUDES.is_empty() { + for target in &*LOG_INCLUDES { + if metadata.target().contains(target) { + return true; + } + } + return false; + } + true + }) + .filter(|metadata| { + // Log all errors, warnings and infos. + let level = metadata.level(); + if level == LevelFilter::Error + || level == LevelFilter::Warn + || level == LevelFilter::Info + { + return true; + } + // Otherwise do not log these targets. + for target in &LOGGING_TARGET_IGNORE_LIST { + if metadata.target().contains(target) { + return false; + } + } + true + }) + .build(), + )?; + + // run DB migrations + async_runtime::block_on(handle_db_migrations()); + + // Check if client needs to be initialized + // and try to load provisioning config if necessary + let provisioning_config = + async_runtime::block_on(handle_client_initialization(app_handle)); + + let state = AppState::new(config, provisioning_config); + app.manage(state); + + // Pre-build windows hidden so they can be shown/hidden without recreation. + if let Err(e) = WindowManager::build_tray_window(app_handle) { + warn!("Failed to pre-build tray window: {e}"); + } + if let Err(e) = WindowManager::build_full_view_window(app_handle) { + warn!("Failed to pre-build full window: {e}"); + } + if let Err(e) = WindowManager::build_welcome_window(app_handle) { + warn!("Failed to pre-build welcome window: {e}"); + } + + // Decide which window to show based on available locations. + // If the app was cold-launched by a deep-link, the full view must open, not the + // tray. + let launched_by_deep_link = app_handle + .deep_link() + .get_current() + .ok() + .flatten() + .is_some(); + if launched_by_deep_link { + info!("App launched via deep link, opening full view directly."); + let _ = WindowManager::open_full_view(app_handle); + } else if open_welcome_view { + info!("Opening welcome view."); + let _ = WindowManager::open_welcome_view(app_handle); + } else { + show_tray_or_full_view(app_handle); + } + + info!("App setup completed, log level: {log_level}"); + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("Failed to build Tauri application"); + + info!("Starting Defguard client version {VERSION}"); + + // Run application. + debug!("Starting the main application event loop."); + app.run(|app_handle, event| match event { + // Startup tasks + RunEvent::Ready => { + let data_dir = app_handle + .path() + .app_data_dir() + .unwrap_or_else(|_| "UNDEFINED DATA DIRECTORY".into()); + let log_dir = app_handle + .path() + .app_log_dir() + .unwrap_or_else(|_| "UNDEFINED LOG DIRECTORY".into()); + + // Ensure directories have appropriate permissions (dg25-28). + #[cfg(unix)] + { + set_perms(&data_dir); + set_perms(&log_dir); + } + + info!( + "Application data (database file) will be stored in: {} and application logs in: \ + {}. Logs of the background Defguard service responsible for managing VPN \ + connections at the network level will be stored in: {}.", + data_dir.display(), + log_dir.display(), + DEFAULT_SERVICE_LOG_DIR + ); + async_runtime::block_on(startup(app_handle)); + + // Handle a deep link that launched the app (startup case). + if let Ok(Some(urls)) = app_handle.deep_link().get_current() { + handle_deep_link(app_handle, &urls); + } + + // Handle Ctrl-C. + debug!("Setting up Ctrl-C handler."); + let app_handle_clone = app_handle.clone(); + async_runtime::spawn(async move { + tokio::signal::ctrl_c() + .await + .expect("Signal handler failure"); + debug!("Ctrl-C handler: quitting the app"); + app_handle_clone.exit(0); + }); + debug!("Ctrl-C handler has been set up successfully"); + } + RunEvent::ExitRequested { code, api, .. } => { + debug!("Received exit request"); + // `code` is `None` when the exit is requested by user interaction. + if code.is_none() { + // Prevent shutdown on window close. + api.prevent_exit(); + } + } + // Handle shutdown. + RunEvent::Exit => { + debug!("Exiting the application's main event loop."); + #[cfg(target_os = "macos")] + { + let semaphore = Arc::new(AtomicBool::new(false)); + let semaphore_clone = Arc::clone(&semaphore); + + let handle = async_runtime::spawn(async move { + let _ = close_all_connections().await; + // This will clean the database file, pruning write-ahead log. + DB_POOL.close().await; + semaphore_clone.store(true, Ordering::Release); + }); + // Obj-C API needs a runtime, but at this point Tauri has closed its runtime, so + // create a temporary one. + spawn_runloop_and_wait_for(&semaphore); + async_runtime::block_on(async move { + let _ = handle.await; + }); + } + #[cfg(not(target_os = "macos"))] + { + async_runtime::block_on(async move { + let _ = close_all_connections().await; + // This will clean the database file, pruning write-ahead log. + DB_POOL.close().await; + }); + } + } + #[cfg(target_os = "macos")] + RunEvent::Reopen { + has_visible_windows, + .. + } => { + if !has_visible_windows { + show_tray_or_full_view(app_handle); + } + } + _ => { + trace!("Received event: {event:?}"); + } + }); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae09fd4e0..d55adeacc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,133 +1,60 @@ // FIXME: actually refactor errors instead #![allow(clippy::result_large_err)] -#[cfg(unix)] -use std::path::Path; -use std::{fmt, path::PathBuf}; -#[cfg(not(windows))] -use std::{ - fs::{set_permissions, Permissions}, - os::unix::fs::PermissionsExt, -}; - -use chrono::NaiveDateTime; -use semver::Version; -use serde::{Deserialize, Serialize}; -use self::database::models::{Id, NoId}; - -pub mod active_connections; -pub mod app_config; #[cfg(target_os = "macos")] pub mod apple; pub mod appstate; pub mod commands; -pub mod database; -pub mod enterprise; -pub mod error; pub mod events; +pub mod gui; pub mod log_watcher; pub mod periodic; -pub mod proto; -pub mod service; +pub mod provisioning; +pub mod session_state; +#[cfg(all(target_os = "macos", feature = "macos_installer"))] +pub mod system_extension; pub mod tray; pub mod utils; -pub mod wg_config; - -pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_SHA")); -pub const MIN_CORE_VERSION: Version = Version::new(1, 6, 0); -pub const MIN_PROXY_VERSION: Version = Version::new(1, 6, 0); -pub const CLIENT_VERSION_HEADER: &str = "defguard-client-version"; -pub const CLIENT_PLATFORM_HEADER: &str = "defguard-client-platform"; -pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); -// Must be without ".log" suffix! -pub const LOG_FILENAME: &str = "defguard-client"; -// This must match tauri.bundle.identifier from tauri.conf.json. -const BUNDLE_IDENTIFIER: &str = "net.defguard"; -// Returns the path to the user's data directory. -#[must_use] -pub fn app_data_dir() -> Option { - dirs_next::data_dir().map(|dir| dir.join(BUNDLE_IDENTIFIER)) -} +pub mod window_manager; -/// Ensures path has appropriate permissions set (dg25-28): -/// - 700 for directories -/// - 600 for files #[cfg(unix)] -pub fn set_perms(path: &Path) { - let perms = if path.is_dir() { 0o700 } else { 0o600 }; - if let Err(err) = set_permissions(path, Permissions::from_mode(perms)) { - warn!( - "Failed to set permissions on path {}: {err}", - path.display() - ); - } -} - -/// Location type used in commands to check if we using tunnel or location -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] -pub enum ConnectionType { - Tunnel, - Location, -} +pub use defguard_client_core::set_perms; +pub use defguard_client_core::{ + app_config, + app_data_dir, + connection, + database, + error, + get_aggregation, + into_location, + proxy, + version::{ + Version, CLIENT_PLATFORM_HEADER, CLIENT_VERSION_HEADER, LOG_FILENAME, MIN_CORE_VERSION, + MIN_PROXY_VERSION, + }, + wg_config, + // Shared types + CommonConnection, + CommonConnectionInfo, + CommonLocationStats, + CommonWireguardFields, + ConnectionType, + // DateTime aggregation + DateTimeAggregation, + // Constants + DEFAULT_ROUTE_IPV4, + DEFAULT_ROUTE_IPV6, +}; -impl fmt::Display for ConnectionType { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - ConnectionType::Tunnel => write!(f, "tunnel"), - ConnectionType::Location => write!(f, "location"), - } - } -} +pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_SHA")); +pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub use defguard_client_common::{check_version_flag, version_string}; #[macro_use] extern crate log; -/// Common fields for Tunnel and Location -#[derive(Debug, Serialize, Deserialize)] -pub struct CommonWireguardFields { - pub instance_id: Id, - // Native network ID from Defguard Core. - pub network_id: Id, - pub name: String, - pub address: String, - pub pubkey: String, - pub endpoint: String, - pub allowed_ips: String, - pub dns: Option, - pub route_all_traffic: bool, -} - -/// Common fields for Connection and TunnelConnection due to shared command -#[derive(Debug, Serialize, Deserialize)] -pub struct CommonConnection { - pub id: I, - pub location_id: Id, - pub start: NaiveDateTime, - pub end: NaiveDateTime, - pub connection_type: ConnectionType, -} - -// Common fields for LocationStats and TunnelStats due to shared command -#[derive(Debug, Serialize, Deserialize)] -pub struct CommonLocationStats { - pub id: I, - pub location_id: Id, - pub upload: i64, - pub download: i64, - pub last_handshake: i64, - pub collected_at: NaiveDateTime, - pub listen_port: u32, - pub persistent_keepalive_interval: Option, - pub connection_type: ConnectionType, -} - -// Common fields for ConnectionInfo and TunnelConnectionInfo due to shared command -#[derive(Debug, Serialize)] -pub struct CommonConnectionInfo { - pub id: Id, - pub location_id: Id, - pub start: NaiveDateTime, - pub end: NaiveDateTime, - pub upload: Option, - pub download: Option, +/// Converts a tauri emit result into our error type. +#[must_use] +pub fn tauri_err_to_app_err(e: tauri::Error) -> defguard_client_core::error::Error { + defguard_client_core::error::Error::Tauri(e.to_string()) } diff --git a/src-tauri/src/log_watcher/global_log_watcher.rs b/src-tauri/src/log_watcher/global_log_watcher.rs index 74ed2b82d..423b397cb 100644 --- a/src-tauri/src/log_watcher/global_log_watcher.rs +++ b/src-tauri/src/log_watcher/global_log_watcher.rs @@ -30,7 +30,7 @@ use crate::{ LOG_FILENAME, }; #[cfg(not(target_os = "macos"))] -use crate::{log_watcher::extract_timestamp, utils::get_service_log_dir}; +use crate::{log_watcher::extract_timestamp, utils::DEFAULT_SERVICE_LOG_DIR}; #[cfg(target_os = "macos")] pub(crate) const VPN_EXTENSION_LOG_FILENAME: &str = "vpn-extension.log"; @@ -56,7 +56,7 @@ impl LogDirs { pub fn new(handle: &AppHandle) -> Result { debug!("Getting log directories for service and client to watch."); #[cfg(not(target_os = "macos"))] - let service_log_dir = get_service_log_dir().to_path_buf(); + let service_log_dir = std::path::Path::new(DEFAULT_SERVICE_LOG_DIR).to_path_buf(); let client_log_dir = handle.path().app_log_dir().map_err(|_| { LogWatcherError::LogPathError("Path to client logs directory is empty.".to_string()) })?; @@ -91,8 +91,7 @@ impl LogDirs { /// Find the latest log file in directory for the service /// - /// Log files are rotated daily and have a known naming format, - /// with the last 10 characters specifying a date (e.g. `2023-12-15`). + /// Log files are rotated daily and include the date in their filename. #[cfg(not(target_os = "macos"))] fn get_latest_log_file(&self) -> Result, LogWatcherError> { debug!( @@ -637,7 +636,7 @@ pub async fn spawn_global_log_watcher_task( let app_state = handle.state::(); // Show logs only from the last hour - let from = Some(Utc::now() - Duration::from_secs(60 * 60)); + let from = Some(Utc::now() - Duration::from_hours(1)); let event_topic = "log-update-global".to_string(); diff --git a/src-tauri/src/log_watcher/mod.rs b/src-tauri/src/log_watcher/mod.rs index b2c5bb8d3..9dc23e356 100644 --- a/src-tauri/src/log_watcher/mod.rs +++ b/src-tauri/src/log_watcher/mod.rs @@ -9,6 +9,9 @@ use serde_with::{serde_as, DisplayFromStr}; use thiserror::Error; use tracing::Level; +const SERVICE_LOG_PREFIX: &str = "defguard-service."; +const SERVICE_LOG_SUFFIX: &str = ".log"; + pub mod global_log_watcher; pub mod service_log_watcher; @@ -68,10 +71,9 @@ struct LogLineFields { fn extract_timestamp(filename: &str) -> Option { trace!("Extracting timestamp from log file name: {filename}"); - // we know that the date is always in the last 10 characters - let split_pos = filename.char_indices().nth_back(9)?.0; - let timestamp = &filename[split_pos..]; - // parse and convert to `NaiveDate` + let timestamp = filename + .strip_prefix(SERVICE_LOG_PREFIX)? + .strip_suffix(SERVICE_LOG_SUFFIX)?; NaiveDate::parse_from_str(timestamp, "%Y-%m-%d").ok() } diff --git a/src-tauri/src/log_watcher/service_log_watcher.rs b/src-tauri/src/log_watcher/service_log_watcher.rs index 175fe696f..280576796 100644 --- a/src-tauri/src/log_watcher/service_log_watcher.rs +++ b/src-tauri/src/log_watcher/service_log_watcher.rs @@ -27,7 +27,7 @@ use tracing::Level; use super::LogLineFields; use super::{LogLine, LogWatcherError}; #[cfg(not(target_os = "macos"))] -use crate::utils::get_service_log_dir; +use crate::utils::DEFAULT_SERVICE_LOG_DIR; use crate::{ appstate::AppState, database::models::Id, error::Error, log_watcher::extract_timestamp, utils::get_tunnel_or_location_name, ConnectionType, @@ -183,8 +183,7 @@ impl<'a> ServiceLogWatcher<'a> { /// Find the latest log file in directory /// - /// Log files are rotated daily and have a knows naming format, - /// with the last 10 characters specifying a date (e.g. `2023-12-15`). + /// Log files are rotated daily and include the date in their filename. fn get_latest_log_file(&self) -> Result, LogWatcherError> { trace!( "Getting latest log file from directory: {}", @@ -428,7 +427,7 @@ pub async fn spawn_log_watcher_task( // prepare cancellation token let token = CancellationToken::new(); - let log_dir = get_service_log_dir(); // get log file directory + let log_dir = Path::new(DEFAULT_SERVICE_LOG_DIR); let mut log_watcher = ServiceLogWatcher::new( handle.clone(), token.clone(), diff --git a/src-tauri/src/periodic/config.rs b/src-tauri/src/periodic/config.rs new file mode 100644 index 000000000..3380376ab --- /dev/null +++ b/src-tauri/src/periodic/config.rs @@ -0,0 +1,235 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::{LazyLock, Mutex}, + time::Duration, +}; + +pub use defguard_client_config_sync::commands::{ + disable_enterprise_features, do_update_instance, locations_changed, sync_service_locations, + sync_service_locations_best_effort, +}; +use defguard_client_config_sync::{ + poll_instance, poll_instances, PollInstanceResult, VersionMismatchPayload, +}; +use defguard_client_core::{ + connection::active_connections::{active_connections, ACTIVE_CONNECTIONS}, + database::{ + models::{instance::Instance, location::Location, Id}, + DB_POOL, + }, + error::Error, + events::EventKey, + ConnectionType, +}; +use log::{debug, error, info}; +use sqlx::{Sqlite, Transaction}; +use tauri::{AppHandle, Emitter}; +use tokio::time::sleep; + +use crate::{commands::disconnect_all_tunnels, events::TunnelsEnabledPayload}; + +const INTERVAL_SECONDS: Duration = Duration::from_secs(30); + +/// Tracks instance IDs for which we already sent a version-mismatch notification, +/// to prevent duplicate notifications in the app's lifetime. +static NOTIFIED_INSTANCES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + +/// Periodically retrieves and updates configuration for all [`Instance`]s. +/// Updates are only performed if no connections are established to the [`Instance`], +/// otherwise event is emitted and UI message is displayed. +pub async fn poll_config(handle: AppHandle) { + debug!("Starting the configuration polling loop."); + // Polling starts sooner than app's frontend may load in dev builds, causing events (toasts) + // to be lost; you may want to wait here before starting if you want to debug it. + let mut last_tunnels_disabled = false; + loop { + let active_instance_ids = match active_instance_ids().await { + Ok(ids) => ids, + Err(err) => { + error!( + "Failed to detect active instances for config polling, retrying in {}s: {err}", + INTERVAL_SECONDS.as_secs() + ); + sleep(INTERVAL_SECONDS).await; + continue; + } + }; + + let outcomes = match poll_instances(&DB_POOL, &active_instance_ids).await { + Ok(outcomes) => outcomes, + Err(err) => { + error!( + "Failed to poll instance configuration, retrying in {}s: {err}", + INTERVAL_SECONDS.as_secs() + ); + sleep(INTERVAL_SECONDS).await; + continue; + } + }; + + debug!( + "Found {} instances with a config polling token, processed configuration polling.", + outcomes.len() + ); + + let mut config_retrieved = 0; + for outcome in outcomes { + let instance_name = outcome.instance_name; + let instance_id = outcome.instance_id; + match outcome.result { + Ok(result) => { + config_retrieved += 1; + emit_version_mismatch(&handle, instance_id, version_mismatch(&result)); + emit_poll_result_events(&handle, instance_id, &instance_name, result); + debug!( + "Finished processing configuration polling request for instance {instance_name}(ID: {instance_id})" + ); + } + Err(Error::CoreNotEnterprise) => { + debug!( + "Tried to contact core for instance {instance_name}(ID: {instance_id}) config but it's not enterprise, can't retrieve config" + ); + } + Err(Error::NoToken) => { + debug!( + "Instance {instance_name}(ID: {instance_id}) has no token, can't retrieve its config from the core", + ); + } + Err(err) => { + error!( + "Failed to retrieve instance {instance_name}(ID: {instance_id}) config from core: {err}" + ); + } + } + } + + if let Err(err) = handle.emit(EventKey::InstanceUpdate.into(), ()) { + error!("Failed to emit instance update event to the frontend: {err}"); + } + + let currently_disabled = Instance::tunnels_disabled(&*DB_POOL).await.unwrap_or(false); + + match (last_tunnels_disabled, currently_disabled) { + (false, true) => { + info!("Tunnels disabled by server administrator, disconnecting any active tunnels"); + if let Err(err) = disconnect_all_tunnels(&handle).await { + error!("Failed to disconnect tunnels after tunnels were disabled: {err}"); + } + } + (true, false) => { + info!("Tunnels re-enabled by server administrator"); + TunnelsEnabledPayload::emit(&handle); + } + _ => {} + } + last_tunnels_disabled = currently_disabled; + + if config_retrieved > 0 { + info!( + "Automatically retrieved the newest instance configuration from core for {config_retrieved} instances, sleeping for {}s", + INTERVAL_SECONDS.as_secs(), + ); + } else { + debug!( + "No configuration updates retrieved, sleeping {}s", + INTERVAL_SECONDS.as_secs(), + ); + } + sleep(INTERVAL_SECONDS).await; + } +} + +/// Retrieves configuration for a given [`Instance`]. +/// Updates the instance if there aren't any active connections, otherwise emits +/// a ConfigChanged event so the frontend can prompt the user to reconnect. +pub async fn poll_instance_with_events( + transaction: &mut Transaction<'_, Sqlite>, + instance: &mut Instance, + handle: &AppHandle, +) -> Result<(), Error> { + let has_active_connections = !active_connections(instance).await?.is_empty(); + let result = poll_instance(transaction, instance, has_active_connections).await?; + + emit_version_mismatch(handle, instance.id, version_mismatch(&result)); + emit_poll_result_events(handle, instance.id, &instance.name, result); + + Ok(()) +} + +fn emit_version_mismatch( + handle: &AppHandle, + instance_id: Id, + payload: Option<&VersionMismatchPayload>, +) { + if let Some(payload) = payload { + let mut notified_instances = NOTIFIED_INSTANCES.lock().unwrap(); + if notified_instances.insert(instance_id) { + if let Err(err) = handle.emit(EventKey::VersionMismatch.into(), payload.clone()) { + error!("Failed to emit version mismatch event to the frontend: {err}"); + // Remove so we can retry next cycle. + notified_instances.remove(&instance_id); + } + } + } +} + +fn emit_poll_result_events( + handle: &AppHandle, + instance_id: Id, + instance_name: &str, + result: PollInstanceResult, +) { + match result { + PollInstanceResult::Unchanged { .. } => {} + PollInstanceResult::Updated { + locations_changed, .. + } => { + if locations_changed { + if let Err(err) = handle.emit(EventKey::InstanceUpdated.into(), ()) { + error!("Failed to emit instance-updated event: {err}"); + } + } + } + PollInstanceResult::ChangedWhileActive { .. } => { + debug!("Emitting config-changed event for instance {instance_name}({instance_id})"); + let _ = handle.emit(EventKey::ConfigChanged.into(), instance_name); + info!("Emitted config-changed event for instance {instance_name}({instance_id})"); + } + } +} + +fn version_mismatch(result: &PollInstanceResult) -> Option<&VersionMismatchPayload> { + match result { + PollInstanceResult::Unchanged { version_mismatch } + | PollInstanceResult::Updated { + version_mismatch, .. + } + | PollInstanceResult::ChangedWhileActive { version_mismatch } => version_mismatch.as_ref(), + } +} + +async fn active_instance_ids() -> Result, Error> { + let active_location_ids = ACTIVE_CONNECTIONS + .lock() + .await + .iter() + .filter(|connection| connection.connection_type == ConnectionType::Location) + .map(|connection| connection.location_id) + .collect::>(); + + if active_location_ids.is_empty() { + return Ok(HashSet::new()); + } + + let location_instances = Location::all(&*DB_POOL, false) + .await? + .into_iter() + .map(|location| (location.id, location.instance_id)) + .collect::>(); + + Ok(active_location_ids + .into_iter() + .filter_map(|location_id| location_instances.get(&location_id).copied()) + .collect()) +} diff --git a/src-tauri/src/periodic/connection.rs b/src-tauri/src/periodic/connection.rs index 38d0e0707..96ebd4418 100644 --- a/src-tauri/src/periodic/connection.rs +++ b/src-tauri/src/periodic/connection.rs @@ -1,11 +1,11 @@ use std::time::Duration; use chrono::{NaiveDateTime, TimeDelta, Utc}; +use defguard_client_core::connection::active_connections::ACTIVE_CONNECTIONS; use tauri::{AppHandle, Manager}; use tokio::time::interval; use crate::{ - active_connections::ACTIVE_CONNECTIONS, appstate::AppState, commands::{connect, disconnect}, database::{ @@ -49,7 +49,7 @@ async fn reconnect( peer_alive_period: peer_alive_period.num_seconds(), }; payload.emit(app_handle); - match connect(con_id, con_type, None, app_handle.clone()).await { + match connect(con_id, con_type, app_handle.clone()).await { Ok(()) => { info!("Reconnect for {con_type} {con_interface_name} ({con_id}) succeeded."); } diff --git a/src-tauri/src/periodic/mod.rs b/src-tauri/src/periodic/mod.rs index 37daff37c..dedc7b2a6 100644 --- a/src-tauri/src/periodic/mod.rs +++ b/src-tauri/src/periodic/mod.rs @@ -2,10 +2,11 @@ use tauri::AppHandle; use tokio::select; use self::{ - connection::verify_active_connections, purge_stats::purge_stats, version::poll_version, + config::poll_config, connection::verify_active_connections, purge_stats::purge_stats, + version::poll_version, }; -use crate::enterprise::periodic::config::poll_config; +pub mod config; pub mod connection; pub mod purge_stats; pub mod version; diff --git a/src-tauri/src/periodic/purge_stats.rs b/src-tauri/src/periodic/purge_stats.rs index 81fafd063..c8ca24945 100644 --- a/src-tauri/src/periodic/purge_stats.rs +++ b/src-tauri/src/periodic/purge_stats.rs @@ -7,8 +7,7 @@ use crate::database::{ DB_POOL, }; -// 12 hours -const PURGE_INTERVAL: Duration = Duration::from_secs(12 * 60 * 60); +const PURGE_INTERVAL: Duration = Duration::from_hours(12); /// Periodically purges location and tunnel stats. /// diff --git a/src-tauri/src/periodic/version.rs b/src-tauri/src/periodic/version.rs index 9b8b9c109..b58c5c13b 100644 --- a/src-tauri/src/periodic/version.rs +++ b/src-tauri/src/periodic/version.rs @@ -5,7 +5,7 @@ use tokio::time::interval; use crate::{appstate::AppState, commands::get_latest_app_version, events::EventKey}; -const INTERVAL_IN_SECONDS: Duration = Duration::from_secs(12 * 60 * 60); // 12 hours +const INTERVAL_IN_SECONDS: Duration = Duration::from_hours(12); pub async fn poll_version(app_handle: AppHandle) { debug!("Starting the latest application version polling loop."); diff --git a/src-tauri/src/proto.rs b/src-tauri/src/proto.rs deleted file mode 100644 index ad0417094..000000000 --- a/src-tauri/src/proto.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::database::models::{ - location::{Location, LocationMfaMode as MfaMode, ServiceLocationMode as SLocationMode}, - Id, NoId, -}; - -tonic::include_proto!("defguard.proxy"); - -impl DeviceConfig { - #[must_use] - pub(crate) fn into_location(self, instance_id: Id) -> Location { - let location_mfa_mode = match self.location_mfa_mode { - Some(_location_mfa_mode) => self.location_mfa_mode().into(), - None => { - // handle legacy core response - // DEPRECATED(1.5): superseeded by location_mfa_mode - #[allow(deprecated)] - if self.mfa_enabled { - MfaMode::Internal - } else { - MfaMode::Disabled - } - } - }; - - let service_location_mode = match self.service_location_mode { - Some(_service_location_mode) => self.service_location_mode().into(), - None => SLocationMode::Disabled, // Default to disabled if not set - }; - - Location { - id: NoId, - instance_id, - network_id: self.network_id, - name: self.network_name, - address: self.assigned_ip, // Transforming assigned_ip to address - pubkey: self.pubkey, - endpoint: self.endpoint, - allowed_ips: self.allowed_ips, - dns: self.dns, - route_all_traffic: false, - keepalive_interval: self.keepalive_interval.into(), - location_mfa_mode, - service_location_mode, - } - } -} diff --git a/src-tauri/src/provisioning.rs b/src-tauri/src/provisioning.rs new file mode 100644 index 000000000..22eb71edf --- /dev/null +++ b/src-tauri/src/provisioning.rs @@ -0,0 +1,41 @@ +use defguard_client_core::database::{models::instance::Instance, DB_POOL}; +use defguard_client_provisioning::{try_get_provisioning_config, ProvisioningConfig}; +use tauri::{AppHandle, Manager}; + +/// Checks if the client has already been initialized +/// and tries to load provisioning config from file if necessary. +pub async fn handle_client_initialization(app_handle: &AppHandle) -> Option { + match Instance::all(&*DB_POOL).await { + Ok(instances) => { + if instances.is_empty() { + debug!( + "Client has not been initialized yet. Checking if provisioning config exists" + ); + let data_dir = app_handle + .path() + .app_data_dir() + .unwrap_or_else(|_| "UNDEFINED DATA DIRECTORY".into()); + match try_get_provisioning_config(&data_dir) { + Some(config) => { + info!( + "Provisioning config found in {}: {config:?}", + data_dir.display() + ); + return Some(config); + } + None => { + debug!( + "Provisioning config not found in {}. Proceeding with normal startup.", + data_dir.display() + ); + } + } + } + } + Err(err) => { + error!("Failed to verify if the client has already been initialized: {err}"); + } + } + + None +} diff --git a/src-tauri/src/service/client.rs b/src-tauri/src/service/client.rs deleted file mode 100644 index abf9afb14..000000000 --- a/src-tauri/src/service/client.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::sync::LazyLock; - -use hyper_util::rt::TokioIo; -#[cfg(windows)] -use tokio::net::windows::named_pipe::ClientOptions; -#[cfg(unix)] -use tokio::net::UnixStream; -use tonic::transport::channel::{Channel, Endpoint}; -#[cfg(unix)] -use tonic::transport::Uri; -use tower::service_fn; -#[cfg(windows)] -use windows_sys::Win32::Foundation::ERROR_PIPE_BUSY; - -#[cfg(unix)] -use super::daemon::DAEMON_SOCKET_PATH; -#[cfg(windows)] -use super::named_pipe::PIPE_NAME; -use super::proto::desktop_daemon_service_client::DesktopDaemonServiceClient; - -pub(crate) static DAEMON_CLIENT: LazyLock> = - LazyLock::new(|| { - debug!("Setting up gRPC client"); - // URL is ignored since we provide our own connectors for unix socket and windows named pipes. - let endpoint = Endpoint::from_static("http://localhost"); - let channel; - #[cfg(unix)] - { - channel = endpoint.connect_with_connector_lazy(service_fn(|_: Uri| async { - // Connect to a Unix domain socket. - let stream = match UnixStream::connect(DAEMON_SOCKET_PATH).await { - Ok(stream) => stream, - Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { - error!( - "Permission denied for UNIX domain socket; please refer to \ - https://docs.defguard.net/support-1/troubleshooting#\ - unix-socket-permission-errors-when-desktop-client-attempts-to-connect-\ - to-vpn-on-linux-machines" - ); - return Err(err); - } - Err(err) => { - error!("Problem connecting to UNIX domain socket: {err}"); - return Err(err); - } - }; - info!("Created unix gRPC client"); - Ok::<_, std::io::Error>(TokioIo::new(stream)) - })); - }; - #[cfg(windows)] - { - channel = endpoint.connect_with_connector_lazy(service_fn(|_| async { - let client = loop { - match ClientOptions::new().open(PIPE_NAME) { - Ok(client) => break client, - Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => (), - Err(err) => { - error!("Problem connecting to named pipe: {err}"); - return Err(err); - } - } - }; - info!("Created windows gRPC client"); - Ok::<_, std::io::Error>(TokioIo::new(client)) - })); - } - DesktopDaemonServiceClient::new(channel) - }); diff --git a/src-tauri/src/service/config.rs b/src-tauri/src/service/config.rs deleted file mode 100644 index e2f8528b1..000000000 --- a/src-tauri/src/service/config.rs +++ /dev/null @@ -1,23 +0,0 @@ -use clap::Parser; - -#[cfg(windows)] -pub const DEFAULT_LOG_DIR: &str = "/Logs/defguard-service"; -#[cfg(not(windows))] -pub const DEFAULT_LOG_DIR: &str = "/var/log/defguard-service"; - -#[derive(Debug, Parser, Clone)] -#[clap(about = "Defguard VPN client interface management service")] -#[command(version)] -pub struct Config { - /// Configures log level of defguard service logs - #[arg(long, env = "DEFGUARD_LOG_LEVEL", default_value = "info")] - pub log_level: String, - - /// Configures logging directory; it is meant for debugging only, so hide it. - #[arg(long, env = "DEFGUARD_LOG_DIR", default_value = DEFAULT_LOG_DIR, hide = true)] - pub log_dir: String, - - /// Defines how often (in seconds) interface statistics are sent to defguard client - #[arg(long, short = 'p', env = "DEFGUARD_STATS_PERIOD", default_value = "10")] - pub stats_period: u64, -} diff --git a/src-tauri/src/service/daemon.rs b/src-tauri/src/service/daemon.rs deleted file mode 100644 index 81761ceb0..000000000 --- a/src-tauri/src/service/daemon.rs +++ /dev/null @@ -1,567 +0,0 @@ -use std::{ - collections::HashMap, - pin::Pin, - sync::{Arc, Mutex, RwLock}, - time::{Duration, SystemTime}, -}; -#[cfg(unix)] -use std::{fs, os::unix::fs::PermissionsExt, path::Path}; - -use common::dns_borrow; -use defguard_wireguard_rs::{ - error::WireguardInterfaceError, InterfaceConfiguration, Kernel, WGApi, WireguardInterfaceApi, -}; -#[cfg(unix)] -use nix::unistd::{chown, Group}; -#[cfg(unix)] -use tokio::net::UnixListener; -use tokio::{sync::mpsc, task::JoinHandle, time::interval}; -#[cfg(unix)] -use tokio_stream::wrappers::UnixListenerStream; -use tonic::{ - codegen::tokio_stream::{wrappers::ReceiverStream, Stream}, - transport::Server, - Code, Response, Status, -}; -use tracing::{debug, error, info, info_span, Instrument}; - -use super::{ - config::Config, - proto::{ - desktop_daemon_service_server::{DesktopDaemonService, DesktopDaemonServiceServer}, - CreateInterfaceRequest, InterfaceData, ReadInterfaceDataRequest, RemoveInterfaceRequest, - }, -}; -#[cfg(windows)] -use crate::enterprise::service_locations::ServiceLocationManager; -#[cfg(windows)] -use crate::service::named_pipe::{get_named_pipe_server_stream, PIPE_NAME}; -use crate::{ - enterprise::service_locations::ServiceLocationError, - service::proto::{DeleteServiceLocationsRequest, SaveServiceLocationsRequest}, - VERSION, -}; - -#[cfg(unix)] -pub(super) const DAEMON_SOCKET_PATH: &str = "/var/run/defguard.socket"; - -#[cfg(target_os = "linux")] -pub(super) const DAEMON_SOCKET_GROUP: &str = "defguard"; - -#[derive(Debug, thiserror::Error)] -pub enum DaemonError { - #[error(transparent)] - WireguardError(#[from] WireguardInterfaceError), - #[error("Unexpected error: {0}")] - Unexpected(String), - #[error(transparent)] - TransportError(#[from] tonic::transport::Error), - #[error(transparent)] - ServiceLocationError(#[from] ServiceLocationError), - #[cfg(windows)] - #[error(transparent)] - WindowsServiceError(#[from] windows_service::Error), -} - -type IfName = String; -#[cfg(not(target_os = "macos"))] -type WG = WGApi; -#[cfg(target_os = "macos")] -type WG = WGApi; - -#[derive(Default)] -pub(crate) struct DaemonService { - // Map of running `WGApi`s; key is interface name. - wgapis: Arc>>, - stats_period: Duration, - stat_tasks: Arc>>>, - #[cfg(windows)] - service_location_manager: Arc>, -} - -impl DaemonService { - #[must_use] - pub fn new( - config: &Config, - #[cfg(windows)] service_location_manager: Arc>, - ) -> Self { - Self { - wgapis: Arc::new(RwLock::new(HashMap::new())), - stats_period: Duration::from_secs(config.stats_period), - stat_tasks: Arc::new(Mutex::new(HashMap::new())), - #[cfg(windows)] - service_location_manager, - } - } -} - -/// Helper function used to perform required configuration steps for a new interface. -/// -/// This allows us to roll back interface creation if some configuration step fails. -fn configure_new_interface( - ifname: &str, - request: &CreateInterfaceRequest, - wgapi: &mut WGApi, - interface_config: &InterfaceConfiguration, -) -> Result<(), Status> { - // The WireGuard DNS config value can be a list of IP addresses and domain names, which will - // be used as DNS servers and search domains respectively. - debug!("Preparing DNS configuration for interface {ifname}"); - let (dns, search_domains) = dns_borrow(&request.dns); - debug!( - "DNS configuration for interface {ifname}: DNS: {dns:?}, Search domains: \ - {search_domains:?}" - ); - - let configure_interface_result = wgapi.configure_interface(interface_config); - - configure_interface_result.map_err(|err| { - let msg = format!("Failed to configure WireGuard interface {ifname}: {err}"); - error!("{msg}"); - Status::new(Code::Internal, msg) - })?; - - #[cfg(not(windows))] - { - debug!("Configuring interface {ifname} routing"); - wgapi - .configure_peer_routing(&interface_config.peers) - .map_err(|err| { - let msg = - format!("Failed to configure routing for WireGuard interface {ifname}: {err}"); - error!("{msg}"); - Status::new(Code::Internal, msg) - })?; - } - if dns.is_empty() { - debug!( - "No DNS configuration provided for interface {ifname}, skipping DNS \ - configuration" - ); - } else { - debug!( - "The following DNS servers will be set: {dns:?}, search domains: \ - {search_domains:?}" - ); - wgapi.configure_dns(&dns, &search_domains).map_err(|err| { - let msg = format!("Failed to configure DNS for WireGuard interface {ifname}: {err}"); - error!("{msg}"); - Status::new(Code::Internal, msg) - })?; - } - - Ok(()) -} - -type InterfaceDataStream = Pin> + Send>>; - -pub(crate) fn setup_wgapi(ifname: &str) -> Result { - let wgapi = WG::new(ifname).map_err(|err| { - let msg = format!("Failed to setup WireGuard API for interface {ifname}: {err}"); - error!("{msg}"); - Status::new(Code::Internal, msg) - })?; - - Ok(wgapi) -} - -#[tonic::async_trait] -impl DesktopDaemonService for DaemonService { - type ReadInterfaceDataStream = InterfaceDataStream; - - #[cfg(not(windows))] - async fn save_service_locations( - &self, - _request: tonic::Request, - ) -> Result, Status> { - debug!("Save service location request received, this is currently not supported on Unix systems"); - Ok(Response::new(())) - } - - #[cfg(not(windows))] - async fn delete_service_locations( - &self, - _request: tonic::Request, - ) -> Result, Status> { - debug!("Delete service location request received, this is currently not supported on Unix systems"); - Ok(Response::new(())) - } - - #[cfg(windows)] - async fn save_service_locations( - &self, - request: tonic::Request, - ) -> Result, Status> { - debug!("Received a request to save service location"); - let service_location = request.into_inner(); - - match self - .service_location_manager - .clone() - .read() - .unwrap() - .save_service_locations( - service_location.service_locations.as_slice(), - &service_location.instance_id, - &service_location.private_key, - ) { - Ok(()) => { - debug!("Service location saved successfully"); - } - Err(e) => { - let msg = format!("Failed to save service location: {e}"); - error!(msg); - return Err(Status::internal(msg)); - } - } - - for saved_location in service_location.service_locations { - match self - .service_location_manager - .clone() - .write() - .unwrap() - .reset_service_location_state(&service_location.instance_id, &saved_location.pubkey) - { - Ok(()) => { - debug!( - "Service location '{}' state reset successfully", - saved_location.name - ); - } - Err(e) => { - error!( - "Failed to reset state for service location '{}': {e}", - saved_location.name - ); - } - } - } - - Ok(Response::new(())) - } - - #[cfg(windows)] - async fn delete_service_locations( - &self, - request: tonic::Request, - ) -> Result, Status> { - debug!("Received a request to delete service location"); - let instance_id = request.into_inner().instance_id; - - self.service_location_manager - .clone() - .write() - .unwrap() - .disconnect_service_locations_by_instance(&instance_id) - .map_err(|err| { - let msg = format!("Failed to disconnect service location: {err}"); - error!(msg); - Status::internal(msg) - })?; - - match self - .service_location_manager - .clone() - .read() - .unwrap() - .delete_all_service_locations_for_instance(&instance_id) - { - Ok(()) => { - debug!("Service location deleted successfully"); - Ok(Response::new(())) - } - Err(err) => { - error!("Failed to delete service location: {err}"); - Err(Status::internal(format!( - "Failed to delete service location: {err}" - ))) - } - } - } - - async fn create_interface( - &self, - request: tonic::Request, - ) -> Result, Status> { - debug!("Received a request to create a new interface"); - let request = request.into_inner(); - let config: InterfaceConfiguration = request - .config - .clone() - .ok_or(Status::new( - Code::InvalidArgument, - "Missing interface config in request", - ))? - .into(); - let ifname = &config.name; - let _span = info_span!("create_interface", interface_name = &ifname).entered(); - // Setup WireGuard API. - let Ok(mut wgapis_map) = self.wgapis.write() else { - error!("Failed to acquire read-write lock for WGApis"); - return Err(Status::new(Code::Internal, "read-write lock error")); - }; - let wgapi = wgapis_map - .entry(ifname.clone()) - .or_insert(setup_wgapi(ifname)?); - - // create new interface - debug!("Creating new interface {ifname}"); - wgapi.create_interface().map_err(|err| { - let msg = format!("Failed to create WireGuard interface {ifname}: {err}"); - error!("{msg}"); - Status::new(Code::Internal, msg) - })?; - info!("Done creating a new interface {ifname}"); - - // attempt to configure new interface - // remove interface if configuration fails to avoid duplicate interfaces - match configure_new_interface(ifname, &request, wgapi, &config) { - Ok(()) => info!("Finished configuring new interface {ifname}"), - Err(err) => { - error!("Failed to configure interface {ifname}. Error: {err}"); - - debug!("Removing newly created interface {ifname} due to configuration failure"); - wgapi.remove_interface().map_err(|err| { - let msg = format!("Failed to remove WireGuard interface {ifname}: {err}"); - error!("{msg}"); - Status::new(Code::Internal, msg) - })?; - - return Err(err); - } - } - - debug!("Finished creating a new interface {ifname}"); - Ok(Response::new(())) - } - - async fn remove_interface( - &self, - request: tonic::Request, - ) -> Result, Status> { - debug!("Received a request to remove an interface"); - let request = request.into_inner(); - let ifname = request.interface_name; - let _span = info_span!("remove_interface", interface_name = &ifname).entered(); - debug!("Removing interface {ifname}"); - - // Stop stats task. - if let Ok(mut tasks) = self.stat_tasks.lock() { - if let Some(handle) = tasks.remove(&ifname) { - info!("Stopping statistics collector task for interface {ifname}"); - handle.abort(); - } - } - - // `WGApi::remove_interface`` takes `&mut self` under Windows. - #[allow(unused_mut)] - let mut wgapi = { - let Ok(mut wgapis_map) = self.wgapis.write() else { - error!("Failed to acquire read-write lock for WGApis"); - return Err(Status::new(Code::Internal, "read-write lock error")); - }; - let Some(wgapi) = wgapis_map.remove(&ifname) else { - error!("Unknown interface {ifname}"); - return Err(Status::new(Code::Internal, "unknown interface")); - }; - wgapi - }; - - #[cfg(not(windows))] - { - debug!("Cleaning up interface {ifname} routing"); - // Ignore error as this should not be considered fatal, - // e.g. endpoint might fail to resolve DNS name. - if let Err(err) = wgapi.remove_endpoint_routing(&request.endpoint) { - error!( - "Failed to remove routing for endpoint {}: {err}", - request.endpoint - ); - } - } - - wgapi.remove_interface().map_err(|err| { - let msg = format!("Failed to remove WireGuard interface {ifname}: {err}"); - error!("{msg}"); - Status::new(Code::Internal, msg) - })?; - - debug!("Finished removing interface {ifname}"); - Ok(Response::new(())) - } - - async fn read_interface_data( - &self, - request: tonic::Request, - ) -> Result, Status> { - let request = request.into_inner(); - let ifname = request.interface_name.clone(); - debug!( - "Received a request to start a new network usage stats data stream for interface \ - {ifname}" - ); - let span = info_span!("read_interface_data", interface_name = &ifname); - - let wgapis = Arc::clone(&self.wgapis); - let mut interval = interval(self.stats_period); - let (tx, rx) = mpsc::channel(64); - - span.in_scope(|| { - info!("Spawning statistics collector task for interface {ifname}"); - }); - let handle = tokio::spawn( - async move { - // Helper map to track if peer data is actually changing to avoid sending duplicate - // stats. - let mut peer_map = HashMap::new(); - - loop { - // Loop delay - interval.tick().await; - debug!( - "Gathering network usage statistics for client's network activity on {ifname}"); - let result = { - let Ok(wgapis_map) = wgapis.read() else { - error!("Failed to acquire read-write lock for WGApis"); - break; - }; - let Some(wgapi) = wgapis_map.get(&ifname) else { - error!("Unknown interface {ifname}"); - break; - }; - wgapi.read_interface_data() - }; - match result { - Ok(mut host) => { - let peers = &mut host.peers; - debug!( - "Found {} peers configured on WireGuard interface", - peers.len() - ); - // Filter out never connected peers. - peers.retain(|_, peer| { - // Last handshake time-stamp must exist. - if let Some(last_hs) = peer.last_handshake { - // ...and not be UNIX epoch. - if last_hs != SystemTime::UNIX_EPOCH - && match peer_map.get(&peer.public_key) { - Some(last_peer) => last_peer != peer, - None => true, - } - { - debug!( - "Peer {} statistics changed; keeping it.", - peer.public_key - ); - peer_map.insert(peer.public_key.clone(), peer.clone()); - return true; - } - } - debug!( - "Peer {} statistics didn't change; ignoring it.", - peer.public_key - ); - false - }); - if let Err(err) = tx.send(Ok(host.into())).await { - error!( - "Couldn't send network usage stats update for {ifname}: {err}" - ); - break; - } - } - Err(err) => { - error!( - "Failed to retrieve network usage stats for interface {ifname}: \ - {err}" - ); - break; - } - } - debug!("Network activity statistics for interface {ifname} sent to the client"); - } - debug!( - "The client has disconnected from the network usage statistics data stream \ - for interface {ifname}, stopping the statistics data collection task." - ); - } - .instrument(span), - ); - if let Ok(mut tasks) = self.stat_tasks.lock() { - tasks.insert(request.interface_name, handle); - } - - let output_stream = ReceiverStream::new(rx); - Ok(Response::new( - Box::pin(output_stream) as Self::ReadInterfaceDataStream - )) - } -} - -#[cfg(unix)] -pub async fn run_server(config: Config) -> anyhow::Result<()> { - debug!("Starting Defguard interface management daemon"); - - let daemon_service = DaemonService::new(&config); - - // Remove existing socket if it exists - if Path::new(DAEMON_SOCKET_PATH).exists() { - debug!("Removing existing socket file at {DAEMON_SOCKET_PATH}"); - fs::remove_file(DAEMON_SOCKET_PATH)?; - } - - debug!("Binding socket file at {DAEMON_SOCKET_PATH}"); - let uds = UnixListener::bind(DAEMON_SOCKET_PATH)?; - - // change owner group for socket file - // get the group ID by name - let group = Group::from_name(DAEMON_SOCKET_GROUP)?.ok_or_else(|| { - error!("Group '{DAEMON_SOCKET_GROUP}' not found"); - crate::error::Error::InternalError(format!("Group '{DAEMON_SOCKET_GROUP}' not found")) - })?; - - // change ownership - keep current user, change group - debug!("Changing owner group of socket file at {DAEMON_SOCKET_PATH} to group {DAEMON_SOCKET_GROUP}"); - chown(DAEMON_SOCKET_PATH, None, Some(group.gid))?; - - // Set socket permissions to allow client access - // 0o660 allows read/write for owner and group only - debug!("Setting permissions for socket file at {DAEMON_SOCKET_PATH} to 0x660"); - fs::set_permissions(DAEMON_SOCKET_PATH, fs::Permissions::from_mode(0o660))?; - - let uds_stream = UnixListenerStream::new(uds); - - info!("Defguard daemon version {VERSION} started, listening on socket {DAEMON_SOCKET_PATH}",); - debug!("Defguard daemon configuration: {config:?}"); - - Server::builder() - .trace_fn(|_| tracing::info_span!("defguard_service")) - .add_service(DesktopDaemonServiceServer::new(daemon_service)) - .serve_with_incoming(uds_stream) - .await?; - - Ok(()) -} - -#[cfg(windows)] -pub(crate) async fn run_server( - config: Config, - service_location_manager: Arc>, -) -> anyhow::Result<()> { - debug!("Starting Defguard interface management daemon"); - - let stream = get_named_pipe_server_stream(); - let daemon_service = DaemonService::new(&config, service_location_manager); - - info!("Defguard daemon version {VERSION} started, listening on named pipe {PIPE_NAME}"); - debug!("Defguard daemon configuration: {config:?}"); - - Server::builder() - .trace_fn(|_| tracing::info_span!("defguard_service")) - .add_service(DesktopDaemonServiceServer::new(daemon_service)) - .serve_with_incoming(stream) - .await?; - - Ok(()) -} diff --git a/src-tauri/src/service/mod.rs b/src-tauri/src/service/mod.rs deleted file mode 100644 index 4d617432a..000000000 --- a/src-tauri/src/service/mod.rs +++ /dev/null @@ -1,152 +0,0 @@ -#[cfg(not(target_os = "macos"))] -pub mod client; -pub mod config; -pub mod proto { - tonic::include_proto!("client"); -} -#[cfg(not(target_os = "macos"))] -pub mod daemon; -#[cfg(windows)] -pub mod named_pipe; -pub mod utils; -#[cfg(windows)] -pub mod windows; - -use std::{ - str::FromStr, - time::{Duration, UNIX_EPOCH}, -}; - -use defguard_wireguard_rs::{ - host::Host, key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration, -}; - -impl From for proto::InterfaceConfig { - fn from(config: InterfaceConfiguration) -> Self { - Self { - name: config.name, - prvkey: config.prvkey, - address: config - .addresses - .iter() - .map(ToString::to_string) - .collect::>() - .join(","), - port: u32::from(config.port), - peers: config.peers.into_iter().map(Into::into).collect(), - mtu: config.mtu, - } - } -} - -impl From for InterfaceConfiguration { - fn from(config: proto::InterfaceConfig) -> Self { - let addresses = config - .address - .split(',') - .filter_map(|ip| IpAddrMask::from_str(ip.trim()).ok()) - .collect(); - Self { - name: config.name, - prvkey: config.prvkey, - addresses, - port: config.port as u16, - peers: config.peers.into_iter().map(Into::into).collect(), - mtu: config.mtu, - fwmark: None, // TODO: add to config - } - } -} - -impl From for proto::Peer { - fn from(peer: Peer) -> Self { - Self { - public_key: peer.public_key.to_lower_hex(), - preshared_key: peer.preshared_key.map(|key| key.to_lower_hex()), - protocol_version: peer.protocol_version, - endpoint: peer.endpoint.map(|addr| addr.to_string()), - last_handshake: peer.last_handshake.map(|time| { - time.duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs() - }), - tx_bytes: peer.tx_bytes, - rx_bytes: peer.rx_bytes, - persistent_keepalive_interval: peer.persistent_keepalive_interval.map(u32::from), - allowed_ips: peer - .allowed_ips - .into_iter() - .map(|addr| addr.to_string()) - .collect(), - } - } -} - -impl From for Peer { - fn from(peer: proto::Peer) -> Self { - Self { - public_key: Key::decode(peer.public_key).expect("Failed to parse public key"), - preshared_key: peer - .preshared_key - .map(|key| Key::decode(key).expect("Failed to parse preshared key: {key}")), - protocol_version: peer.protocol_version, - endpoint: peer.endpoint.map(|addr| { - addr.parse() - .expect("Failed to parse endpoint address: {addr}") - }), - last_handshake: peer - .last_handshake - .map(|timestamp| UNIX_EPOCH + Duration::from_secs(timestamp)), - tx_bytes: peer.tx_bytes, - rx_bytes: peer.rx_bytes, - persistent_keepalive_interval: peer - .persistent_keepalive_interval - .and_then(|interval| u16::try_from(interval).ok()), - allowed_ips: peer - .allowed_ips - .into_iter() - .map(|addr| addr.parse().expect("Failed to parse allowed IP: {addr}")) - .collect(), - } - } -} - -impl From for proto::InterfaceData { - fn from(host: Host) -> Self { - Self { - listen_port: u32::from(host.listen_port), - peers: host.peers.into_values().map(Into::into).collect(), - } - } -} - -#[cfg(test)] -mod tests { - use std::time::SystemTime; - - use x25519_dalek::{EphemeralSecret, PublicKey}; - - use super::*; - - #[test] - fn convert_peer() { - let secret = EphemeralSecret::random(); - let key = PublicKey::from(&secret); - let peer_key: Key = key.as_ref().try_into().unwrap(); - let mut base_peer = Peer::new(peer_key); - let addr = IpAddrMask::from_str("10.20.30.2/32").unwrap(); - base_peer.allowed_ips.push(addr); - // Workaround since nanoseconds are lost in conversion. - base_peer.last_handshake = Some(SystemTime::UNIX_EPOCH); - base_peer.protocol_version = Some(3); - base_peer.endpoint = Some("127.0.0.1:8080".parse().unwrap()); - base_peer.tx_bytes = 100; - base_peer.rx_bytes = 200; - - let proto_peer: proto::Peer = base_peer.clone().into(); - - let converted_peer: Peer = proto_peer.into(); - - assert_eq!(base_peer, converted_peer); - } -} diff --git a/src-tauri/src/service/utils.rs b/src-tauri/src/service/utils.rs deleted file mode 100644 index 7796cf56d..000000000 --- a/src-tauri/src/service/utils.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::io::stdout; - -use tracing::Level; -use tracing_appender::non_blocking::WorkerGuard; -use tracing_subscriber::{ - fmt, fmt::writer::MakeWriterExt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, - Layer, -}; - -pub fn logging_setup(log_dir: &str, log_level: &str) -> WorkerGuard { - // prepare log file appender - let file_appender = tracing_appender::rolling::daily(log_dir, "defguard-service.log"); - let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); - - // prepare log level filter for stdout - let stdout_filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| format!("{log_level},hyper=info,h2=info").into()); - - // prepare log level filter for JSON file - let json_filter = EnvFilter::new("DEBUG,hyper=info,h2=info"); - - // prepare tracing layers - let stdout_layer = fmt::layer() - .pretty() - .with_writer(stdout.with_max_level(Level::DEBUG)) - .with_filter(stdout_filter); - let json_file_layer = fmt::layer() - .json() - .with_writer(non_blocking.with_max_level(Level::DEBUG)) - .with_filter(json_filter); - - // initialize tracing subscriber - tracing_subscriber::registry() - .with(stdout_layer) - .with(json_file_layer) - .init(); - - guard -} diff --git a/src-tauri/src/service/windows.rs b/src-tauri/src/service/windows.rs deleted file mode 100644 index 00c5c0a0c..000000000 --- a/src-tauri/src/service/windows.rs +++ /dev/null @@ -1,251 +0,0 @@ -use std::{ - ffi::OsString, - result::Result, - sync::{mpsc, Arc, RwLock}, - time::Duration, -}; - -use clap::Parser; -use error; -use tokio::{runtime::Runtime, time::sleep}; -use windows_service::{ - define_windows_service, - service::{ - ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, - ServiceType, - }, - service_control_handler::{register, ServiceControlHandlerResult}, - service_dispatcher, -}; - -use crate::{ - enterprise::service_locations::{ - windows::{watch_for_login_logoff, watch_for_network_change}, - ServiceLocationError, ServiceLocationManager, - }, - service::{ - config::Config, - daemon::{run_server, DaemonError}, - utils::logging_setup, - }, -}; - -static SERVICE_NAME: &str = "DefguardService"; -const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; -const LOGIN_LOGOFF_MONITORING_RESTART_DELAY_SECS: Duration = Duration::from_secs(5); -const SERVICE_LOCATION_CONNECT_RETRY_COUNT: u32 = 5; -const SERVICE_LOCATION_CONNECT_RETRY_DELAY: Duration = Duration::from_secs(30); - -pub fn run() -> Result<(), windows_service::Error> { - // Register generated `ffi_service_main` with the system and start the service, blocking - // this thread until the service is stopped. - service_dispatcher::start(SERVICE_NAME, ffi_service_main) -} - -define_windows_service!(ffi_service_main, service_main); - -pub fn service_main(_arguments: Vec) { - if let Err(err) = run_service() { - error!("Error while running the service. {err}"); - panic!("{err}"); - } -} - -fn run_service() -> Result<(), DaemonError> { - // Create a channel to be able to poll a stop event from the service worker loop. - let (shutdown_tx, shutdown_rx) = mpsc::channel::(); - let shutdown_tx_server = shutdown_tx.clone(); - - // Define system service event handler that will be receiving service events. - let event_handler = move |control_event| -> ServiceControlHandlerResult { - match control_event { - // Notifies a service to report its current status information to the service - // control manager. Always return NoError even if not implemented. - ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, - - // Handle stop - ServiceControl::Stop => { - let _ = shutdown_tx.send(1); - ServiceControlHandlerResult::NoError - } - - _ => ServiceControlHandlerResult::NotImplemented, - } - }; - - // Register system service event handler. - // The returned status handle should be used to report service status changes to the system. - let status_handle = register(SERVICE_NAME, event_handler)?; - - let rt = Runtime::new(); - - if let Ok(runtime) = rt { - status_handle.set_service_status(ServiceStatus { - service_type: SERVICE_TYPE, - current_state: ServiceState::Running, - controls_accepted: ServiceControlAccept::STOP, - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::default(), - process_id: None, - })?; - - let config: Config = Config::parse(); - let _guard = logging_setup(&config.log_dir, &config.log_level); - - let default_panic = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - default_panic(info); - std::process::exit(1); - })); - - let service_location_manager = match ServiceLocationManager::init() { - Ok(api) => { - info!("Service locations storage initialized successfully"); - Ok(api) - } - Err(err) => { - error!( - "Failed to initialize service locations storage: {err}. Shutting down service \ - location thread" - ); - Err(ServiceLocationError::InitError(err.to_string())) - } - }?; - - let service_location_manager = Arc::new(RwLock::new(service_location_manager)); - - // Spawn network change monitoring on a dedicated OS thread so the blocking - // NotifyAddrChange syscall does not stall Tokio's async worker threads. - // Register it first so no network event can be missed before the watcher is listening; - // the retry loop below is the backstop for any event that slips through the startup window. - let service_location_manager_clone = service_location_manager.clone(); - std::thread::Builder::new() - .name("network-change-monitor".to_string()) - .spawn(move || { - info!("Starting network change monitoring"); - watch_for_network_change(service_location_manager_clone); - error!("Network change monitoring ended unexpectedly."); - }) - .expect("Failed to spawn network change monitor thread"); - - // Spawn service location auto-connect task with retries. - // Each attempt skips locations that are already connected, so it is safe to call - // connect_to_service_locations repeatedly. The retry loop exists to handle the case - // where the connection may fail initially at startup because the network - // (e.g. Wi-Fi) is not yet available (mainly DNS resolution issues), and serves as - // a backstop for any network events missed by the watcher above. - // If all locations connect successfully on a given attempt, no further retries are made. - let service_location_manager_connect = service_location_manager.clone(); - runtime.spawn(async move { - for attempt in 1..=SERVICE_LOCATION_CONNECT_RETRY_COUNT { - info!( - "Attempting to auto-connect to service locations \ - (attempt {attempt}/{SERVICE_LOCATION_CONNECT_RETRY_COUNT})" - ); - match service_location_manager_connect - .write() - .unwrap() - .connect_to_service_locations() - { - Ok(true) => { - info!( - "All service locations connected successfully \ - (attempt {attempt}/{SERVICE_LOCATION_CONNECT_RETRY_COUNT})" - ); - break; - } - Ok(false) => { - warn!( - "Auto-connect attempt {attempt}/{SERVICE_LOCATION_CONNECT_RETRY_COUNT} \ - completed with some failures" - ); - } - Err(err) => { - warn!( - "Auto-connect attempt {attempt}/{SERVICE_LOCATION_CONNECT_RETRY_COUNT} \ - failed: {err}" - ); - } - } - - if attempt < SERVICE_LOCATION_CONNECT_RETRY_COUNT { - sleep(SERVICE_LOCATION_CONNECT_RETRY_DELAY).await; - } - } - info!("Service location auto-connect task finished"); - }); - - // Spawn login/logoff monitoring on a dedicated OS thread so the blocking - // WTSWaitSystemEvent syscall does not stall Tokio's async worker threads. - let service_location_manager_clone = service_location_manager.clone(); - std::thread::Builder::new() - .name("login-logoff-monitor".to_string()) - .spawn(move || { - info!("Starting login/logoff event monitoring"); - loop { - match watch_for_login_logoff(service_location_manager_clone.clone()) { - Ok(()) => { - warn!( - "Login/logoff event monitoring ended unexpectedly. Restarting in \ - {LOGIN_LOGOFF_MONITORING_RESTART_DELAY_SECS:?}..." - ); - std::thread::sleep(LOGIN_LOGOFF_MONITORING_RESTART_DELAY_SECS); - } - Err(e) => { - error!( - "Error in login/logoff event monitoring: {e}. Restarting in \ - {LOGIN_LOGOFF_MONITORING_RESTART_DELAY_SECS:?}...", - ); - std::thread::sleep(LOGIN_LOGOFF_MONITORING_RESTART_DELAY_SECS); - info!("Restarting login/logoff event monitoring"); - } - } - } - }) - .expect("Failed to spawn login/logoff monitor thread"); - - // Spawn the main gRPC server task - let service_location_manager_clone = service_location_manager.clone(); - runtime.spawn(async move { - let result = run_server(config, service_location_manager_clone).await; - - let signal = if result.is_err() { - error!("Server task ended with error: {:?}", result.err()); - 2 - } else { - warn!("Server task ended without an error."); - 1 - }; - - let _ = shutdown_tx_server.send(signal); - }); - - loop { - // Poll shutdown event. - match shutdown_rx.recv_timeout(Duration::from_secs(1)) { - // Break the loop either upon stop or channel disconnect - Ok(1) | Err(mpsc::RecvTimeoutError::Disconnected) => break, - Ok(2) => { - panic!("Server has stopped working.") - } - Ok(_) => break, - - // Continue work if no events were received within the timeout - Err(mpsc::RecvTimeoutError::Timeout) => (), - } - } - - status_handle.set_service_status(ServiceStatus { - service_type: SERVICE_TYPE, - current_state: ServiceState::Stopped, - controls_accepted: ServiceControlAccept::empty(), - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::default(), - process_id: None, - })?; - } - - Ok(()) -} diff --git a/src-tauri/src/session_state.rs b/src-tauri/src/session_state.rs new file mode 100644 index 000000000..09cc04385 --- /dev/null +++ b/src-tauri/src/session_state.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; + +use defguard_client_core::events::EventKey; +use serde::{Deserialize, Serialize}; +use struct_patch::Patch; +use tauri::{AppHandle, Emitter, Manager, State}; + +use crate::appstate::AppState; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SessionStateMfaMethod { + Totp, + Email, + Oidc, + Biometric, + MobileApprove, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ViewSelectionKind { + Instance, + Tunnel, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct OverviewViewSelection { + pub kind: ViewSelectionKind, + pub id: i64, +} + +#[derive(Clone, Debug, Default, Deserialize, Patch, Serialize)] +#[patch(attribute(derive(Debug, Deserialize, Serialize)))] +pub struct SessionState { + pub view_selection: Option, + // needed to display properly the method tile between windows as connection doesn't hold this + pub connection_mfa_method: HashMap>, +} + +#[tauri::command] +pub fn get_session_state(app_state: State<'_, AppState>) -> Result { + app_state + .session_state + .lock() + .map(|s| s.clone()) + .map_err(|err| format!("Session state mutex poisoned: {err}")) +} + +#[tauri::command(async)] +pub async fn patch_session_state( + patch: SessionStatePatch, + app_handle: AppHandle, +) -> Result { + let app_state = app_handle.state::(); + let updated = app_state + .session_state + .lock() + .map_err(|err| format!("Session state mutex poisoned: {err}")) + .map(|mut s| { + s.apply(patch); + s.clone() + })?; + if let Err(err) = app_handle.emit(EventKey::SessionStateChanged.into(), ()) { + error!("Failed to emit session-state-changed event: {err}"); + } + Ok(updated) +} diff --git a/src-tauri/src/system_extension.rs b/src-tauri/src/system_extension.rs new file mode 100644 index 000000000..dd32ec446 --- /dev/null +++ b/src-tauri/src/system_extension.rs @@ -0,0 +1,110 @@ +use std::sync::{LazyLock, Mutex}; + +use dispatch2::DispatchQueue; +use objc2::{ + define_class, msg_send, + rc::Retained, + runtime::{NSObjectProtocol, ProtocolObject}, + AnyThread, +}; +use objc2_foundation::{NSError, NSObject, NSString}; +use objc2_system_extensions::{ + OSSystemExtensionManager, OSSystemExtensionProperties, OSSystemExtensionReplacementAction, + OSSystemExtensionRequest, OSSystemExtensionRequestDelegate, OSSystemExtensionRequestResult, +}; + +// OSSystemExtensionRequest.delegate is a `weak` property, so we must keep our delegate alive +// for the duration of the activation handshake. +static DELEGATE: LazyLock>>> = + LazyLock::new(|| Mutex::new(None)); + +define_class!( + #[unsafe(super(NSObject))] + #[name = "DefguardSystemExtensionDelegate"] + struct SystemExtensionDelegate; + + unsafe impl NSObjectProtocol for SystemExtensionDelegate {} + + unsafe impl OSSystemExtensionRequestDelegate for SystemExtensionDelegate { + /// A newer version of the extension is being installed; always replace. + #[unsafe(method(request:actionForReplacingExtension:withExtension:))] + fn action_for_replacing( + &self, + _request: &OSSystemExtensionRequest, + _existing: &OSSystemExtensionProperties, + _ext: &OSSystemExtensionProperties, + ) -> OSSystemExtensionReplacementAction { + OSSystemExtensionReplacementAction::Replace + } + + /// The extension is waiting for user approval in System Settings > Privacy & Security. + #[unsafe(method(requestNeedsUserApproval:))] + fn request_needs_user_approval(&self, _request: &OSSystemExtensionRequest) { + info!( + "VPN system extension requires user approval — open System Settings > General > \ + Login Items & Extensions > Network Extensions to enable it." + ); + } + + /// Activation finished (or will finish after reboot). + #[unsafe(method(request:didFinishWithResult:))] + fn request_did_finish( + &self, + _request: &OSSystemExtensionRequest, + result: OSSystemExtensionRequestResult, + ) { + if result == OSSystemExtensionRequestResult::WillCompleteAfterReboot { + info!("VPN system extension installed; activation will complete after reboot."); + } else { + info!("VPN system extension activated successfully."); + } + } + + /// Activation failed. + #[unsafe(method(request:didFailWithError:))] + fn request_did_fail(&self, _request: &OSSystemExtensionRequest, error: &NSError) { + error!( + "VPN system extension activation failed: {}", + error.localizedDescription() + ); + } + } +); + +impl SystemExtensionDelegate { + fn new() -> Retained { + let this = Self::alloc().set_ivars(()); + unsafe { msg_send![super(this), init] } + } +} + +/// Activate a system extension. +/// +/// Safe to call on every launch — the OS ignores duplicate requests for extensions that are +/// already active. Callbacks arrive asynchronously on the main queue via the embedded delegate. +/// +/// +pub fn activate_system_extension(bundle_id: &str) { + let identifier = NSString::from_str(bundle_id); + let delegate = SystemExtensionDelegate::new(); + + // SAFETY: `delegate` is kept alive in DELEGATE for the duration of the async handshake, and + // the main dispatch queue lives for the whole process. + unsafe { + let request = OSSystemExtensionRequest::activationRequestForExtension_queue( + &identifier, + DispatchQueue::main(), + ); + request.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + + let manager = OSSystemExtensionManager::sharedManager(); + manager.submitRequest(&request); + } + + info!("Submitted system extension activation request for {bundle_id}."); + + // Keep the delegate alive until the asynchronous callbacks are delivered. + if let Ok(mut guard) = DELEGATE.lock() { + *guard = Some(delegate); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 94ff99c75..48722362d 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,18 +1,20 @@ +use defguard_client_core::connection::active_connections::{ + get_connection_id_by_type, ACTIVE_CONNECTIONS, +}; use tauri::{ image::Image, menu::{Menu, MenuBuilder, MenuEvent, MenuItem, SubmenuBuilder}, path::BaseDirectory, - tray::TrayIconBuilder, - AppHandle, Emitter, Manager, Runtime, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, Runtime, }; use crate::{ - active_connections::{get_connection_id_by_type, ACTIVE_CONNECTIONS}, appstate::AppState, commands::{all_instances, all_locations, connect, disconnect}, database::{models::location::Location, DB_POOL}, error::Error, - events::EventKey, + window_manager::{show_tray_or_full_view, trigger_mfa, COMPACT_WINDOW_ID}, ConnectionType, }; @@ -20,8 +22,6 @@ const SUBSCRIBE_UPDATES_LINK: &str = "https://defguard.net/newsletter"; const JOIN_COMMUNITY_LINK: &str = "https://github.com/DefGuard/defguard/discussions/new/choose"; const FOLLOW_US_LINK: &str = "https://floss.social/@defguard"; -const MAIN_WINDOW_ID: &str = "main"; - const TRAY_ICON_ID: &str = "tray"; const TRAY_EVENT_QUIT: &str = "quit"; @@ -31,27 +31,48 @@ const TRAY_EVENT_UPDATES: &str = "updates"; const TRAY_EVENT_COMMUNITY: &str = "community"; const TRAY_EVENT_FOLLOW: &str = "follow"; +fn store_tray_click_position(app: &AppHandle, event: &TrayIconEvent) { + let position = match event { + TrayIconEvent::Click { + button_state: MouseButtonState::Down, + rect, + .. + } => Some(rect.position.to_physical(1.0)), + _ => None, + }; + + if let Some(position) = position { + *app.state::().tray_click_position.lock().unwrap() = Some(position); + } +} + /// Generate contents of system tray menu. async fn generate_tray_menu(app: &AppHandle) -> Result, Error> { debug!("Generating tray menu."); - let quit = MenuItem::with_id(app, TRAY_EVENT_QUIT, "Quit", true, None::<&str>)?; - let show = MenuItem::with_id(app, TRAY_EVENT_SHOW, "Show", true, None::<&str>)?; - let hide = MenuItem::with_id(app, TRAY_EVENT_HIDE, "Hide", true, None::<&str>)?; + let quit = MenuItem::with_id(app, TRAY_EVENT_QUIT, "Quit", true, None::<&str>) + .map_err(crate::tauri_err_to_app_err)?; + let show = MenuItem::with_id(app, TRAY_EVENT_SHOW, "Show", true, None::<&str>) + .map_err(crate::tauri_err_to_app_err)?; + let hide = MenuItem::with_id(app, TRAY_EVENT_HIDE, "Hide", true, None::<&str>) + .map_err(crate::tauri_err_to_app_err)?; let subscribe_updates = MenuItem::with_id( app, TRAY_EVENT_UPDATES, "Subscribe for updates", true, None::<&str>, - )?; + ) + .map_err(crate::tauri_err_to_app_err)?; let join_community = MenuItem::with_id( app, TRAY_EVENT_COMMUNITY, "Community support", true, None::<&str>, - )?; - let follow_us = MenuItem::with_id(app, TRAY_EVENT_FOLLOW, "Follow us", true, None::<&str>)?; + ) + .map_err(crate::tauri_err_to_app_err)?; + let follow_us = MenuItem::with_id(app, TRAY_EVENT_FOLLOW, "Follow us", true, None::<&str>) + .map_err(crate::tauri_err_to_app_err)?; let mut menu = MenuBuilder::new(app); debug!("Getting all instances information for the tray menu"); @@ -76,7 +97,8 @@ async fn generate_tray_menu(app: &AppHandle) -> Result, Error location.menu_label(), true, None::<&str>, - )?; + ) + .map_err(crate::tauri_err_to_app_err)?; menu = menu.item(&menu_item); } } else { @@ -96,10 +118,11 @@ async fn generate_tray_menu(app: &AppHandle) -> Result, Error location.menu_label(), true, None::<&str>, - )?; + ) + .map_err(crate::tauri_err_to_app_err)?; instance_menu = instance_menu.item(&menu_item); } - let submenu = instance_menu.build()?; + let submenu = instance_menu.build().map_err(crate::tauri_err_to_app_err)?; menu = menu.item(&submenu); } } @@ -109,14 +132,14 @@ async fn generate_tray_menu(app: &AppHandle) -> Result, Error } } - Ok(menu - .separator() + menu.separator() .items(&[&show, &hide]) .separator() .items(&[&subscribe_updates, &join_community, &follow_us]) .separator() .item(&quit) - .build()?) + .build() + .map_err(crate::tauri_err_to_app_err) } /// Setup system tray. @@ -124,27 +147,42 @@ async fn generate_tray_menu(app: &AppHandle) -> Result, Error pub async fn setup_tray(app: &AppHandle) -> Result<(), Error> { let tray_menu = generate_tray_menu(app).await?; - // On macOS, always show menu under system tray icon. - #[cfg(target_os = "macos")] - TrayIconBuilder::with_id(TRAY_ICON_ID) - .menu(&tray_menu) - .show_menu_on_left_click(true) - .on_menu_event(handle_tray_menu_event) - .build(app)?; - // On other systems (especially Windows), system tray menu is on right-click, - // and double-click shows the main window. - #[cfg(not(target_os = "macos"))] TrayIconBuilder::with_id(TRAY_ICON_ID) .menu(&tray_menu) .show_menu_on_left_click(false) + // NOTE: on Linux this click handler never fires. The `tray-icon` appindicator + // backend (libayatana-appindicator) does not emit tray click events - only the + // context menu works (`show_menu_on_left_click` is likewise a no-op on Linux). + // So left-click cannot open/toggle the window on Linux; users interact via the + // right-click menu's Show/Hide items (handled in `handle_tray_menu_event`). + // This is an upstream limitation, not a bug here. Documented in known-issues. .on_tray_icon_event(|icon, event| { - if let tauri::tray::TrayIconEvent::DoubleClick { .. } = event { - show_main_window(icon.app_handle()); + store_tray_click_position(icon.app_handle(), &event); + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = icon.app_handle(); + + let tray_visible = app + .get_webview_window(COMPACT_WINDOW_ID) + .and_then(|w| w.is_visible().ok()) + .unwrap_or(false); + + if tray_visible { + if let Some(w) = app.get_webview_window(COMPACT_WINDOW_ID) { + let _ = w.hide(); + } + } else { + show_tray_or_full_view(app); + } } }) .on_menu_event(handle_tray_menu_event) - .build(app)?; - + .build(app) + .map_err(crate::tauri_err_to_app_err)?; debug!("Tray menu successfully generated"); Ok(()) } @@ -163,35 +201,17 @@ pub(crate) async fn reload_tray_menu(app: &AppHandle) { } } -fn hide_main_window(app: &AppHandle) { +fn hide_visible_windows(app: &AppHandle) { #[cfg(target_os = "macos")] if let Err(err) = app.hide() { warn!("Failed to hide application: {err}"); } - #[cfg(not(target_os = "macos"))] - if let Some(main_window) = app.get_webview_window(MAIN_WINDOW_ID) { - if let Err(err) = main_window.hide() { - warn!("Failed to hide main window: {err}"); - } - } -} - -pub fn show_main_window(app: &AppHandle) { - if let Some(main_window) = app.get_webview_window(MAIN_WINDOW_ID) { - if let Err(err) = main_window.unminimize() { - warn!("Failed to unminimize main window: {err}"); - } - #[cfg(target_os = "macos")] - if let Err(err) = app.show() { - warn!("Failed to show application: {err}"); - } - #[cfg(not(target_os = "macos"))] - { - if let Err(err) = main_window.show() { - warn!("Failed to show main window: {err}"); + for (id, window) in app.webview_windows() { + if window.is_visible().unwrap_or(false) { + if let Err(err) = window.hide() { + warn!("Failed to hide window {id}: {err}"); } } - let _ = main_window.set_focus(); } } @@ -203,8 +223,8 @@ pub fn handle_tray_menu_event(app: &AppHandle, event: MenuEvent) { info!("Received QUIT request. Initiating shutdown..."); handle.exit(0); } - TRAY_EVENT_SHOW => show_main_window(app), - TRAY_EVENT_HIDE => hide_main_window(app), + TRAY_EVENT_SHOW => show_tray_or_full_view(app), + TRAY_EVENT_HIDE => hide_visible_windows(app), TRAY_EVENT_UPDATES => { let _ = webbrowser::open(SUBSCRIBE_UPDATES_LINK); } @@ -225,19 +245,27 @@ pub fn handle_tray_menu_event(app: &AppHandle, event: MenuEvent) { /// Show correct system tray icon, depending on the theme and connection status. pub async fn configure_tray_icon(app_handle: &AppHandle) -> Result<(), Error> { - let state = app_handle.state::(); - let theme = state.app_config.lock().unwrap().tray_theme; - let Some(tray_icon) = app_handle.tray_by_id(TRAY_ICON_ID) else { error!("System tray menu not initialized."); return Ok(()); }; - let mut resource_str = String::from("resources/icons/tray-32x32-"); - resource_str.push_str(theme.as_ref()); + let mut resource_str = String::from("resources/icons/tray/"); + #[cfg(windows)] + resource_str.push_str("blue"); + #[cfg(not(windows))] + { + // TODO: `use tauri::Theme;` + // let theme = app_handle + // .webview_windows() + // .into_values() + // .next() + // .and_then(|w| w.theme().ok()); + resource_str.push_str("white"); + } let active_connections = ACTIVE_CONNECTIONS.lock().await; if !active_connections.is_empty() { - resource_str.push_str("-active"); + resource_str.push_str("-connected"); } resource_str.push_str(".png"); debug!("Trying to load the tray icon from {resource_str}"); @@ -245,8 +273,10 @@ pub async fn configure_tray_icon(app_handle: &AppHandle) -> Result<(), Error> { .path() .resolve(&resource_str, BaseDirectory::Resource) { - let icon = Image::from_path(icon_path)?; - tray_icon.set_icon(Some(icon))?; + let icon = Image::from_path(icon_path).map_err(crate::tauri_err_to_app_err)?; + tray_icon + .set_icon(Some(icon)) + .map_err(crate::tauri_err_to_app_err)?; debug!("Tray icon set to {resource_str} successfully."); Ok(()) } else { @@ -271,19 +301,12 @@ async fn handle_location_tray_menu(id: String, app: &AppHandle) { info!("Connect location with ID {id}"); // Check if MFA is enabled. If so, trigger modal on frontend. if location.mfa_enabled() { - info!( - "MFA enabled for location with ID {:?}, trigger MFA modal", - location.id - ); - show_main_window(app); - let _ = app.emit(EventKey::MfaTrigger.into(), &location); + info!("MFA enabled for location with ID {id}, trigger MFA modal"); + trigger_mfa(app, &location); } else if let Err(err) = - connect(location_id, ConnectionType::Location, None, app.clone()).await + connect(location_id, ConnectionType::Location, app.clone()).await { - info!( - "Unable to connect location with ID {}, error: {err:?}", - location.id - ); + info!("Unable to connect location with ID {id}, error: {err:?}"); } } } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 7e0eb05cd..de346f457 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,19 +1,30 @@ -#[cfg(not(target_os = "macos"))] -use std::str::FromStr; #[cfg(target_os = "macos")] use std::time::Duration; -use std::{env, path::Path, process::Command}; +#[cfg(not(target_os = "macos"))] +use std::{collections::HashMap, str::FromStr}; +use std::{env, process::Command}; +#[cfg(target_os = "linux")] +use std::{fs, path::Path}; -use base64::{prelude::BASE64_STANDARD, Engine}; #[cfg(not(target_os = "macos"))] -use common::{find_free_tcp_port, get_interface_name}; +use defguard_client_common::{find_free_tcp_port, get_interface_name}; +#[cfg(windows)] +use defguard_client_core::connection::active_connections::find_connection; +#[cfg(target_os = "macos")] +use defguard_client_core::connection::apple::tunnel_stats; +use defguard_client_core::connection::{bring_up, ConnectionTarget}; +#[cfg(not(target_os = "macos"))] +use defguard_client_core::{ + connection::daemon_client::DAEMON_CLIENT, DEFAULT_ROUTE_IPV4, DEFAULT_ROUTE_IPV6, +}; +#[cfg(not(target_os = "macos"))] +use defguard_client_proto::defguard::client::v1::{ + CreateInterfaceRequest, ReadInterfaceDataRequest, +}; #[cfg(not(target_os = "macos"))] use defguard_wireguard_rs::{key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration}; -use prost::Message; use sqlx::query; use tauri::{AppHandle, Emitter, Manager}; -#[cfg(not(target_os = "macos"))] -use tonic::Code; use tracing::Level; #[cfg(windows)] use windows_service::{ @@ -23,124 +34,95 @@ use windows_service::{ #[cfg(windows)] use windows_sys::Win32::Foundation::ERROR_SERVICE_DOES_NOT_EXIST; -#[cfg(windows)] -use crate::active_connections::find_connection; -#[cfg(target_os = "macos")] -use crate::apple::tunnel_stats; +#[cfg(not(target_os = "macos"))] +use crate::database::models::{ + location_stats::peer_to_location_stats, tunnel::peer_to_tunnel_stats, +}; use crate::{ appstate::AppState, commands::LocationInterfaceDetails, database::{ - models::{ - connection::{ActiveConnection, Connection}, - location::Location, - tunnel::{Tunnel, TunnelConnection}, - wireguard_keys::WireguardKeys, - Id, - }, + models::{location::Location, tunnel::Tunnel, wireguard_keys::WireguardKeys, Id}, DbPool, DB_POOL, }, error::Error, events::EventKey, log_watcher::service_log_watcher::spawn_log_watcher_task, - proto::ClientPlatformInfo, ConnectionType, }; -#[cfg(not(target_os = "macos"))] -use crate::{ - database::models::{location_stats::peer_to_location_stats, tunnel::peer_to_tunnel_stats}, - service::{ - client::DAEMON_CLIENT, - proto::{CreateInterfaceRequest, ReadInterfaceDataRequest, RemoveInterfaceRequest}, - }, -}; -pub(crate) static DEFAULT_ROUTE_IPV4: &str = "0.0.0.0/0"; -pub(crate) static DEFAULT_ROUTE_IPV6: &str = "::/0"; // Work-around MFA propagation delay. FIXME: remove once Core API is corrected. #[cfg(target_os = "macos")] static TUNNEL_START_DELAY: Duration = Duration::from_secs(1); -/// Setup client interface for `Instance`. -#[cfg(not(target_os = "macos"))] -pub(crate) async fn setup_interface( - location: &Location, - name: &str, - preshared_key: Option, - mtu: Option, - pool: &DbPool, -) -> Result { - debug!("Setting up interface for location: {location}"); - let interface_name = get_interface_name(name); +fn stats_diffs(previous_totals: Option<(i64, i64)>, current_totals: (i64, i64)) -> (i64, i64) { + previous_totals.map_or((0, 0), |(previous_upload, previous_download)| { + ( + current_totals.0.saturating_sub(previous_upload).max(0), + current_totals.1.saturating_sub(previous_download).max(0), + ) + }) +} - // request interface configuration - debug!("Looking for a free port for interface {interface_name}."); - let Some(port) = find_free_tcp_port() else { - let msg = format!( - "Couldn't find free port during interface {interface_name} setup for location \ - {location}" +#[cfg(target_os = "linux")] +const NVIDIA_EXPLICIT_SYNC_ENV: &str = "__NV_DISABLE_EXPLICIT_SYNC"; +#[cfg(target_os = "linux")] +const WEBKIT_DMABUF_ENV: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; + +/// Sets relevant environment variables to workaround webkitgtk on nvidia and wayland issues. +/// https://v2.tauri.app/develop/debug/linux-graphics +#[cfg(target_os = "linux")] +pub fn set_webkitgtk_variables() { + let (should_set_dmabuf, should_set_explicit_sync) = should_set_webkit_variables(); + if should_set_dmabuf { + env::set_var(WEBKIT_DMABUF_ENV, "1"); + eprintln!( + "Applied Linux WebKitGTK NVIDIA environment variable: \ + {WEBKIT_DMABUF_ENV}=1" ); - error!("{msg}"); - return Err(Error::InternalError(msg)); - }; - debug!("Found free port: {port} for interface {interface_name}."); - - let mut interface_config = location - .interface_configuration(pool, interface_name.clone(), preshared_key, mtu) - .await?; - interface_config.mtu = mtu; - debug!("Creating interface for location {location} with configuration {interface_config:?}"); - let request = CreateInterfaceRequest { - config: Some(interface_config.clone().into()), - dns: location.dns.clone(), - }; - if let Err(error) = DAEMON_CLIENT.clone().create_interface(request).await { - if error.code() == Code::Unavailable { - error!( - "Failed to set up connection for location {location}; background service is \ - unavailable. Make sure the service is running. Error: {error}, Interface \ - configuration: {interface_config:?}" - ); - Err(Error::InternalError( - "Background service is unavailable. Make sure the service is running.".into(), - )) - } else { - error!( - "Failed to send a request to the background service to create an interface for \ - location {location} with the following configuration: {interface_config:?}. \ - Error: {error}" - ); - Err(Error::InternalError(format!( - "Failed to send a request to the background service to create an interface for \ - location {location}. Error: {error}. Check logs for details." - ))) - } - } else { - info!( - "The interface for location {location} has been created successfully, interface \ - name: {}.", - interface_config.name + } + if should_set_explicit_sync { + env::set_var(NVIDIA_EXPLICIT_SYNC_ENV, "1"); + eprintln!( + "Applied Linux WebKitGTK NVIDIA on Wayland environment variable: \ + {NVIDIA_EXPLICIT_SYNC_ENV}=1" ); - Ok(interface_name) } } -#[cfg(target_os = "macos")] -pub(crate) async fn setup_interface( - location: &Location, - _name: &str, - preshared_key: Option, - mtu: Option, - _pool: &DbPool, -) -> Result { - let tunnel_config = location.tunnel_configurarion(preshared_key, mtu).await?; +/// Encodes the decision on which webkitgtk-related variables should be set. +/// Returns (should_set_dmabuf, should_set_explicit_sync) bool pair. +#[cfg(target_os = "linux")] +fn should_set_webkit_variables() -> (bool, bool) { + let nvidia_driver = has_nvidia_driver(); + ( + nvidia_driver && env::var_os(WEBKIT_DMABUF_ENV).is_none(), + nvidia_driver && is_wayland_session() && env::var_os(NVIDIA_EXPLICIT_SYNC_ENV).is_none(), + ) +} - tunnel_config.save(); - tokio::time::sleep(TUNNEL_START_DELAY).await; - tunnel_config.start_tunnel(); +#[cfg(target_os = "linux")] +fn is_wayland_session() -> bool { + env::var("XDG_SESSION_TYPE") + .is_ok_and(|session_type| session_type.eq_ignore_ascii_case("wayland")) + || env::var_os("WAYLAND_DISPLAY").is_some() +} - // FIXME: not really useful nor true. - Ok(String::new()) +#[cfg(target_os = "linux")] +fn has_nvidia_driver() -> bool { + Path::new("/sys/module/nvidia").exists() + || Path::new("/proc/driver/nvidia/version").exists() + || fs::read_to_string("/proc/modules") + .is_ok_and(|modules| proc_modules_has_nvidia(&modules)) +} + +#[cfg(target_os = "linux")] +fn proc_modules_has_nvidia(modules: &str) -> bool { + modules.lines().any(|line| { + line.split_whitespace() + .next() + .is_some_and(|name| name == "nvidia" || name.starts_with("nvidia_")) + }) } #[cfg(target_os = "macos")] @@ -153,6 +135,7 @@ pub(crate) async fn stats_handler(id: Id, connection_type: ConnectionType) { let mut interval = tokio::time::interval(CHECK_INTERVAL); let pool = DB_POOL.clone(); + let mut previous_totals = None; loop { debug!("Waiting for the next stats collection interval for ID {id} and connection type {connection_type:?}"); @@ -162,6 +145,8 @@ pub(crate) async fn stats_handler(id: Id, connection_type: ConnectionType) { let Some(stats) = stats else { continue; }; + let current_totals = (stats.tx_bytes.cast_signed(), stats.rx_bytes.cast_signed()); + let (upload_diff, download_diff) = stats_diffs(previous_totals, current_totals); let mut transaction = match pool.begin().await { Ok(transactions) => transactions, @@ -172,6 +157,7 @@ pub(crate) async fn stats_handler(id: Id, connection_type: ConnectionType) { continue; } }; + let mut saved = false; if connection_type == ConnectionType::Location { let location_stats = LocationStats::new( @@ -181,10 +167,12 @@ pub(crate) async fn stats_handler(id: Id, connection_type: ConnectionType) { stats.last_handshake.cast_signed(), 0, None, - ); + ) + .with_diffs(upload_diff, download_diff); match location_stats.save(&mut *transaction).await { Ok(_) => { debug!("Saved network usage stats for location ID {id}"); + saved = true; } Err(err) => { error!("Failed to save network usage stats for location ID {id}: {err}"); @@ -199,10 +187,12 @@ pub(crate) async fn stats_handler(id: Id, connection_type: ConnectionType) { chrono::Utc::now().naive_utc(), 0, 0, - ); + ) + .with_diffs(upload_diff, download_diff); match tunnel_stats.save(&mut *transaction).await { Ok(_) => { debug!("Saved network usage stats for tunnel ID {id}"); + saved = true; } Err(err) => { error!("Failed to save network usage stats for tunnel ID {id}: {err}"); @@ -212,6 +202,8 @@ pub(crate) async fn stats_handler(id: Id, connection_type: ConnectionType) { if let Err(err) = transaction.commit().await { error!("Failed to commit database transaction for saving location/tunnel stats: {err}"); + } else if saved { + previous_totals = Some(current_totals); } } } @@ -228,6 +220,7 @@ pub(crate) async fn stats_handler(interface_name: String, connection_type: Conne .await .expect("Failed to connect to interface stats stream for interface {interface_name}") .into_inner(); + let mut previous_totals = HashMap::new(); loop { match stream.message().await { @@ -246,8 +239,18 @@ pub(crate) async fn stats_handler(interface_name: String, connection_type: Conne } }; - let peers: Vec = interface_data.peers.into_iter().map(Into::into).collect(); + let peers = interface_data + .peers + .into_iter() + .filter_map(|peer| { + Peer::try_from(peer) + .inspect_err(|err| error!("Skipping malformed peer: {err}")) + .ok() + }) + .collect::>(); + let mut pending_totals = Vec::new(); for peer in peers { + let current_totals = (peer.tx_bytes.cast_signed(), peer.rx_bytes.cast_signed()); if connection_type.eq(&ConnectionType::Location) { let location_stats = match peer_to_location_stats( &peer, @@ -272,9 +275,14 @@ pub(crate) async fn stats_handler(interface_name: String, connection_type: Conne (interface {interface_name})." ); trace!("Stats: {location_stats:?}"); + let location_id = location_stats.location_id; + let (upload_diff, download_diff) = + stats_diffs(previous_totals.get(&location_id).copied(), current_totals); + let location_stats = location_stats.with_diffs(upload_diff, download_diff); match location_stats.save(&mut *transaction).await { Ok(_) => { debug!("Saved network usage stats for location {location_name}"); + pending_totals.push((location_id, current_totals)); } Err(err) => { error!( @@ -305,9 +313,14 @@ pub(crate) async fn stats_handler(interface_name: String, connection_type: Conne "Saving network usage stats related to tunnel {tunnel_name} \ (interface {interface_name}): {tunnel_stats:?}" ); + let tunnel_id = tunnel_stats.tunnel_id; + let (upload_diff, download_diff) = + stats_diffs(previous_totals.get(&tunnel_id).copied(), current_totals); + let tunnel_stats = tunnel_stats.with_diffs(upload_diff, download_diff); match tunnel_stats.save(&mut *transaction).await { Ok(_) => { debug!("Saved stats for tunnel {tunnel_name}"); + pending_totals.push((tunnel_id, current_totals)); } Err(err) => { error!("Failed to save stats for tunnel {tunnel_name}: {err}"); @@ -322,6 +335,8 @@ pub(crate) async fn stats_handler(interface_name: String, connection_type: Conne "Failed to commit database transaction for saving location/tunnel stats: \ {err}", ); + } else { + previous_totals.extend(pending_totals); } } Ok(None) => { @@ -352,22 +367,17 @@ pub fn load_log_targets() -> Vec { Vec::new() } -/// Helper function to get log file directory for `defguard-service` daemon. -#[must_use] -pub fn get_service_log_dir() -> &'static Path { - #[cfg(windows)] - let path = "/Logs/defguard-service"; - - #[cfg(not(windows))] - let path = "/var/log/defguard-service"; +/// Default log file directory for `defguard-service` daemon. +#[cfg(windows)] +pub const DEFAULT_SERVICE_LOG_DIR: &str = "/Logs/defguard-service"; - Path::new(path) -} +#[cfg(not(windows))] +pub const DEFAULT_SERVICE_LOG_DIR: &str = "/var/log/defguard-service"; /// Setup client interface #[cfg(not(target_os = "macos"))] pub async fn setup_interface_tunnel( - tunnel: &Tunnel, + tunnel: Tunnel, name: &str, mtu: Option, ) -> Result { @@ -522,13 +532,13 @@ pub async fn setup_interface_tunnel( #[cfg(target_os = "macos")] pub async fn setup_interface_tunnel( - tunnel: &Tunnel, + tunnel: Tunnel, _name: &str, mtu: Option, ) -> Result { debug!("Setting up interface for tunnel: {tunnel}"); - let tunnel_config = tunnel.tunnel_configurarion(mtu)?; + let tunnel_config = tunnel.tunnel_configuration(mtu)?; tunnel_config.save(); tokio::time::sleep(TUNNEL_START_DELAY).await; @@ -592,6 +602,7 @@ pub async fn get_tunnel_interface_details( allowed_ips: tunnel.allowed_ips.unwrap_or_default(), persistent_keepalive_interval, last_handshake, + mfa_method: None, }) } else { error!("Error while fetching tunnel details for ID {tunnel_id}: tunnel not found"); @@ -660,6 +671,7 @@ pub async fn get_location_interface_details( allowed_ips: location.allowed_ips, persistent_keepalive_interval, last_handshake, + mfa_method: location.mfa_method, }) } else { error!("Error while fetching location details for ID {location_id}: location not found"); @@ -669,7 +681,7 @@ pub async fn get_location_interface_details( /// Setup new connection for location pub(crate) async fn handle_connection_for_location( - location: &Location, + location: Location, preshared_key: Option, handle: &AppHandle, ) -> Result<(), Error> { @@ -680,14 +692,22 @@ pub(crate) async fn handle_connection_for_location( .lock() .expect("failed to lock app state") .mtu(); - let interface_name = - setup_interface(location, &location.name, preshared_key, mtu, &DB_POOL).await?; + let interface_name = bring_up( + ConnectionTarget::Location(location.clone()), + preshared_key, + mtu, + &DB_POOL, + None, + ) + .await?; state .add_connection(location.id, &interface_name, ConnectionType::Location) .await; debug!("Sending event informing the frontend that a new connection has been created."); - handle.emit(EventKey::ConnectionChanged.into(), ())?; + handle + .emit(EventKey::ConnectionChanged.into(), ()) + .map_err(crate::tauri_err_to_app_err)?; debug!("Event informing the frontend that a new connection has been created sent."); // spawn log watcher @@ -707,37 +727,49 @@ pub(crate) async fn handle_connection_for_location( /// Setup new connection for tunnel pub(crate) async fn handle_connection_for_tunnel( - tunnel: &Tunnel, + tunnel: Tunnel, handle: &AppHandle, ) -> Result<(), Error> { - debug!("Setting up the connection for tunnel: {}", tunnel.name); + let tunnel_id = tunnel.id; + let tunnel_name = tunnel.name.clone(); + let tunnel_preshared_key = tunnel.preshared_key.clone(); + debug!("Setting up the connection for tunnel: {tunnel_name}"); let state = handle.state::(); let mtu = state .app_config .lock() .expect("failed to lock app state") .mtu(); - let interface_name = setup_interface_tunnel(tunnel, &tunnel.name, mtu).await?; + let interface_name = bring_up( + ConnectionTarget::Tunnel(tunnel), + tunnel_preshared_key, + mtu, + &DB_POOL, + None, + ) + .await?; state - .add_connection(tunnel.id, &interface_name, ConnectionType::Tunnel) + .add_connection(tunnel_id, &interface_name, ConnectionType::Tunnel) .await; debug!("Sending event informing the frontend that a new connection has been created."); - handle.emit(EventKey::ConnectionChanged.into(), ())?; + handle + .emit(EventKey::ConnectionChanged.into(), ()) + .map_err(crate::tauri_err_to_app_err)?; debug!("Event informing the frontend that a new connection has been created sent."); // spawn log watcher - debug!("Spawning log watcher for tunnel {}", tunnel.name); + debug!("Spawning log watcher for tunnel {tunnel_name}"); spawn_log_watcher_task( handle, - tunnel.id, + tunnel_id, interface_name, ConnectionType::Tunnel, Level::DEBUG, None, ) .await?; - debug!("Log watcher for tunnel {} spawned", tunnel.name); + debug!("Log watcher for tunnel {tunnel_name} spawned"); Ok(()) } @@ -764,164 +796,6 @@ pub fn execute_command(command: &str) -> Result<(), Error> { } Ok(()) } - -/// Helper function to remove interface and close connection -pub(crate) async fn disconnect_interface( - active_connection: &ActiveConnection, -) -> Result<(), Error> { - debug!( - "Disconnecting interface {}.", - active_connection.interface_name - ); - let location_id = active_connection.location_id; - let interface_name = active_connection.interface_name.clone(); - - match active_connection.connection_type { - ConnectionType::Location => { - let Some(location) = Location::find_by_id(&*DB_POOL, location_id).await? else { - error!( - "Error while disconnecting interface {interface_name}, location with ID \ - {location_id} not found" - ); - return Err(Error::NotFound); - }; - - #[cfg(target_os = "macos")] - { - let result = location.stop_vpn_tunnel(); - error!( - "stop_tunnel() for location {} returned {result:?}", - location.name - ); - if !result { - return Err(Error::InternalError("Error from tunnel".into())); - } - } - - #[cfg(not(target_os = "macos"))] - { - let request = RemoveInterfaceRequest { - interface_name, - endpoint: location.endpoint.clone(), - }; - debug!( - "Sending request to the background service to remove interface {} for location \ - {}...", - active_connection.interface_name, location.name - ); - if let Err(error) = DAEMON_CLIENT.clone().remove_interface(request).await { - let msg = if error.code() == Code::Unavailable { - format!( - "Couldn't remove interface {}. Background service is unavailable. \ - Please make sure the service is running. Error: {error}.", - active_connection.interface_name - ) - } else { - format!( - "Failed to send a request to the background service to remove interface \ - {}. Error: {error}.", - active_connection.interface_name - ) - }; - error!("{msg}"); - } - } - - let connection: Connection = active_connection.into(); - let connection = connection.save(&*DB_POOL).await?; - debug!( - "Saved location {} new connection status in the database", - location.name - ); - trace!("Saved connection: {connection:?}"); - info!( - "Network interface {} for location {location} has been removed", - active_connection.interface_name - ); - debug!("Finished disconnecting from location {}", location.name); - } - ConnectionType::Tunnel => { - let Some(tunnel) = Tunnel::find_by_id(&*DB_POOL, location_id).await? else { - error!( - "Error while disconnecting interface {interface_name}, tunnel with ID \ - {location_id} not found" - ); - return Err(Error::NotFound); - }; - if let Some(pre_down) = &tunnel.pre_down { - debug!( - "Executing defined PreDown command before setting up the interface {} for the \ - tunnel {tunnel}: {pre_down}", - active_connection.interface_name - ); - let _ = execute_command(pre_down); - info!( - "Executed defined PreDown command before setting up the interface {} for the \ - tunnel {tunnel}: {pre_down}", - active_connection.interface_name - ); - } - - #[cfg(target_os = "macos")] - { - let result = tunnel.stop_vpn_tunnel(); - error!( - "stop_tunnel() for tunnel {} returned {result:?}", - tunnel.name - ); - if !result { - return Err(Error::InternalError("Error from tunnel".into())); - } - } - - #[cfg(not(target_os = "macos"))] - { - let request = RemoveInterfaceRequest { - interface_name, - endpoint: tunnel.endpoint.clone(), - }; - if let Err(error) = DAEMON_CLIENT.clone().remove_interface(request).await { - error!( - "Error while removing interface {}, error details: {error:?}", - active_connection.interface_name - ); - return Err(Error::InternalError(format!( - "Failed to remove interface, error message: {}", - error.message() - ))); - } - } - if let Some(post_down) = &tunnel.post_down { - debug!( - "Executing defined PostDown command after removing the interface {} for the \ - tunnel {tunnel}: {post_down}", - active_connection.interface_name - ); - let _ = execute_command(post_down); - info!( - "Executed defined PostDown command after removing the interface {} for the \ - tunnel {tunnel}: {post_down}", - active_connection.interface_name - ); - } - let connection: TunnelConnection = active_connection.into(); - let connection = connection.save(&*DB_POOL).await?; - debug!( - "Saved new tunnel {} connection status in the database", - tunnel.name - ); - trace!("Saved connection: {connection:#?}"); - info!( - "Network interface {} for tunnel {tunnel} has been removed", - active_connection.interface_name - ); - debug!("Finished disconnecting from tunnel {}", tunnel.name); - } - } - - Ok(()) -} - /// Helper function to get the name of a tunnel or location by its ID /// Returns the name of the tunnel or location if it exists, otherwise "UNKNOWN" /// This is for logging purposes. @@ -1014,7 +888,9 @@ async fn check_connection( .await; debug!("Sending event informing the frontend that a new connection has been created."); - app_handle.emit(EventKey::ConnectionChanged.into(), ())?; + app_handle + .emit(EventKey::ConnectionChanged.into(), ()) + .map_err(crate::tauri_err_to_app_err)?; debug!("Event informing the frontend that a new connection has been created sent."); debug!("Spawning service log watcher for {connection_type} {name}..."); @@ -1087,32 +963,22 @@ pub async fn sync_connections(app_handle: &AppHandle) -> Result<(), Error> { Ok(()) } -#[must_use] -pub(crate) fn construct_platform_header() -> String { - let os = os_info::get(); - - let platform_info = ClientPlatformInfo { - os_family: std::env::consts::OS.to_string(), - os_type: os.os_type().to_string(), - version: os.version().to_string(), - edition: os.edition().map(str::to_string), - codename: os.codename().map(str::to_string), - bitness: Some(os.bitness().to_string()), - architecture: os.architecture().map(str::to_string), - }; - - debug!("Constructed platform info header: {platform_info:?}"); +#[cfg(test)] +mod tests { + use super::stats_diffs; - let buffer = platform_info.encode_to_vec(); + #[test] + fn stats_diffs_returns_zero_for_first_sample() { + assert_eq!(stats_diffs(None, (100, 200)), (0, 0)); + } - BASE64_STANDARD.encode(buffer) -} + #[test] + fn stats_diffs_returns_counter_increments() { + assert_eq!(stats_diffs(Some((100, 200)), (150, 275)), (50, 75)); + } -#[must_use] -/// Utility function to get all tunnels and locations from the database. -#[cfg(target_os = "macos")] -pub async fn get_all_tunnels_locations() -> (Vec>, Vec>) { - let tunnels = Tunnel::all(&*DB_POOL).await.unwrap_or_default(); - let locations = Location::all(&*DB_POOL, false).await.unwrap_or_default(); - (tunnels, locations) + #[test] + fn stats_diffs_clamps_counter_resets_to_zero() { + assert_eq!(stats_diffs(Some((100, 200)), (50, 125)), (0, 0)); + } } diff --git a/src-tauri/src/window_manager/macos.rs b/src-tauri/src/window_manager/macos.rs new file mode 100644 index 000000000..1da0af69e --- /dev/null +++ b/src-tauri/src/window_manager/macos.rs @@ -0,0 +1,106 @@ +use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowStyleMask, NSWindowTitleVisibility}; +use tauri::{ + AppHandle, LogicalPosition, LogicalSize, Manager, Monitor, Position, Runtime, WebviewWindow, +}; + +use crate::{appstate::AppState, window_manager::WINDOW_GAP}; + +pub(crate) fn enable_rounded_corners( + window: &WebviewWindow, + enable_system_controls: bool, +) -> Result<(), String> { + window + .with_webview(move |webview| { + let ns_window = unsafe { &*webview.ns_window().cast::() }; + // Add necessary styles for rounded corners. + let style_mask = ns_window.styleMask() + | NSWindowStyleMask::Borderless + | NSWindowStyleMask::Titled + | NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable + | NSWindowStyleMask::FullSizeContentView; + ns_window.setStyleMask(style_mask); + ns_window.setTitlebarAppearsTransparent(true); + ns_window.setTitleVisibility(NSWindowTitleVisibility::Hidden); + + let buttons = [ + ns_window.standardWindowButton(NSWindowButton::CloseButton), + ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton), + ns_window.standardWindowButton(NSWindowButton::ZoomButton), + ]; + for btn in buttons.into_iter().flatten() { + btn.setHidden(!enable_system_controls); + } + }) + .map_err(|err| err.to_string()) +} + +/// Try to get monitor at the given position, with a fall back to primary monitor, and then to the +/// first one on the list of available monitors. +fn get_monitor_for_position(app: &AppHandle, x: f64, y: f64) -> Option { + if let Ok(Some(monitor)) = app.monitor_from_point(x, y) { + return Some(monitor); + } + + if let Ok(Some(monitor)) = app.primary_monitor() { + return Some(monitor); + } + + // On macOS, it seems this is the only working method (as of Tauri 2.11), but fortunately it + // returns the current monitor as the first one. + if let Ok(mut monitors) = app.available_monitors() { + monitors.pop() + } else { + None + } +} + +fn get_tray_window_position( + app: &AppHandle, + window_size: LogicalSize, +) -> Option> { + let app_state = app.state::(); + let mut x; + let mut y; + + if let Some(tray_position) = *app_state.tray_click_position.lock().unwrap() { + let monitor = get_monitor_for_position(app, tray_position.x, tray_position.y)?; + + let scale_factor = monitor.scale_factor(); + let monitor_position = monitor.position().to_logical::(scale_factor); + let monitor_size = monitor.size().to_logical::(scale_factor); + let tray_position = tray_position.to_logical::(scale_factor); + + x = tray_position.x; + y = tray_position.y; + + x = x.clamp( + monitor_position.x, + monitor_position.x + monitor_size.width - window_size.width, + ); + y = y.clamp( + monitor_position.y, + monitor_position.y + monitor_size.height - window_size.height, + ); + } else { + let monitor = app.primary_monitor().ok().flatten()?; + let scale_factor = monitor.scale_factor(); + let monitor_position = monitor.position().to_logical::(scale_factor); + let monitor_size = monitor.size().to_logical::(scale_factor); + + x = monitor_position.x + monitor_size.width - window_size.width - WINDOW_GAP; + y = monitor_position.y + WINDOW_GAP; + } + + Some(LogicalPosition::new(x, y)) +} + +pub(super) fn position_window_near_tray(app: &AppHandle, window: &WebviewWindow) { + let size = window.outer_size().unwrap_or_default(); + let scale_factor = window.scale_factor().unwrap_or(1.0); + if let Some(position) = get_tray_window_position(app, size.to_logical::(scale_factor)) { + if let Err(err) = window.set_position(Position::Logical(position)) { + warn!("Failed to position window near tray icon: {err}"); + } + } +} diff --git a/src-tauri/src/window_manager/mod.rs b/src-tauri/src/window_manager/mod.rs new file mode 100644 index 000000000..0b30839c2 --- /dev/null +++ b/src-tauri/src/window_manager/mod.rs @@ -0,0 +1,318 @@ +use defguard_client_core::version::mark_welcome_shown; +use tauri::{ + async_runtime::block_on, AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, + WebviewWindowBuilder, +}; + +use crate::{ + database::{ + models::{location::Location, tunnel::Tunnel, Id}, + DB_POOL, + }, + events::EventKey, +}; + +/// Returns `true` if there are any non-service locations in the database. +pub async fn has_non_service_locations() -> bool { + Location::exist(&*DB_POOL, false).await.unwrap_or_default() +} + +/// Returns `true` if the compact (tray) view has anything to show: at least +/// one non-service location or at least one tunnel. +pub async fn has_tray_content() -> bool { + if has_non_service_locations().await { + return true; + } + Tunnel::exists(&*DB_POOL).await.unwrap_or_default() +} + +pub const COMPACT_WINDOW_ID: &str = "compact-view"; +pub const FULL_VIEW_WINDOW_ID: &str = "full-view"; +pub const WELCOME_WINDOW_ID: &str = "welcome"; +pub const WELCOME_WINDOW_WIDTH: f64 = 640.0; +pub const WELCOME_WINDOW_HEIGHT: f64 = 585.0; +pub const COMPACT_WINDOW_WIDTH: f64 = 380.0; +pub const COMPACT_WINDOW_HEIGHT: f64 = 680.0; +pub const FULL_VIEW_WINDOW_WIDTH: f64 = 800.0; +pub const FULL_VIEW_WINDOW_HEIGHT: f64 = 700.0; +#[cfg(not(target_os = "linux"))] +const WINDOW_GAP: f64 = 20.0; +const WINDOW_TITLE: &str = "Defguard"; + +#[must_use] +pub fn compact_view_ui_url() -> WebviewUrl { + if cfg!(any(defguard_client_dev)) { + WebviewUrl::External("http://localhost:5072/compact/".parse().unwrap()) + } else { + WebviewUrl::App("compact/".into()) + } +} + +#[must_use] +pub fn full_view_ui_url() -> WebviewUrl { + if cfg!(any(defguard_client_dev)) { + WebviewUrl::External("http://localhost:5072/full/".parse().unwrap()) + } else { + WebviewUrl::App("full/".into()) + } +} + +#[must_use] +pub fn welcome_ui_url() -> WebviewUrl { + if cfg!(any(defguard_client_dev)) { + WebviewUrl::External("http://localhost:5072/welcome/".parse().unwrap()) + } else { + WebviewUrl::App("welcome/".into()) + } +} + +/// Hides any currently visible webview window other than `skip_label`. +fn hide_shown_windows(app: &AppHandle, skip_label: &str) { + for (label, window) in app.webview_windows() { + if label != skip_label && window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } + } +} + +pub struct WindowManager; + +impl WindowManager { + pub fn build_tray_window(app: &AppHandle) -> tauri::Result { + let window = WebviewWindowBuilder::new(app, COMPACT_WINDOW_ID, compact_view_ui_url()) + .title(WINDOW_TITLE) + .inner_size(COMPACT_WINDOW_WIDTH, COMPACT_WINDOW_HEIGHT) + .resizable(false) + .decorations(false) + .visible(false) + .always_on_top(true) + .skip_taskbar(true); + #[cfg(target_os = "macos")] + let window = window.hidden_title(true); + + let window = window.build()?; + + #[cfg(target_os = "macos")] + if let Err(err) = macos::enable_rounded_corners(&window, false) { + warn!("Failed to enable rounded corners on tray window: {err}"); + } + + Ok(window) + } + + pub fn build_full_view_window(app: &AppHandle) -> tauri::Result { + let window = WebviewWindowBuilder::new(app, FULL_VIEW_WINDOW_ID, full_view_ui_url()) + .title(WINDOW_TITLE) + .inner_size(FULL_VIEW_WINDOW_WIDTH, FULL_VIEW_WINDOW_HEIGHT) + .min_inner_size(FULL_VIEW_WINDOW_WIDTH, FULL_VIEW_WINDOW_HEIGHT) + .decorations(cfg!(not(any(windows, target_os = "macos")))) + .visible(false) + .build()?; + + #[cfg(target_os = "macos")] + if let Err(err) = macos::enable_rounded_corners(&window, true) { + warn!("Failed to enable rounded corners on full view window: {err}"); + } + + Ok(window) + } + + pub fn build_welcome_window(app: &AppHandle) -> tauri::Result { + let window = WebviewWindowBuilder::new(app, WELCOME_WINDOW_ID, welcome_ui_url()) + .title(WINDOW_TITLE) + .inner_size(WELCOME_WINDOW_WIDTH, WELCOME_WINDOW_HEIGHT) + .resizable(false) + .maximizable(false) + .decorations(false) + .skip_taskbar(false) + .always_on_top(true) + .visible(false); + #[cfg(target_os = "macos")] + let window = window.hidden_title(true); + + let window = window.build()?; + + #[cfg(target_os = "macos")] + if let Err(err) = macos::enable_rounded_corners(&window, false) { + warn!("Failed to enable rounded corners on welcome window: {err}"); + } + + Ok(window) + } +} + +#[cfg(not(windows))] +impl WindowManager { + pub fn open_tray(app: &AppHandle) -> tauri::Result { + let window = if let Some(window) = app.get_webview_window(COMPACT_WINDOW_ID) { + let _ = window.unminimize(); + window + } else { + Self::build_tray_window(app)? + }; + #[cfg(target_os = "macos")] + { + macos::position_window_near_tray(app, &window); + let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory); + let _ = app.set_dock_visibility(false); + let _ = app.show(); + } + let _ = window.show(); + let _ = window.set_focus(); + Ok(window) + } + + pub fn open_full_view(app: &AppHandle) -> tauri::Result { + let window = if let Some(window) = app.get_webview_window(FULL_VIEW_WINDOW_ID) { + let _ = window.unminimize(); + window + } else { + Self::build_full_view_window(app)? + }; + #[cfg(target_os = "macos")] + { + let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular); + let _ = app.set_dock_visibility(true); + let _ = app.show(); + } + let _ = window.show(); + let _ = window.set_focus(); + Ok(window) + } + + pub fn open_welcome_view(app: &AppHandle) -> tauri::Result { + hide_shown_windows(app, WELCOME_WINDOW_ID); + + let window = if let Some(window) = app.get_webview_window(WELCOME_WINDOW_ID) { + let _ = window.unminimize(); + window + } else { + Self::build_welcome_window(app)? + }; + #[cfg(target_os = "macos")] + let _ = app.set_dock_visibility(true); + #[cfg(target_os = "macos")] + let _ = app.show(); + let _ = window.show(); + let _ = window.set_focus(); + Ok(window) + } +} + +#[cfg(windows)] +pub mod windows; + +#[cfg(target_os = "macos")] +pub mod macos; + +// Export tauri commands so they can be registered in main.rs +pub(crate) fn show_tray_window(app: &AppHandle) { + let _ = WindowManager::open_tray(app); +} + +/// Show the compact (tray) window when there is tray content (a non-service +/// location or a tunnel), otherwise fall back to the full view. +pub fn show_tray_or_full_view(app: &AppHandle) { + if block_on(has_tray_content()) { + // Hide the full view if it is open and visible (not minimized) so only the compact window is shown. + if let Some(full_view) = app.get_webview_window(FULL_VIEW_WINDOW_ID) { + let full_view_visible = full_view.is_visible().ok().unwrap_or(false); + if full_view_visible { + let _ = full_view.hide(); + } + } + show_tray_window(app); + } else { + let _ = WindowManager::open_full_view(app); + } +} + +/// Surface the window that should host the MFA flow, and emit `MfaTrigger` targeted at that window. +pub fn trigger_mfa(app: &AppHandle, location: &Location) { + let target = if let Some(window) = app + .get_webview_window(FULL_VIEW_WINDOW_ID) + .filter(|w| w.is_visible().unwrap_or(false)) + { + let _ = window.unminimize(); + let _ = window.set_focus(); + FULL_VIEW_WINDOW_ID + } else { + show_tray_window(app); + COMPACT_WINDOW_ID + }; + let _ = app.emit_to(target, EventKey::MfaTrigger.into(), location); +} + +#[tauri::command] +pub fn open_tray_window(app: AppHandle) { + show_tray_window(&app); +} + +#[tauri::command] +pub fn open_full_view_window(app: AppHandle) { + let _ = WindowManager::open_full_view(&app); +} + +#[tauri::command] +pub fn swap_to_full_view(app: AppHandle) { + info!("swap_to_full_view called"); + if let Some(window) = app.get_webview_window(COMPACT_WINDOW_ID) { + if let Err(err) = window.hide() { + error!("swap_to_full_view task: Failed to hide new-ui window: {err:?}"); + } + } + if let Err(err) = WindowManager::open_full_view(&app) { + error!("swap_to_full_view task: Failed to open full view: {err:?}"); + } else if let Err(err) = app.emit(EventKey::WindowSwapped.into(), ()) { + error!("swap_to_full_view task: Failed to emit window swapped event: {err:?}"); + } +} + +#[tauri::command] +pub fn close_tray_window(app: AppHandle) { + info!("close_tray_window called"); + + if let Some(window) = app.get_webview_window(COMPACT_WINDOW_ID) { + info!("close_tray_window task: Hiding new-ui window"); + if let Err(err) = window.hide() { + error!("close_tray_window task: Failed to hide new-ui window: {err:?}"); + } + } else { + warn!("close_tray_window task: new-ui window not found"); + } +} + +#[tauri::command] +pub fn close_welcome_window(app: AppHandle) { + info!("close_welcome_window called"); + + if let Some(window) = app.get_webview_window(WELCOME_WINDOW_ID) { + if let Err(err) = window.hide() { + error!("close_welcome_window task: Failed to hide welcome window: {err:?}"); + } + } else { + warn!("close_welcome_window task: welcome window not found"); + } + + let config_dir = app + .path() + .app_data_dir() + .expect("Failed to access app data"); + mark_welcome_shown(&config_dir, &app.package_info().version); + + show_tray_or_full_view(&app); +} + +#[tauri::command] +pub fn swap_to_tray(app: AppHandle) { + info!("swap_to_tray called"); + show_tray_window(&app); + if let Some(window) = app.get_webview_window(FULL_VIEW_WINDOW_ID) { + if let Err(err) = window.hide() { + error!("swap_to_tray task: Failed to hide full-view window: {err:?}"); + } + } + if let Err(err) = app.emit(EventKey::WindowSwapped.into(), ()) { + error!("swap_to_tray task: Failed to emit window swapped event: {err:?}"); + } +} diff --git a/src-tauri/src/window_manager/windows.rs b/src-tauri/src/window_manager/windows.rs new file mode 100644 index 000000000..2e6c29298 --- /dev/null +++ b/src-tauri/src/window_manager/windows.rs @@ -0,0 +1,369 @@ +use std::{ffi::OsString, os::windows::ffi::OsStringExt}; + +use tauri::Manager; +use windows::Win32::{ + Foundation::{LPARAM, RECT}, + Graphics::Gdi::{EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFOEXW}, + UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}, +}; + +use crate::window_manager::{ + hide_shown_windows, WindowManager, COMPACT_WINDOW_HEIGHT, COMPACT_WINDOW_ID, + COMPACT_WINDOW_WIDTH, FULL_VIEW_WINDOW_HEIGHT, FULL_VIEW_WINDOW_ID, FULL_VIEW_WINDOW_WIDTH, + WELCOME_WINDOW_HEIGHT, WELCOME_WINDOW_ID, WELCOME_WINDOW_WIDTH, WINDOW_GAP, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum TaskbarPosition { + Bottom, + Top, + Left, + Right, + HiddenOrNone, +} + +#[derive(Debug, Clone)] +pub struct MonitorInfo { + pub name: String, + pub is_primary: bool, + pub physical_x: i32, + pub physical_y: i32, + pub physical_width: u32, + pub physical_height: u32, + pub scale_factor: f64, + pub taskbar_position: TaskbarPosition, + pub taskbar_size: u32, +} + +impl WindowManager { + pub fn get_monitors() -> Vec { + let mut monitors = Vec::new(); + + unsafe extern "system" fn monitor_enum_proc( + hmonitor: HMONITOR, + _hdc: HDC, + _rect: *mut RECT, + lparam: LPARAM, + ) -> windows::core::BOOL { + let monitors = &mut *(lparam.0 as *mut Vec); + + let mut info = MONITORINFOEXW::default(); + info.monitorInfo.cbSize = std::mem::size_of::() as u32; + + if GetMonitorInfoW(hmonitor, &mut info as *mut _ as *mut _).as_bool() { + // Name + let name_len = info + .szDevice + .iter() + .position(|&c| c == 0) + .unwrap_or(info.szDevice.len()); + let name = OsString::from_wide(&info.szDevice[..name_len]) + .to_string_lossy() + .into_owned(); + + let is_primary = (info.monitorInfo.dwFlags & 1) != 0; + + // DPI and Scaling + let mut dpi_x = 0; + let mut dpi_y = 0; + let scale_factor = if GetDpiForMonitor( + hmonitor, + MDT_EFFECTIVE_DPI, + &mut dpi_x, + &mut dpi_y, + ) + .is_ok() + { + dpi_x as f64 / 96.0 + } else { + 1.0 + }; + + let physical_x = info.monitorInfo.rcMonitor.left; + let physical_y = info.monitorInfo.rcMonitor.top; + let physical_width = (info.monitorInfo.rcMonitor.right + - info.monitorInfo.rcMonitor.left) + .unsigned_abs(); + let physical_height = (info.monitorInfo.rcMonitor.bottom + - info.monitorInfo.rcMonitor.top) + .unsigned_abs(); + + // Taskbar position and size + let mut taskbar_position = TaskbarPosition::HiddenOrNone; + let mut taskbar_size = 0; + + let mon = info.monitorInfo.rcMonitor; + let work = info.monitorInfo.rcWork; + + if work.bottom < mon.bottom { + taskbar_position = TaskbarPosition::Bottom; + taskbar_size = (mon.bottom - work.bottom).unsigned_abs(); + } else if work.top > mon.top { + taskbar_position = TaskbarPosition::Top; + taskbar_size = (work.top - mon.top).unsigned_abs(); + } else if work.left > mon.left { + taskbar_position = TaskbarPosition::Left; + taskbar_size = (work.left - mon.left).unsigned_abs(); + } else if work.right < mon.right { + taskbar_position = TaskbarPosition::Right; + taskbar_size = (mon.right - work.right).unsigned_abs(); + } + + monitors.push(MonitorInfo { + name, + is_primary, + physical_x, + physical_y, + physical_width, + physical_height, + scale_factor, + taskbar_position, + taskbar_size, + }); + } + + true.into() + } + + unsafe { + let _ = EnumDisplayMonitors( + None, + None, + Some(monitor_enum_proc), + LPARAM(&mut monitors as *mut _ as isize), + ); + } + + monitors + } + + pub fn open_tray(app: &tauri::AppHandle) -> tauri::Result { + let state = tauri::Manager::state::(app); + let tray_pos = *state.tray_click_position.lock().unwrap(); + let monitors = Self::get_monitors(); + let primary = monitors + .iter() + .find(|m| m.is_primary) + .unwrap_or(&monitors[0]); + + let window = if let Some(window) = app.get_webview_window(COMPACT_WINDOW_ID) { + let _ = window.unminimize(); + window + } else { + Self::build_tray_window(app)? + }; + + let logical_width = COMPACT_WINDOW_WIDTH; + let logical_height = COMPACT_WINDOW_HEIGHT; + + let physical_width = (logical_width * primary.scale_factor) as i32; + let physical_height = (logical_height * primary.scale_factor) as i32; + + let physical_gap = (WINDOW_GAP * primary.scale_factor) as i32; + + let work_left = primary.physical_x + + if primary.taskbar_position == TaskbarPosition::Left { + primary.taskbar_size as i32 + } else { + 0 + }; + let work_top = primary.physical_y + + if primary.taskbar_position == TaskbarPosition::Top { + primary.taskbar_size as i32 + } else { + 0 + }; + let work_right = primary.physical_x + primary.physical_width as i32 + - if primary.taskbar_position == TaskbarPosition::Right { + primary.taskbar_size as i32 + } else { + 0 + }; + let work_bottom = primary.physical_y + primary.physical_height as i32 + - if primary.taskbar_position == TaskbarPosition::Bottom { + primary.taskbar_size as i32 + } else { + 0 + }; + + let (final_x, final_y) = if let Some(pos) = tray_pos { + let icon_x = pos.x as i32; + let icon_y = pos.y as i32; + let icon_width = 0; + let icon_height = 0; + + let icon_center_x = icon_x + (icon_width / 2); + let default_x = icon_center_x - (physical_width / 2); + let max_x = work_right - physical_gap - physical_width; + let min_x = work_left + physical_gap; + let clamped_x = default_x.clamp(min_x, max_x); + + let icon_center_y = icon_y + (icon_height / 2); + let default_y = icon_center_y - (physical_height / 2); + let max_y = work_bottom - physical_gap - physical_height; + let min_y = work_top + physical_gap; + let clamped_y = default_y.clamp(min_y, max_y); + + match primary.taskbar_position { + TaskbarPosition::Bottom => { + (clamped_x, work_bottom - physical_height - physical_gap) + } + TaskbarPosition::Top => (clamped_x, work_top + physical_gap), + TaskbarPosition::Left => (work_left + physical_gap, clamped_y), + TaskbarPosition::Right => (work_right - physical_width - physical_gap, clamped_y), + _ => (clamped_x, work_bottom - physical_height - physical_gap), + } + } else { + let x = work_right - physical_width - physical_gap; + let y = work_bottom - physical_height - physical_gap; + (x, y) + }; + + window.set_always_on_top(true)?; + window.set_position(tauri::PhysicalPosition::new(final_x, final_y))?; + window.show()?; + + let window_focus = window.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + // Toggle always_on_top to force Z-order above the Windows tray overflow popup + let _ = window_focus.set_always_on_top(false); + let _ = window_focus.set_always_on_top(true); + let _ = window_focus.set_focus(); + }); + + Ok(window) + } + + pub fn open_full_view(app: &tauri::AppHandle) -> tauri::Result { + info!("open_full_view: Getting monitors"); + let monitors = Self::get_monitors(); + info!("open_full_view: Found {} monitors", monitors.len()); + let primary = monitors + .iter() + .find(|m| m.is_primary) + .unwrap_or(&monitors[0]); + info!( + "open_full_view: Primary monitor scale factor: {}", + primary.scale_factor + ); + + info!("open_full_view: Checking if full view window exists"); + let window = if let Some(window) = app.get_webview_window(FULL_VIEW_WINDOW_ID) { + info!("open_full_view: full view window exists, unminimizing"); + let _ = window.unminimize(); + window + } else { + info!("open_full_view: full view window does not exist, building it"); + let win = Self::build_full_view_window(app)?; + info!("open_full_view: full view window built successfully"); + win + }; + + info!("open_full_view: Querying outer_size"); + let outer_size = window.outer_size().unwrap_or(tauri::PhysicalSize { + width: (FULL_VIEW_WINDOW_WIDTH * primary.scale_factor) as u32, + height: (FULL_VIEW_WINDOW_HEIGHT * primary.scale_factor) as u32, + }); + info!("open_full_view: outer_size = {outer_size:?}"); + + info!("open_full_view: Querying inner_size"); + let inner_size = window.inner_size().unwrap_or(tauri::PhysicalSize { + width: (FULL_VIEW_WINDOW_WIDTH * primary.scale_factor) as u32, + height: (FULL_VIEW_WINDOW_HEIGHT * primary.scale_factor) as u32, + }); + info!("open_full_view: inner_size = {inner_size:?}"); + + let physical_width = outer_size.width as i32; + let physical_height = outer_size.height as i32; + + // Windows invisible borders (shadows) are included in outer_size for decorated windows. + let border_thickness = (physical_width - (inner_size.width as i32)) / 2; + let visible_height = physical_height - border_thickness; + + let physical_gap = (WINDOW_GAP * primary.scale_factor) as i32; + + let center_x = primary.physical_x + (primary.physical_width as i32 / 2); + let center_y = primary.physical_y + (primary.physical_height as i32 / 2); + + let mut window_x = center_x - (physical_width / 2); + let mut window_y = center_y - (visible_height / 2); + + let taskbar_size = primary.taskbar_size as i32; + + match primary.taskbar_position { + TaskbarPosition::Bottom => { + let max_y = primary.physical_y + primary.physical_height as i32 + - taskbar_size + - physical_gap; + if window_y + visible_height > max_y { + window_y = max_y - visible_height; + } + } + TaskbarPosition::Top => { + let min_y = primary.physical_y + taskbar_size + physical_gap; + if window_y < min_y { + window_y = min_y; + } + } + TaskbarPosition::Left => { + let min_x = primary.physical_x + taskbar_size + physical_gap; + if window_x + border_thickness < min_x { + window_x = min_x - border_thickness; + } + } + TaskbarPosition::Right => { + let max_x = primary.physical_x + primary.physical_width as i32 + - taskbar_size + - physical_gap; + if window_x + physical_width - border_thickness > max_x { + window_x = max_x - physical_width + border_thickness; + } + } + _ => {} + } + + info!("open_full_view: Setting position to ({window_x}, {window_y})"); + window.set_position(tauri::PhysicalPosition::new(window_x, window_y))?; + info!("open_full_view: Position set, showing window"); + window.show()?; + info!("open_full_view: Window shown successfully"); + Ok(window) + } + + pub fn open_welcome_view(app: &tauri::AppHandle) -> tauri::Result { + hide_shown_windows(app, WELCOME_WINDOW_ID); + + let monitors = Self::get_monitors(); + let primary = monitors + .iter() + .find(|m| m.is_primary) + .unwrap_or(&monitors[0]); + + let window = if let Some(window) = app.get_webview_window(WELCOME_WINDOW_ID) { + let _ = window.unminimize(); + window + } else { + Self::build_welcome_window(app)? + }; + + let outer_size = window.outer_size().unwrap_or(tauri::PhysicalSize { + width: (WELCOME_WINDOW_WIDTH * primary.scale_factor) as u32, + height: (WELCOME_WINDOW_HEIGHT * primary.scale_factor) as u32, + }); + + let physical_width = outer_size.width as i32; + let physical_height = outer_size.height as i32; + + let center_x = primary.physical_x + (primary.physical_width as i32 / 2); + let center_y = primary.physical_y + (primary.physical_height as i32 / 2); + + let window_x = center_x - (physical_width / 2); + let window_y = center_y - (physical_height / 2); + + window.set_position(tauri::PhysicalPosition::new(window_x, window_y))?; + window.show()?; + window.set_focus()?; + Ok(window) + } +} diff --git a/src-tauri/tauri.app.conf.json b/src-tauri/tauri.app.conf.json new file mode 100644 index 000000000..0bed74a7d --- /dev/null +++ b/src-tauri/tauri.app.conf.json @@ -0,0 +1,14 @@ +{ + "bundle": { + "macOS": { + "entitlements": "./Client.entitlements", + "files": { + "embedded.provisionprofile": "/Users/admin/Library/Developer/Xcode/UserData/Provisioning Profiles/fc371386-d198-4658-be77-5d659f274523.provisionprofile", + "PlugIns/VPNExtension.appex": "../swift/extension/build/Release/VPNExtension.appex" + } + }, + "targets": [ + "app" + ] + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2fbc00c93..fde6c1b24 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,63 +1,47 @@ { "$schema": "https://schema.tauri.app/config/2", "build": { - "beforeBuildCommand": "pnpm build", - "beforeDevCommand": "pnpm dev", "frontendDist": "../dist", - "devUrl": "http://localhost:3001" + "devUrl": "http://localhost:5072" }, "bundle": { "active": true, "category": "Utility", "copyright": "Defguard", - "targets": [ - "deb", - "app" - ], "externalBin": [], "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" + "icons/windows/32x32.png", + "icons/windows/128x128.png", + "icons/windows/128x128@2x.png", + "icons/windows/icon.icns", + "icons/windows/icon.ico" ], "windows": { - "certificateThumbprint": null, + "certificateThumbprint": "c79305cdcb7e5832dd161d18580d6a846bd0506b", "digestAlgorithm": "sha256", - "timestampUrl": "", + "timestampUrl": "http://time.certum.pl/", "wix": { "upgradeCode": "923b21f5-7d3f-4f5e-8dcb-43fe1c65fb43", "bannerPath": "./resources-windows/msi/top_banner.png", "dialogImagePath": "./resources-windows/msi/side_banner.png", - "fragmentPaths": [ - "./resources-windows/fragments/service.wxs", - "./resources-windows/fragments/provisioning.wxs" - ], - "componentRefs": [ - "DefguardServiceFragment", - "ProvisioningScriptFragment" - ], + "fragmentPaths": [ + "./resources-windows/fragments/service.wxs", + "./resources-windows/fragments/provisioning.wxs" + ], + "componentRefs": [ + "DefguardServiceFragment", + "ProvisioningScriptFragment" + ], "template": "./resources-windows/msi/main.wxs" } }, - "macOS": { - "bundleVersion": "@BUILD_NUMBER@", - "entitlements": "./Client.entitlements", - "files": { - "embedded.provisionprofile": "Defguard_Client_Mac_App_Store.provisionprofile", - "PlugIns/VPNExtension.appex": "../swift/extension/build/Release/VPNExtension.appex" - }, - "minimumSystemVersion": "13.5" - }, - "resources": [ - "resources/icons/*" - ], + "resources": ["resources/icons/tray/*"], "shortDescription": "Defguard desktop client", "longDescription": "Defguard desktop client", "linux": { "deb": { "files": { + "/usr/bin/dg": "target/release/dg", "/usr/sbin/defguard-service": "target/release/defguard-service", "/usr/lib/systemd/system/defguard-service.service": "../resources-linux/defguard-service.service", "../control/rules": "../resources-linux/rules", @@ -65,18 +49,15 @@ "../control/prerm": "../resources-linux/prerm", "../control/postrm": "../resources-linux/postrm" }, - "depends": [ - "desktop-file-utils" - ] + "depends": ["desktop-file-utils"] }, "rpm": { "files": { + "/usr/bin/dg": "target/release/dg", "/usr/sbin/defguard-service": "target/release/defguard-service", "/lib/systemd/system/defguard-service.service": "../resources-linux/defguard-service.service" }, - "depends": [ - "desktop-file-utils" - ], + "depends": ["desktop-file-utils"], "postInstallScript": "../resources-linux/postinst", "preRemoveScript": "../resources-linux/prerm", "postRemoveScript": "../resources-linux/postrm" @@ -86,38 +67,18 @@ "productName": "Defguard", "mainBinaryName": "defguard-client", "identifier": "net.defguard", - "version": "1.6.8", + "version": "2.1.0", "app": { "security": { - "capabilities": [ - "main-capability" - ], + "capabilities": ["main-capability"], "csp": null }, - "windows": [ - { - "fullscreen": false, - "center": true, - "maximized": true, - "height": 720, - "resizable": true, - "maximizable": true, - "minimizable": true, - "closable": true, - "title": "Defguard", - "width": 992, - "minWidth": 650, - "minHeight": 450, - "useHttpsScheme": true - } - ] + "windows": [] }, "plugins": { "deep-link": { "desktop": { - "schemes": [ - "defguard" - ] + "schemes": ["defguard"] } } } diff --git a/src-tauri/tauri.dmg.conf.json b/src-tauri/tauri.dmg.conf.json new file mode 100644 index 000000000..00a028816 --- /dev/null +++ b/src-tauri/tauri.dmg.conf.json @@ -0,0 +1,26 @@ +{ + "build": { + "beforeBundleCommand": { + "cwd": "../swift", + "script": "./build.sh VPNSystemExtension Installer" + }, + "features": ["macos_installer"] + }, + "bundle": { + "icon": [ + "icons/macos/32x32.png", + "icons/macos/128x128.png", + "icons/macos/128x128@2x.png", + "icons/macos/icon.icns", + "icons/macos/icon.ico" + ], + "macOS": { + "entitlements": "Installer.entitlements", + "files": { + "embedded.provisionprofile": "/Users/admin/Library/Developer/Xcode/UserData/Provisioning Profiles/fbbc4fe8-8738-432c-a647-484dee3f95bb.provisionprofile", + "Library/SystemExtensions/net.defguard.VPNExtension.systemextension": "../swift/extension/build/Installer/VPNSystemExtension.systemextension" + } + }, + "targets": ["dmg"] + } +} diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index 8e0c32086..ef71faf7b 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -1,11 +1,9 @@ { "productName": "defguard-client", - "build": { - "features": [ - "service" - ] - }, "bundle": { - "longDescription": "IMPORTANT: Reboot or Re-login Required\nOn initial install the user is added to the defguard group.\nA reboot or logging out and back in is required for group membership changes to take effect.\nThis is not required on subsequent updates." + "longDescription": "IMPORTANT: Reboot or Re-login Required\nOn initial install the user is added to the defguard group.\nA reboot or logging out and back in is required for group membership changes to take effect.\nThis is not required on subsequent updates.", + "targets": [ + "deb" + ] } } diff --git a/src-tauri/tauri.local.conf.json b/src-tauri/tauri.local.conf.json new file mode 100644 index 000000000..b817d4607 --- /dev/null +++ b/src-tauri/tauri.local.conf.json @@ -0,0 +1,13 @@ +{ + "build": { + "beforeBundleCommand": null + }, + "bundle": { + "targets": ["msi"], + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": null, + "timestampUrl": null + } + } +} diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 39e50dd04..0b4888aa2 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -4,5 +4,18 @@ "cwd": "../swift", "script": "./build.sh" } + }, + "bundle": { + "icon": [ + "icons/macos/32x32.png", + "icons/macos/128x128.png", + "icons/macos/128x128@2x.png", + "icons/macos/icon.icns", + "icons/macos/icon.ico" + ], + "macOS": { + "bundleVersion": "@BUILD_NUMBER@", + "minimumSystemVersion": "13.5" + } } } diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index eb86e8c78..3865b5ce5 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -1,17 +1,18 @@ { "build": { - "features": [ - "service" - ] + "beforeBundleCommand": { + "cwd": ".", + "script": "powershell -ExecutionPolicy Bypass -File ./resources-windows/scripts/Sign-Binaries.ps1" + } }, "bundle": { - "targets": [ - "msi" - ], "resources": [ "resources-windows/binaries/*", "resources-windows/scripts/*", - "resources/icons/*" + "resources/icons/tray/*" + ], + "targets": [ + "msi" ] } } diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx deleted file mode 100644 index 8b70f22c8..000000000 --- a/src/components/App/App.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import 'dayjs/locale/en'; -import '../../shared/defguard-ui/scss/index.scss'; -import '../../shared/scss/index.scss'; - -import { QueryClient } from '@tanstack/query-core'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { getVersion } from '@tauri-apps/api/app'; -import { debug, info } from '@tauri-apps/plugin-log'; -import { openUrl } from '@tauri-apps/plugin-opener'; -import { exit } from '@tauri-apps/plugin-process'; -import dayjs from 'dayjs'; -import customParseData from 'dayjs/plugin/customParseFormat'; -import duration from 'dayjs/plugin/duration'; -import localeData from 'dayjs/plugin/localeData'; -import relativeTime from 'dayjs/plugin/relativeTime'; -import timezone from 'dayjs/plugin/timezone'; -import updateLocale from 'dayjs/plugin/updateLocale'; -import utc from 'dayjs/plugin/utc'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; -import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'; -import { localStorageDetector } from 'typesafe-i18n/detectors'; -import TypesafeI18n from '../../i18n/i18n-react'; -import { detectLocale } from '../../i18n/i18n-util'; -import { loadLocaleAsync } from '../../i18n/i18n-util.async'; -import { ClientPage } from '../../pages/client/ClientPage'; -import { clientApi } from '../../pages/client/clientAPI/clientApi'; -import type { PlatformInfo } from '../../pages/client/clientAPI/types'; -import { useClientStore } from '../../pages/client/hooks/useClientStore'; -import { CarouselPage } from '../../pages/client/pages/CarouselPage/CarouselPage'; -import { ClientAddedPage } from '../../pages/client/pages/ClientAddedPage/ClientAddedPage'; -import { ClientAddInstancePage } from '../../pages/client/pages/ClientAddInstancePage/ClientAddInstnacePage'; -import { ClientAddTunnelPage } from '../../pages/client/pages/ClientAddTunnelPage/ClientAddTunnelPage'; -import { ClientEditTunnelPage } from '../../pages/client/pages/ClientEditTunnelPage/ClientEditTunnelPage'; -import { ClientInstancePage } from '../../pages/client/pages/ClientInstancePage/ClientInstancePage'; -import { ClientSettingsPage } from '../../pages/client/pages/ClientSettingsPage/ClientSettingsPage'; -import { ClientConnectionType } from '../../pages/client/types'; -import { EnrollmentPage } from '../../pages/enrollment/EnrollmentPage'; -import { SessionTimeoutPage } from '../../pages/sessionTimeout/SessionTimeoutPage'; -import { ToastManager } from '../../shared/defguard-ui/components/Layout/ToastManager/ToastManager'; -import { useTheme } from '../../shared/defguard-ui/hooks/theme/useTheme'; -import { ThemeProvider } from '../../shared/providers/ThemeProvider/ThemeProvider'; -import { routes } from '../../shared/routes'; -import { ApplicationUpdateManager } from '../ApplicationUpdateManager/ApplicationUpdateManager'; - -dayjs.extend(duration); -dayjs.extend(utc); -dayjs.extend(customParseData); -dayjs.extend(relativeTime); -dayjs.extend(localeData); -dayjs.extend(updateLocale); -dayjs.extend(timezone); - -const queryClient = new QueryClient(); - -const { getAppConfig, getInstances, getTunnels } = clientApi; - -const router = createBrowserRouter([ - { - index: true, - element: , - }, - { - path: '/timeout', - element: , - }, - { - path: '/enrollment', - element: , - }, - { - path: '/client', - element: , - children: [ - { - path: '/client/', - index: true, - element: , - }, - { - path: '/client/instance', - element: , - }, - { - path: '/client/carousel', - element: , - }, - { - path: '/client/add-instance', - element: , - }, - { - path: '/client/instance-created', - element: , - }, - { - path: '/client/add-tunnel', - element: , - }, - { - path: '/client/tunnel-created', - element: , - }, - { - path: '/client/edit-tunnel', - element: , - }, - { - path: '/client/settings', - element: , - }, - { - path: '/client/*', - element: , - }, - ], - }, - { - path: '/*', - element: , - }, -]); - -const detectedLocale = detectLocale(localStorageDetector); - -export const App = () => { - // Workaround: ensure effect once in dev mode, thanks react :3 - const tauriInitLoadRef = useRef(false); - const localeLoadRef = useRef(false); - const [localeLoaded, setWasLoaded] = useState(false); - const [settingsLoaded, setSettingsLoaded] = useState(false); - const [platformInfoLoaded, setPlatformInfoLoaded] = useState(false); - const setClientState = useClientStore((state) => state.setState); - const { changeTheme } = useTheme(); - - const appLoaded = useMemo( - () => localeLoaded && settingsLoaded && platformInfoLoaded, - [localeLoaded, settingsLoaded, platformInfoLoaded], - ); - - // load locales - useEffect(() => { - if (!localeLoadRef.current) { - localeLoadRef.current = true; - debug('Loading locales'); - loadLocaleAsync(detectedLocale).then(() => { - setWasLoaded(true); - debug(`Locale ${detectedLocale} loaded.`); - }); - dayjs.locale(detectedLocale); - } - }, []); - - // Load settings from Tauri for the first time. - // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater - useEffect(() => { - if (!tauriInitLoadRef.current) { - tauriInitLoadRef.current = true; - const loadTauriState = async () => { - debug('App init state from Tauri'); - const appConfig = await getAppConfig(); - const instances = await getInstances(); - const tunnels = await getTunnels(); - changeTheme(appConfig.theme); - setClientState({ appConfig, instances, tunnels }); - debug('Tauri init data loaded'); - setSettingsLoaded(true); - }; - loadTauriState(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const handler = (e: MouseEvent) => { - const target = e.target as HTMLElement | undefined; - if (target) { - const link = target.closest('a'); - if ( - link instanceof HTMLAnchorElement && - link.target === '_blank' && - link.href.startsWith('https') - ) { - void openUrl(link.href); - } - } - }; - document.addEventListener('click', handler); - return () => { - document.removeEventListener('click', handler); - }; - }, []); - - // register ctrl+q keyboard shortcut - useHotkeys('ctrl+q', () => { - info('Ctrl-Q pressed, exiting.'); - exit(0); - }); - - useEffect(() => { - const loadPlatformInfo = async () => { - debug('Loading platform info from Tauri'); - const version = await getVersion().catch(() => 'unknown'); - const platformHeader = await clientApi.getPlatformHeader(); - const platformInfo: PlatformInfo = { - client_version: `${version}`, - platform_info: platformHeader, - }; - setClientState({ platformInfo }); - debug('Platform info loaded from Tauri'); - setPlatformInfoLoaded(true); - }; - void loadPlatformInfo(); - }, [setClientState]); - - if (!appLoaded) return null; - - return ( - - - - - - - - - - ); -}; diff --git a/src/components/ApplicationUpdateManager/ApplicationUpdateManager.tsx b/src/components/ApplicationUpdateManager/ApplicationUpdateManager.tsx deleted file mode 100644 index f4d9f787f..000000000 --- a/src/components/ApplicationUpdateManager/ApplicationUpdateManager.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { getVersion } from '@tauri-apps/api/app'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { error } from '@tauri-apps/plugin-log'; -import { useEffect, useState } from 'react'; -import { clientApi } from '../../pages/client/clientAPI/clientApi.ts'; -import { useClientStore } from '../../pages/client/hooks/useClientStore'; -import { TauriEventKey } from '../../pages/client/types'; -import type { NewApplicationVersionInfo } from '../../shared/hooks/api/types'; -import { errorDetail } from '../../shared/utils/errorDetail'; -import { - type ApplicationUpdateStore, - useApplicationUpdateStore, -} from './useApplicationUpdateStore'; - -const { getLatestAppVersion } = clientApi; - -export const ApplicationUpdateManager = () => { - const [appVersion, setAppVersion] = useState(undefined); - - const setApplicationUpdateData = useApplicationUpdateStore((state) => state.setValues); - const checkForUpdates = useClientStore((state) => state.appConfig.check_for_updates); - - // Get current application version. - useEffect(() => { - const getAppVersion = async () => { - const version = await getVersion().catch(() => { - return ''; - }); - setAppVersion(version); - }; - - getAppVersion(); - }, []); - - // Listen to new application release info. - useEffect(() => { - const subs: UnlistenFn[] = []; - - // Stop listening if "check for updates" setting has been turned off. - if (!checkForUpdates) { - subs.forEach((sub) => { - sub(); - }); - return; - } - - listen(TauriEventKey.APP_VERSION_FETCH, (data) => { - const payload = data.payload as NewApplicationVersionInfo; - const state = { - latestVersion: payload.version, - releaseDate: payload.release_date, - releaseNotesUrl: payload.release_notes_url, - updateUrl: payload.update_url, - dismissed: false, - } as ApplicationUpdateStore; - setApplicationUpdateData(state); - }).then((cleanup) => { - subs.push(cleanup); - }); - - return () => { - subs.forEach((sub) => { - sub(); - }); - }; - }, [checkForUpdates, setApplicationUpdateData]); - - // Check for updates on launch and when "check for updates" setting has been turned on. - useEffect(() => { - if (!checkForUpdates || !appVersion) return; - - const getNewVersion = async (appVersion: string) => { - if (!appVersion) return; - - try { - const response = await getLatestAppVersion(); - - setApplicationUpdateData({ - currentVersion: appVersion, - latestVersion: response.version, - releaseDate: response.release_date, - releaseNotesUrl: response.release_notes_url, - updateUrl: response.update_url, - dismissed: false, - }); - } catch (e) { - const detail = errorDetail(e); - error(`Failed to check latest app version (current: ${appVersion}): ${detail}`); - } - }; - - getNewVersion(appVersion); - }, [checkForUpdates, appVersion, setApplicationUpdateData]); - - return null; -}; diff --git a/src/components/ApplicationUpdateManager/useApplicationUpdateStore.tsx b/src/components/ApplicationUpdateManager/useApplicationUpdateStore.tsx deleted file mode 100644 index 4adbd30a4..000000000 --- a/src/components/ApplicationUpdateManager/useApplicationUpdateStore.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { createWithEqualityFn } from 'zustand/traditional'; - -export interface ApplicationUpdateStore { - currentVersion: string | undefined; - latestVersion: string | undefined; - releaseDate: string | undefined; - releaseNotesUrl: string | undefined; - updateUrl: string | undefined; - dismissed: boolean; - setValues: (values: Partial) => void; -} - -const defaultState = { - currentVersion: undefined, - latestVersion: undefined, - releaseDate: undefined, - releaseNotesUrl: undefined, - updateUrl: undefined, - dismissed: false, -} as ApplicationUpdateStore; - -export const useApplicationUpdateStore = createWithEqualityFn( - (set) => ({ - ...defaultState, - setValues: (values: Partial) => set({ ...values }), - }), - Object.is, -); diff --git a/src/components/ApplicationUpdateManager/useNewAppVersionAvailable.tsx b/src/components/ApplicationUpdateManager/useNewAppVersionAvailable.tsx deleted file mode 100644 index 747649144..000000000 --- a/src/components/ApplicationUpdateManager/useNewAppVersionAvailable.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { compareVersions } from 'compare-versions'; - -import { useApplicationUpdateStore } from './useApplicationUpdateStore'; - -export const useNewAppVersionAvailable = () => { - const newAppVersionAvailable = useApplicationUpdateStore((state) => { - if (!state.currentVersion || !state.latestVersion) return false; - - return compareVersions(state.latestVersion, state.currentVersion) === 1; - }); - - return { - newAppVersionAvailable, - }; -}; diff --git a/src/components/AutoProvisioningManager.tsx b/src/components/AutoProvisioningManager.tsx deleted file mode 100644 index aed98ba54..000000000 --- a/src/components/AutoProvisioningManager.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { error } from '@tauri-apps/plugin-log'; -import { type PropsWithChildren, useEffect } from 'react'; -import { clientApi } from '../pages/client/clientAPI/clientApi'; -import type { ProvisioningConfig } from '../pages/client/clientAPI/types'; -import { clientQueryKeys } from '../pages/client/query'; -import { useToaster } from '../shared/defguard-ui/hooks/toasts/useToaster'; -import useAddInstance from '../shared/hooks/useAddInstance'; -import { errorDetail } from '../shared/utils/errorDetail'; - -const { getProvisioningConfig } = clientApi; - -export default function AutoProvisioningManager({ children }: PropsWithChildren) { - const toaster = useToaster(); - const { handleAddInstance } = useAddInstance(); - const { data: provisioningConfig } = useQuery({ - queryFn: getProvisioningConfig, - queryKey: [clientQueryKeys.getProvisioningConfig], - refetchOnMount: false, - refetchOnWindowFocus: false, - }); - - const handleProvisioning = async (config: ProvisioningConfig) => { - try { - await handleAddInstance({ - url: config.enrollment_url, - token: config.enrollment_token, - }); - } catch (e) { - const detail = errorDetail(e); - error( - `Failed to handle automatic client provisioning (url: ${config.enrollment_url}): ${detail}`, - ); - toaster.error( - 'Automatic client provisioning failed, please contact your administrator.', - ); - } - }; - - // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater - useEffect(() => { - if (provisioningConfig) { - handleProvisioning(provisioningConfig); - } - }, [provisioningConfig]); - - return <>{children}; -} diff --git a/src/components/LogoContainer/LogoContainer.tsx b/src/components/LogoContainer/LogoContainer.tsx deleted file mode 100644 index 21e78e4b1..000000000 --- a/src/components/LogoContainer/LogoContainer.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import './style.scss'; - -import SvgDefguardLogoText from '../../shared/components/svg/DefguardLogoText'; -import SvgTeoniteLogo from '../../shared/components/svg/TeoniteLogo'; -import { Divider } from '../../shared/defguard-ui/components/Layout/Divider/Divider'; -import { DividerDirection } from '../../shared/defguard-ui/components/Layout/Divider/types'; - -export const LogoContainer = () => { - return ( -
- - - -
- ); -}; diff --git a/src/components/LogoContainer/style.scss b/src/components/LogoContainer/style.scss deleted file mode 100644 index 946b702ac..000000000 --- a/src/components/LogoContainer/style.scss +++ /dev/null @@ -1,25 +0,0 @@ -.logo-container { - display: flex; - flex-flow: row; - column-gap: 40px; - align-items: center; - justify-content: center; - height: 48px; - width: 100%; - - & > .divider { - height: 100%; - } - - & > .defguard { - path { - fill: var(--text-body-primary); - } - } - - & > .teonite { - path { - fill: var(--surface-teonite-logo); - } - } -} diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts deleted file mode 100644 index aa0ade37a..000000000 --- a/src/i18n/en/index.ts +++ /dev/null @@ -1,790 +0,0 @@ -/* eslint-disable no-irregular-whitespace */ -/* eslint-disable max-len */ -import type { BaseTranslation } from '../i18n-types'; - -const en = { - time: { - seconds: { - singular: 'second', - plural: 'seconds', - }, - minutes: { - singular: 'minute', - plural: 'minutes', - }, - }, - form: { - errors: { - invalid: 'Field is invalid', - email: 'Enter a valid E-mail', - required: 'Field is required', - minValue: 'Field requires minimal value of {min: number}', - maxValue: 'Field cannot exceed maximal value of {max: number}', - aboveZero: 'Field value must be above zero', - minLength: 'Min length of {length: number}', - maxLength: 'Max length of {length: number}', - specialsRequired: 'At least one special character', - specialsForbidden: 'Special characters are forbidden', - numberRequired: 'At least one number required', - password: { - floatingTitle: 'Please correct the following:', - }, - oneLower: 'At least one lower case character', - oneUpper: 'At least one upper case character', - duplicatedName: 'Device with this name already exists', - }, - }, - common: { - controls: { - back: 'Back', - next: 'Next', - submit: 'Submit', - cancel: 'Cancel', - close: 'Close', - reset: 'Reset', - save: 'Save', - }, - messages: { - error: 'Unexpected error occurred!', - errorWithMessage: 'An error occurred: {message}', - tokenExpired: - 'Token has expired, please contact your administrator to issue a new enrollment token', - networkError: "There was a network error. Can't reach proxy.", - configChanged: - 'Configuration for instance {instance: string} has changed. Disconnect from all locations to apply changes.', - deadConDropped: - 'Detected that the {con_type: string} {interface_name: string} has disconnected, trying to reconnect...', - noCookie: 'No defguard_proxy set-cookie received', - insecureContext: 'Context is not secure.', - clipboard: { - error: 'Clipboard is not accessible.', - success: 'Content copied to clipboard.', - }, - versionMismatch: - 'Your Defguard instance "{instance_name: string}" version is not supported by your Defguard Client version. \ - Defguard Core version: {core_version: string} (required: {core_required_version: string}), Defguard Proxy version: {proxy_version: string} (required: {proxy_required_version: string}). \ - Please contact your administrator.', - uuidMismatch: - 'The identifier (UUID) of the remote Defguard instance "{instance_name: string}" does not match the one stored locally. \ - Because of this, some features may not work correctly. To resolve this issue, remove the instance and add it again, or contact your administrator.', - }, - }, - components: { - adminInfo: { - title: 'Your admin', - }, - }, - pages: { - client: { - modals: { - deadConDropped: { - title: '{conType: string} {name: string} disconnected', - tunnel: 'Tunnel', - location: 'Location', - message: - 'The {conType: string} {name: string} has been disconnected, since we have detected that the server is not responding with any traffic for {time: number}s. If this message keeps occurring, please contact your administrator and inform them about this fact.', - controls: { - close: 'Close', - }, - }, - }, - pages: { - carouselPage: { - slides: { - shared: { - //md - isMore: '**defguard** is all the above and more!', - githubButton: 'Visit defguard on', - }, - welcome: { - // md - title: 'Welcome to **defguard** desktop client!', - instance: { - title: 'Add Instance', - subtitle: - 'Establish a connection to defguard instance effortlessly by configuring it with a single token.', - }, - tunnel: { - title: 'Add Tunnel', - subtitle: - 'Utilize it as a WireGuard® Desktop Client with ease. Set up your own tunnel or import a configuration file.', - }, - }, - twoFa: { - // md - title: 'WireGuard **2FA with defguard**', - // md - sideText: `Since WireGuard protocol doesn't support 2FA/MFA - most (if not all) currently available WireGuard clients do not support real Multi-Factor Authentication/2FA - and use 2FA just as authorization to the "application" itself (and not WireGuard tunnel). - -If you would like to secure your WireGuard instance try **defguard** VPN & SSO server (which is also free & open source) to get real 2FA using WireGuard PSK keys and peers configuration by defguard gateway!`, - }, - security: { - // md - title: 'Security and Privacy **done right!**', - // md - sideText: `* Privacy requires controlling your data, thus your user data (Identity, SSO) needs to be on-premise (on your servers) -* Securing your data and applications requires authentication and authorization (SSO) with Multi-Factor Authentication, and for highest security - MFA with Hardware Security Modules -* Accessing your data and applications securely and privately requires data encryption (HTTPS) and a secure tunnel between your device and the Internet to encrypt all traffic (VPN). -* To fully trust your SSO, VPN, it needs to be Open Source`, - }, - instances: { - // md - title: '**Multiple** instance & locations', - // md - sideText: `**defguard** (both server nad this client) support multiple instances (installations) and multiple Locations (VPN tunnels). - -If you are an admin/devops - all your customers (instances) and all their tunnels (locations) can be in one place!`, - }, - support: { - // md - title: '**Support us** on Github', - // md - text: `**defguard** is free and truly Open Source and our team has been working on it for several months. Please consider supporting us by: `, - githubText: `staring us on`, - githubLink: `GitHub`, - spreadWordText: `spreading the word about:`, - defguard: `defguard!`, - githubDiscussions: `Reach out to our community via `, - supportUs: 'Support Us!', - }, - }, - }, - settingsPage: { - title: 'Settings', - tabs: { - global: { - common: { - value_in_seconds: '(seconds)', - }, - peer_alive: { - title: 'Session timeout', - helper: - 'If active connection exceeds given time without making an handshake with the server. The connection will be considered invalid and disconnected automatically.', - }, - mtu: { - title: 'MTU (Maximum Transmission Unit)', - helper: - 'MTU sets the largest packet size sent through the network. Lowering it can improve connection stability in restrictive or unreliable ISP networks. The default value on most systems is 1500. Try lowering it to 1300-1400 if you encounter ISP-related issues. 0 = default.', - }, - tray: { - title: 'System tray', - label: 'Tray icon theme', - options: { - color: 'Color', - white: 'White', - black: 'Black', - gray: 'Gray', - }, - }, - logging: { - title: 'Logging threshold', - warning: 'Change will take effect after client restart.', - options: { - error: 'Error', - info: 'Info', - debug: 'Debug', - trace: 'Trace', - }, - }, - globalLogs: { - logSources: { - client: 'Client', - vpn: 'VPN', - all: 'All', - }, - logSourceHelper: - 'The source of the logs. Logs can come from the Defguard client or the VPN service/extension that manages VPN connections at the network level.', - }, - theme: { - title: 'Theme', - options: { - light: 'Light', - dark: 'Dark', - }, - }, - versionUpdate: { - title: 'Updates', - checkboxTitle: 'Check for updates', - }, - }, - }, - }, - createdPage: { - tunnel: { - title: 'Your Tunnel Was Added Successfully', - content: - 'Your tunnel has been successfully added. You can now connect this device, check its status and view statistics using the menu in the left sidebar.', - controls: { - submit: 'Add Another Tunnel', - }, - }, - instance: { - title: 'Your Instance Was Added Successfully', - content: - 'Your instance has been successfully added. You can now connect this device, check its status and view statistics using the menu in the left sidebar.', - controls: { - submit: 'Add Another Instance', - }, - }, - }, - instancePage: { - title: 'Locations', - //md - noData: ` -Currently you do not have access to any VPN Locations. This may be temporary - your administration team maybe is configuring your access policies. - -If this will not change, please contact your administrator.`, - controls: { - connect: 'Connect', - disconnect: 'Disconnect', - traffic: { - predefinedTraffic: 'Predefined traffic', - allTraffic: 'All traffic', - label: 'Allowed traffic', - helper: ` -

- Predefined traffic - route only traffic for networks defined by Admin through this VPN location
- All traffic - route ALL your network traffic through this VPN location -

`, - }, - }, - header: { - title: 'Locations', - edit: 'Edit Instance', - filters: { - views: { - grid: 'Grid View', - detail: 'Detail View', - }, - }, - }, - connectionLabels: { - lastConnectedFrom: 'Last connected from', - lastConnected: 'Last connected', - connectedFrom: 'Connected from', - assignedIp: 'Assigned IP', - active: 'Active', - neverConnected: 'Never connected', - }, - locationNeverConnected: { - title: 'Never Connected', - content: - 'This device was never connected to this location, connect to view statistics and information about connection', - }, - LocationNoStats: { - title: 'No stats', - content: - 'This device has no stats for this location in specified time period. Connect to location and wait for client to gather statistics.', - }, - detailView: { - history: { - title: 'Connection history', - headers: { - date: 'Date', - duration: 'Duration', - connectedFrom: 'Connected from', - upload: 'Upload', - download: 'Download', - }, - }, - details: { - title: 'Details', - logs: { - title: 'Log', - }, - info: { - configuration: { - title: 'Device configuration', - pubkey: 'Public key', - address: 'Addresses', - listenPort: 'Listen port', - }, - vpn: { - title: 'VPN Server Configuration', - pubkey: 'Public key', - serverAddress: 'Server Address', - allowedIps: 'Allowed IPs', - dns: 'DNS servers', - keepalive: 'Persistent keepalive', - handshake: 'Latest Handshake', - handshakeValue: '{seconds: number} seconds ago', - }, - }, - }, - }, - }, - tunnelPage: { - title: 'WireGuard Tunnels', - header: { - edit: 'Edit Tunnel', - }, - }, - - editTunnelPage: { - title: 'Edit WireGuard® Tunnel', - messages: { - editSuccess: 'Tunnel edited', - editError: 'Editing tunnel failed', - }, - controls: { - save: 'Save changes', - }, - }, - addTunnelPage: { - title: 'Add WireGuard® Tunnel', - forms: { - initTunnel: { - title: 'Please provide Instance URL and token', - sections: { - vpnServer: 'VPN Server', - advancedOptions: 'Advanced Options', - }, - labels: { - name: 'Tunnel Name', - privateKey: 'Private Key', - publicKey: 'Public Key', - address: 'Address', - serverPubkey: 'Public Key', - presharedKey: 'Pre-shared Key', - endpoint: 'VPN Server Address:Port', - dns: 'DNS', - allowedips: 'Allowed IPs (separate with comma)', - persistentKeepAlive: 'Persistent Keep Alive (sec)', - preUp: 'PreUp', - postUp: 'PostUp', - PreDown: 'PreDown', - PostDown: 'PostDown', - }, - helpers: { - advancedOptions: - 'Click the "Advanced Options" section to reveal additional settings for fine-tuning your WireGuard tunnel configuration. You can customize pre and post scripts, among other options.', - name: 'A unique name for your WireGuard tunnel to identify it easily.', - pubkey: - 'The public key associated with the WireGuard tunnel for secure communication.', - prvkey: - 'The private key associated with the WireGuard tunnel for secure communication.', - address: - 'The IP address assigned to this WireGuard client within the VPN network.', - serverPubkey: - 'The public key of the WireGuard server for secure communication.', - presharedKey: 'Optional symmetric secret key for enhanced security.', - allowedIps: - 'A comma-separated list of IP addresses or CIDR ranges that are allowed for communication through the tunnel.', - endpoint: - 'The address and port of the WireGuard server, typically in the format "hostname:port".', - dns: 'The DNS (Domain Name System) server that the WireGuard tunnel should use for name resolution. Right now we only support DNS server IP, in the feature we will support domain search.', - persistentKeepAlive: - 'The interval (in seconds) for sending periodic keep-alive messages to ensure the tunnel stays active. Adjust as needed.', - routeAllTraffic: - 'If enabled, all network traffic will be routed through the WireGuard tunnel.', - preUp: - 'Shell commands or scripts to be executed before bringing up the WireGuard tunnel.', - postUp: - 'Shell commands or scripts to be executed after bringing up the WireGuard tunnel.', - preDown: - 'Shell commands or scripts to be executed before tearing down the WireGuard tunnel.', - postDown: - 'Shell commands or scripts to be executed after tearing down the WireGuard tunnel.', - }, - submit: 'Add Tunnel', - messages: { - configError: 'Error parsing config file', - addSuccess: 'Tunnel added', - addError: 'Creating tunnel failed', - }, - controls: { - importConfig: 'Import Config File', - generatePrvkey: 'Generate Private Key', - }, - }, - }, - guide: { - title: 'Adding WireGuard tunnel', - subTitle: `

To establish secure communication between two or more devices over the internet create a virtual private network by configuring your tunnel.

If you don’t see options like Table or MTU it means we do not support it for now, but will be added later.

`, - card: { - title: 'Setting Up A new Tunnel:', - content: ` -

1. Import Configuration File

-
-
    -
  • Click on the "Import Config File" button.
  • -
  • Navigate to configuration file using the file selection dialog.
  • -
  • Select the .conf file you received or created.
  • -
-
-

2. Or Fill in Form on the Left

-
-
    -
  • Enter a name for the tunnel.
  • -
  • Provide essential details such as the private key, public key, and endpoint (server address).
  • -
-
-

- For more help, please visit defguard help (https://docs.defguard.net) -

- `, - }, - }, - }, - addInstancePage: { - title: 'Add Instance', - forms: { - initInstance: { - title: 'Please provide Instance URL and token', - labels: { - url: 'Instance URL', - token: 'Token', - }, - submit: 'Add Instance', - }, - device: { - title: 'Name this device', - labels: { - name: 'Name', - }, - submit: 'Finish', - messages: { - addSuccess: 'Device added', - }, - }, - }, - guide: { - title: 'Adding Instances and connecting to VPN locations', - subTitle: - 'In order to activate this device and access all VPN locations, you must provide the URL to your defguard instance and enter the activation token.', - card: { - title: 'You can obtain the token by', - content: ` -

1. Invoking Remote Desktop activation process yourself

-
-

- If you have access to your defguard instance (either you are at home/office where defguard is accessible), go to defguard -> your profile -> "Add device" and choose: Activate Defguard Client. Then select if you wish to have the token sent to you by email or just copy it from defguard. -

-
-

2. Activating remotely by your administrator

-
-

- If you do not have access to defguard - please contact your administrator (in your onboarding message/email there were the admin contact details) and ask for Remote desktop activation - best to send you the activation email, from which you can copy the instance URL & token. -

-
-

- For more help, please visit defguard help (https://docs.defguard.net) -

- `, - }, - }, - }, - }, - sideBar: { - instances: 'defguard Instances', - addInstance: 'Add Instance', - addTunnel: 'Add Tunnel', - tunnels: 'WireGuard Tunnels', - settings: 'Settings', - copyright: { - copyright: `Copyright © 2023`, - appVersion: 'Application version: {version:string}', - }, - applicationVersion: 'Application version: ', - }, - newApplicationVersion: { - header: 'New version available', - dismiss: 'Dismiss', - releaseNotes: "See what's new", - }, - }, - enrollment: { - sideBar: { - title: 'Enrollment', - steps: { - welcome: 'Welcome', - verification: 'Data verification', - password: 'Create password', - vpn: 'Configure VPN', - finish: 'Finish', - mfa: 'Configure MFA', - mfaChoice: 'Choose method', - mfaSetup: 'Complete method', - mfaRecovery: 'Recovery codes', - }, - appVersion: 'Application version', - }, - stepsIndicator: { - step: 'Step', - of: 'of', - }, - timeLeft: 'Time left', - steps: { - welcome: { - title: 'Hello, {name: string}', - explanation: ` -In order to gain access to the company infrastructure, we require you to complete this enrollment process. During this process, you will need to: - -1. Verify your data -2. Create your password -3. Configure VPN device - -You have a time limit of **{time: string} minutes** to complete this process. -If you have any questions, please consult your assigned admin.All necessary information can be found at the bottom of the sidebar.`, - }, - dataVerification: { - title: 'Data verification', - messageBox: - 'Please, check your data. If anything is wrong, notify your admin after you complete the process.', - form: { - fields: { - firstName: { - label: 'Name', - }, - lastName: { - label: 'Last name', - }, - email: { - label: 'E-mail', - }, - phone: { - label: 'Phone number', - }, - }, - }, - }, - password: { - title: 'Create password', - form: { - fields: { - password: { - label: 'Create new password', - }, - repeat: { - label: 'Confirm new password', - errors: { - matching: `Passwords aren't matching`, - }, - }, - }, - }, - }, - deviceSetup: { - desktopSetup: { - title: 'Configure this device', - controls: { - create: 'Configure device', - success: 'Device is configured', - }, - messages: { - deviceConfigured: 'Device is configured', - }, - }, - optionalMessage: `* This step is OPTIONAL. You can skip it if you wish. This can be configured later in defguard.`, - cards: { - device: { - title: 'Configure your device for VPN', - create: { - submit: 'Create Configuration', - messageBox: - 'Please be advised that you have to download the configuration now, since we do not store your private key. After this dialog is closed, you will not be able to get your full configuration file (with private keys, only blank template).', - form: { - fields: { - name: { - label: 'Device Name', - }, - public: { - label: 'My Public Key', - }, - toggle: { - generate: 'Generate key pair', - own: 'Use my own public key', - }, - }, - }, - }, - config: { - messageBox: { - auto: ` -

- Please be advised that you have to download the configuration now, - since we do not store your private key. After this - dialog is closed, you will not be able to get your - full configuration file (with private keys, only blank template). -

-`, - manual: ` -

- Please be advised that configuration provided here does not include private key and uses public key to fill it's place you will need to replace it on your own for configuration to work properly. -

-`, - }, - deviceNameLabel: 'My Device Name', - cardTitle: - 'Use provided configuration file below by scanning QR Code or importing it as file on your devices WireGuard app.', - card: { - selectLabel: 'Config file for location', - }, - }, - }, - guide: { - title: 'Quick Guide', - messageBox: 'This quick guide will help you with device configuration.', - step: 'Step {step: number}:', - steps: { - wireguard: { - content: - 'Download and install WireGuard client on your computer or app on phone.', - button: 'Download WireGuard', - }, - downloadConfig: 'Download provided configuration file to your device.', - addTunnel: `Open WireGuard and select "Add Tunnel" (Import tunnel(s) from file). Find your -Defguard configuration file and hit "OK". On phone use WireGuard app “+” icon and scan QR code.`, - activate: 'Select your tunnel from the list and press "activate".', - finish: ` -**Great work - your Defguard VPN is now active!** - -If you want to disengage your VPN connection, simply press "deactivate". -`, - }, - }, - }, - }, - finish: { - title: 'Configuration completed!', - }, - }, - }, - sessionTimeout: { - card: { - header: 'Session timed out', - message: - 'Sorry, you have exceeded the time limit to complete the process. Please try again. If you need assistance, please watch our guide or contact your administrator.', - }, - controls: { - back: 'Enter new token', - contact: 'Contact admin', - }, - }, - token: { - card: { - title: 'Please, enter your personal enrollment token', - messageBox: { - email: 'You can find token in e-mail message or use direct link.', - }, - form: { - errors: { - token: { - required: 'Token is required', - }, - }, - fields: { - token: { - placeholder: 'Token', - }, - }, - controls: { - submit: 'Next', - }, - }, - }, - }, - }, - modals: { - updateInstance: { - title: 'Update instance', - infoMessage: - "Enter the token sent by the administrator to update the Instance configuration.\nAlternatively, you can choose to remove this Instance entirely by clicking the 'Remove Instance' button below.", - form: { - fieldLabels: { - token: 'Token', - url: 'URL', - }, - fieldErrors: { - token: { - rejected: 'Token or URL rejected.', - instanceIsNotPresent: 'Instance for this token was not found.', - }, - }, - }, - controls: { - updateInstance: 'Update Instance', - removeInstance: 'Remove Instance', - }, - messages: { - success: '{name: string} updated.', - error: 'Token or URL is invalid.', - errorInstanceNotFound: 'Instance for given token is not registered !', - }, - }, - deleteInstance: { - title: 'Delete instance', - subtitle: 'Are you sure you want to delete {name: string}?', - messages: { - success: 'Instance deleted', - error: 'Unexpected error occurred', - }, - controls: { - submit: 'Delete instance', - }, - }, - deleteTunnel: { - title: 'Delete tunnel', - subtitle: 'Are you sure you want to delete {name: string}?', - messages: { - success: 'Tunnel deleted', - error: 'Unexpected error occurred', - }, - controls: { - submit: 'Delete tunnel', - }, - }, - mfa: { - authentication: { - title: 'Two-factor authentication', - authenticatorAppDescription: - 'Paste the authentication code from your Authenticator Application.', - emailCodeDescription: - 'Paste the authentication code that was sent to your email address.', - mfaStartDescriptionPrimary: - 'For this connection, two-factor authentication (2FA) is mandatory.', - mfaStartDescriptionSecondary: 'Select your preferred authentication method.', - useAuthenticatorApp: 'Use authenticator app', - useEmailCode: 'Use your email code', - saveAuthenticationMethodForFutureLogins: 'Use this method for future logins', - buttonSubmit: 'Verify', - openidLogin: { - description: - 'In order to connect to the VPN please login with {provider}. To do so, please click "Authenticate with {provider}" button below.', - browserWarning: - '**This will open a new window in your Web Browser** and automatically redirect you to the {provider} login page. After authenticating with {provider} please get back here.', - buttonText: 'Authenticate with {provider}', - }, - openidPending: { - description: 'Waiting for authentication in your browser...', - tryAgain: 'Try again', - errorDescription: - 'There was an error during authentication. Use the try again button below to retry the authentication process.', - }, - openidUnavailable: { - description: - 'The OpenID authentication is currently unavailable. This may be due to a configuration issue or the Defguard instance is down. Please contact your administrator or try again later.', - tryAgain: 'Try again', - }, - errors: { - mfaNotConfigured: 'Selected method has not been configured.', - mfaStartGeneric: - 'Could not start MFA process. Please try again or contact administrator.', - mfaFinishGeneric: - 'Could not finish MFA process. Please try again or contact administrator.', - instanceNotFound: 'Could not find instance.', - locationNotSpecified: 'Location is not specified.', - invalidCode: - 'Error, this code is invalid, try again or contact your administrator.', - tokenExpired: 'Token has expired. Please try to connect again.', - authenticationTimeout: - 'Authentication took too long and timed out. Please try connecting again.', - sessionInvalidated: - 'Error: Your login session might have been invalidated or expired. Please try again.', - }, - }, - }, - }, -} satisfies BaseTranslation; - -export default en; diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts deleted file mode 100644 index 51a876c94..000000000 --- a/src/i18n/formatters.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { FormattersInitializer } from 'typesafe-i18n'; - -import type { Formatters, Locales } from './i18n-types'; - -// eslint-disable-next-line -export const initFormatters: FormattersInitializer = () => { - const formatters: Formatters = { - // add your formatter functions here - }; - - return formatters; -}; diff --git a/src/i18n/fr/index.ts b/src/i18n/fr/index.ts deleted file mode 100644 index 51bf3260a..000000000 --- a/src/i18n/fr/index.ts +++ /dev/null @@ -1,749 +0,0 @@ -/* eslint-disable no-irregular-whitespace */ -/* eslint-disable max-len */ -import { deepmerge } from 'deepmerge-ts'; - -import en from '../en'; -import type { BaseTranslation } from '../i18n-types'; - -const translation = { - time: { - seconds: { - singular: 'seconde', - plural: 'secondes', - }, - minutes: { - singular: 'minute', - plural: 'minutes', - }, - }, - form: { - errors: { - invalid: 'Champ invalide', - email: 'Entrez une adresse e-mail valide', - required: 'Champ requis', - minValue: 'Le champ requiert une valeur minimale de {min: number}', - maxValue: 'Le champ ne peut pas dépasser la valeur maximale de {max: number}', - aboveZero: 'La valeur du champ doit être supérieure à zéro', - minLength: 'Longueur minimale de {length: number}', - maxLength: 'Longueur maximale de {length: number}', - specialsRequired: 'Au moins un caractère spécial', - specialsForbidden: 'Les caractères spéciaux sont interdits', - numberRequired: 'Au moins un chiffre requis', - password: { - floatingTitle: 'Veuillez corriger ce qui suit :', - }, - oneLower: 'Au moins une lettre minuscule', - oneUpper: 'Au moins une lettre majuscule', - duplicatedName: 'Un appareil avec ce nom existe déjà', - }, - }, - common: { - controls: { - back: 'Retour', - next: 'Suivant', - submit: 'Soumettre', - cancel: 'Annuler', - close: 'Fermer', - reset: 'Réinitialiser', - save: 'Enregistrer', - }, - messages: { - error: "Une erreur inattendue s'est produite !", - errorWithMessage: "Une erreur s'est produite : {message}", - tokenExpired: - "Le jeton a expiré, veuillez contacter votre administrateur pour émettre un nouveau jeton d'inscription", - networkError: "Il y a eu une erreur réseau. Impossible d'atteindre le proxy.", - configChanged: - "La configuration pour l'instance {instance: string} a changé. Déconnectez-vous de tous les emplacements pour appliquer les modifications.", - deadConDropped: - "L '{conType: string} {name: string} a été déconnecté, tentative de reconnexion...", - noCookie: 'Aucun defguard_proxy set-cookie reçu', - }, - }, - components: { - adminInfo: { - title: 'Votre administrateur', - }, - }, - pages: { - client: { - modals: { - deadConDropped: { - title: '{conType: string} {name: string} déconnecté', - tunnel: 'Tunnel', - location: 'Emplacement', - message: - "L '{conType: string} {name: string} a été déconnecté, car nous avons détecté que le serveur ne répond pas avec du trafic depuis {time: number}s. Si ce message continue d'apparaître, veuillez contacter votre administrateur", - controls: { - close: 'Fermer', - }, - }, - }, - pages: { - carouselPage: { - slides: { - shared: { - //md - isMore: '**defguard** est tout cela et plus encore !', - githubButton: 'Visitez defguard sur', - }, - welcome: { - // md - title: 'Bienvenue sur le client **defguard** !', - instance: { - title: 'Ajouter une instance', - subtitle: - 'Établissez une connexion à une instance defguard sans effort en la configurant avec un seul jeton.', - }, - tunnel: { - title: 'Ajouter un tunnel', - subtitle: - 'Utilisez-le simplement comme un client WireGuard®. Configurez votre propre tunnel ou importez un fichier de configuration.', - }, - }, - twoFa: { - // md - title: 'WireGuard **2FA avec defguard**', - // md - sideText: `Le protocole WireGuard ne supporte pas le 2FA/MFA - la plupart (sinon tous) des clients WireGuard actuellement disponibles ne supportent pas l'authentification multi-facteurs/2FA réelle - et utilisent le 2FA uniquement comme autorisation à l'"application" elle-même (et non au tunnel WireGuard). - -Si vous souhaitez sécuriser votre instance WireGuard, essayez le serveur **defguard** VPN & SSO (qui est également gratuit et open source) pour obtenir un véritable 2FA en utilisant les clés PSK WireGuard et la configuration des pairs par la passerelle defguard !`, - }, - security: { - // md - title: "Une sécurité et une confidentialité dans **les règles de l'art**", - // md - sideText: `* La confidentialité nécessite le contrôle de vos données (Identité, SSO), elles doivent donc être hebergées sur vos serveurs. -* La sécurisation de vos données et applications nécessite une authentification et une autorisation (SSO) avec une authentification multi-facteurs. Pour une sécurité plus evoluée - du MFA avec un module de sécurité matériel. -* L'accés sécurisé et confidentielle à vos données et applications nécessite un chiffrement des données (HTTPS) et un tunnel sécurisé, entre votre appareil et Internet, pour bénéficier d'un chiffrement de bout en bout du trafic (VPN). -* Pour faire entièrement confiance à vos solutions SSO & VPN, elle doivent être open source`, - }, - instances: { - // md - title: '**Multiples** instances et emplacements', - // md - sideText: `**defguard** (le serveur et ce client) supporte plusieurs instances (installations) et plusieurs emplacements (tunnels VPN). - -Si vous êtes un administrateur/devops - tous vos clients (instances) et tous leurs tunnels (emplacements) peuvent être au même endroit !`, - }, - support: { - // md - title: '**Soutenez-nous** sur Github', - // md - text: `**defguard** est gratuit et véritablement open source. Notre équipe travaille sur ce projet depuis plusieurs mois. N'ésitez pas à nous soutenir : `, - githubText: `En nous mettant des étoiles sur`, - githubLink: `GitHub`, - spreadWordText: `En faisant passer le mot sur :`, - defguard: `defguard !`, - githubDiscussions: `Contactez notre communauté via `, - supportUs: 'Soutenez-nous !', - }, - }, - }, - settingsPage: { - title: 'Paramètres', - tabs: { - global: { - common: { - value_in_seconds: '(secondes)', - }, - peer_alive: { - title: 'Délai de session', - helper: - 'Si une connexion active dépasse le temps donné sans effectuer de handshake avec le serveur. La connexion sera considérée comme invalide et déconnectée automatiquement.', - }, - tray: { - title: "Barre d'état", - label: "Thème de l'icône de la barre d'état", - options: { - color: 'Couleur', - white: 'Blanc', - black: 'Noir', - gray: 'Gris', - }, - }, - logging: { - title: 'Niveau de journalisation', - warning: 'Le changement prendra effet après le redémarrage du client.', - options: { - error: 'Erreur', - info: 'Info', - debug: 'Débogage', - trace: 'Trace', - }, - }, - globalLogs: { - logSources: { - client: 'Client', - vpn: 'VPN', - all: 'Tous', - }, - logSourceHelper: - 'La source des journaux. Les journaux peuvent provenir du client Defguard ou du service/extension VPN qui gère les connexions VPN au niveau du réseau.', - }, - theme: { - title: 'Thème', - options: { - light: 'Clair', - dark: 'Sombre', - }, - }, - versionUpdate: { - title: 'Mises à jour', - checkboxTitle: 'Vérifier les mises à jour', - }, - }, - }, - }, - createdPage: { - tunnel: { - title: 'Votre tunnel a été ajouté avec succès', - content: - 'Votre tunnel a été ajouté avec succès. Vous pouvez maintenant connecter cet appareil. Vérifiez son état et les statistiques en utilisant le menu dans la barre latérale de gauche.', - controls: { - submit: 'Ajouter un autre tunnel', - }, - }, - instance: { - title: 'Votre instance a été ajoutée avec succès', - content: - 'Votre instance a été ajoutée avec succès. Vous pouvez maintenant connecter cet appareil. Vérifier son état et les statistiques en utilisant le menu dans la barre latérale de gauche.', - controls: { - submit: 'Ajouter une autre instance', - }, - }, - }, - instancePage: { - title: 'Emplacements', - controls: { - connect: 'Connecter', - disconnect: 'Déconnecter', - traffic: { - predefinedTraffic: 'Trafic prédéfini', - allTraffic: 'Tout le trafic', - label: 'Trafic autorisé', - helper: ` -

- Trafic prédéfini - router uniquement le trafic pour les réseaux définis par l'administrateur via cet emplacement VPN
- Tout le trafic - router L'INTEGRALITE de votre trafic réseau via cet emplacement VPN -

`, - }, - }, - header: { - title: 'Emplacements', - edit: "Modifier l'instance", - filters: { - views: { - grid: 'Vue en grille', - detail: 'Vue détaillée', - }, - }, - }, - connectionLabels: { - lastConnectedFrom: 'Dernière connexion depuis', - lastConnected: 'Dernière connexion', - connectedFrom: 'Connecté depuis', - assignedIp: 'IP attribuée', - active: 'Actif', - neverConnected: 'Jamais connecté', - }, - locationNeverConnected: { - title: 'Jamais connecté', - content: - "Cet appareil n'a jamais été connecté à cet emplacement, connectez-vous pour voir les statistiques et les informations sur la connexion", - }, - LocationNoStats: { - title: 'Aucune statistique', - content: - "Cet appareil n'a aucune statistique pour cet emplacement dans la période de temps spécifiée. Connectez-vous à l'emplacement et attendez que le client rassemble les statistiques.", - }, - detailView: { - history: { - title: 'Historique des connexions', - headers: { - date: 'Date', - duration: 'Durée', - connectedFrom: 'Connecté depuis', - upload: 'Téléversement', - download: 'Téléchargement', - }, - }, - details: { - title: 'Détails', - logs: { - title: 'Journal', - }, - info: { - configuration: { - title: "Configuration de l'appareil", - pubkey: 'Clé publique', - address: 'Adresses', - listenPort: "Port d'écoute", - }, - vpn: { - title: 'Configuration du serveur VPN', - pubkey: 'Clé publique', - serverAddress: 'Adresse du serveur', - allowedIps: 'IP autorisées', - dns: 'Serveurs DNS', - keepalive: 'Keepalive', - handshake: 'Handshake', - handshakeValue: '{seconds: number} secondes', - }, - }, - }, - }, - }, - tunnelPage: { - title: 'Tunnels WireGuard', - header: { - edit: 'Modifier le tunnel', - }, - }, - - editTunnelPage: { - title: 'Modifier le tunnel WireGuard®', - messages: { - editSuccess: 'Tunnel modifié', - editError: 'Échec de la modification du tunnel', - }, - controls: { - save: 'Enregistrer les modifications', - }, - }, - addTunnelPage: { - title: 'Ajouter un tunnel WireGuard®', - forms: { - initTunnel: { - title: "Veuillez fournir l'URL de l'instance et le jeton", - sections: { - vpnServer: 'Serveur VPN', - advancedOptions: 'Options avancées', - }, - labels: { - name: 'Nom du tunnel', - privateKey: 'Clé privée', - publicKey: 'Clé publique', - address: 'Adresse', - serverPubkey: 'Clé publique', - presharedKey: 'Clé pré-partagée', - endpoint: 'Adresse:Port du serveur VPN', - dns: 'DNS', - allowedips: 'IP autorisées (séparées par une virgule)', - persistentKeepAlive: 'Keep Alive persistant (sec)', - preUp: 'PreUp', - postUp: 'PostUp', - PreDown: 'PreDown', - PostDown: 'PostDown', - }, - helpers: { - advancedOptions: `Cliquez sur la section "Options avancées" pour afficher les paramètres supplémentaires permettant d'affiner la configuration de votre tunnel WireGuard. Parmi les options disponibles, vous pouvez personnaliser les scripts de pré-exécution/post-exécution`, - name: "Un nom unique pour votre tunnel WireGuard afin de l'identifier facilement.", - pubkey: - 'La clé publique associée au tunnel WireGuard pour une communication sécurisée.', - prvkey: - 'La clé privée associée au tunnel WireGuard pour une communication sécurisée.', - address: - "L'adresse IP attribuée à ce client WireGuard au sein du réseau VPN.", - serverPubkey: - 'La clé publique du serveur WireGuard pour une communication sécurisée.', - presharedKey: - 'Clé secrète symétrique optionnelle pour une sécurité renforcée.', - allowedIps: - "Une liste d'adresses IP ou de plages CIDR séparées par des virgules qui sont autorisées pour la communication via le tunnel.", - endpoint: `L'adresse et le port du serveur WireGuard, généralement au format "nom_hôte:port".`, - dns: "Le serveur DNS (Domain Name System) que le tunnel WireGuard doit utiliser pour la résolution de noms. Actuellement, nous ne supportons que l'adresse IP du serveur DNS, mais nous supporterons la recherche de domaine à l'avenir.", - persistentKeepAlive: - "L'intervalle (en secondes) pour envoyer des messages keep-alive périodiques afin de maintenir le tunnel actif. Ajustez selon les besoins.", - routeAllTraffic: - 'Si activé, tout le trafic réseau sera routé via le tunnel WireGuard.', - preUp: - 'Commandes shell ou scripts à exécuter avant de monter le tunnel WireGuard.', - postUp: - 'Commandes shell ou scripts à exécuter après avoir monté le tunnel WireGuard.', - preDown: - 'Commandes shell ou scripts à exécuter avant de démonter le tunnel WireGuard.', - postDown: - 'Commandes shell ou scripts à exécuter après avoir démonté le tunnel WireGuard.', - }, - submit: 'Ajouter un tunnel', - messages: { - configError: "Erreur lors de l'analyse du fichier de configuration", - addSuccess: 'Tunnel ajouté', - addError: 'Échec de la création du tunnel', - }, - controls: { - importConfig: 'Importer un fichier de configuration', - generatePrvkey: 'Générer une clé privée', - }, - }, - }, - guide: { - title: "Ajout d'un tunnel WireGuard", - subTitle: `

Pour établir une communication sécurisée entre deux appareils ou plus sur Internet, créez un réseau privé virtuel en configurant votre tunnel.

Si vous ne voyez pas d'options comme Table ou MTU, cela signifie que nous ne les supportons pas pour le moment, mais elles seront ajoutées plus tard.

`, - card: { - title: "Configuration d'un nouveau tunnel :", - content: ` -

1. Importer un fichier de configuration

-
-
    -
  • Cliquez sur le bouton "Importer un fichier de configuration".
  • -
  • Accédez au fichier de configuration en utilisant la boîte de dialogue de sélection de fichier.
  • -
  • Sélectionnez le fichier .conf que vous avez reçu ou créé.
  • -
-
-

2. Ou remplissez le formulaire à gauche

-
-
    -
  • Entrez un nom pour le tunnel.
  • -
  • Fournissez les détails nécessaires tels que la clé privée, la clé publique et l'endpoint (adresse du serveur).
  • -
-
-

- Pour plus d'aide, veuillez visiter l'aide defguard (https://docs.defguard.net) -

- `, - }, - }, - }, - addInstancePage: { - title: 'Ajouter une instance', - forms: { - initInstance: { - title: "Veuillez fournir l'URL de l'instance et le jeton", - labels: { - url: "URL de l'instance", - token: 'Jeton', - }, - submit: 'Ajouter une instance', - }, - device: { - title: 'Nommez cet appareil', - labels: { - name: 'Nom', - }, - submit: 'Terminer', - messages: { - addSuccess: 'Appareil ajouté', - }, - }, - }, - guide: { - title: "Ajout d'instances et connexion aux emplacements VPN", - subTitle: - "Afin d'activer cet appareil et d'accéder à tous les emplacements VPN, vous devez fournir l'URL de votre instance defguard et entrer le jeton d'activation.", - card: { - title: 'Vous pouvez obtenir le jeton en', - content: ` -

1. Invoquant le processus d'activation du Bureau à distance vous-même

-
-

- Si vous avez accès à votre instance defguard (depuis chez vous ou au bureau), allez sur defguard -> votre profil -> "Ajouter un appareil" et choisissez : Activer le client Defguard. Ensuite, choisissez si vous souhaitez que le jeton vous soit envoyé par e-mail ou simplement le copier depuis defguard. -

-
-

2. L'activant à distance par votre administrateur

-
-

- Si vous n'avez pas accès à defguard - veuillez contacter votre administrateur (dans votre message/e-mail d'intégration se trouvent les coordonnées de l'administrateur) et demandez l'activation du bureau à distance - le mieux est de vous envoyer l'e-mail d'activation, à partir duquel vous pourrez copier l'URL de l'instance et le jeton. -

-
-

- Pour plus d'aide, veuillez visiter l'aide defguard (https://docs.defguard.net) -

- `, - }, - }, - }, - }, - sideBar: { - instances: 'Instances defguard', - addInstance: 'Ajouter une instance', - addTunnel: 'Ajouter un tunnel', - tunnels: 'Tunnels WireGuard', - settings: 'Paramètres', - copyright: { - copyright: `Copyright © 2023`, - appVersion: "Version de l'application : {version:string}", - }, - applicationVersion: "Version de l'application : ", - }, - newApplicationVersion: { - header: 'Nouvelle version disponible', - dismiss: 'Ignorer', - releaseNotes: 'Voir les nouveautés', - }, - }, - enrollment: { - sideBar: { - title: 'Inscription', - steps: { - welcome: 'Bienvenue', - verification: 'Vérification des données', - password: 'Créer un mot de passe', - vpn: 'Configurer le VPN', - finish: 'Terminer', - }, - appVersion: "Version de l'application", - }, - stepsIndicator: { - step: 'Étape', - of: 'sur', - }, - timeLeft: 'Temps restant', - steps: { - welcome: { - title: 'Bonjour, {name: string}', - explanation: ` -Afin d'accéder à l'infrastructure de l'entreprise, vous devez compléter ce formulaire d'inscription. Au cours de ce processus, vous devrez : - -1. Vérifier vos données -2. Créer votre mot de passe -3. Configurer l'appareil VPN - -Vous avez un délai de **{time: string} minutes** pour le compléter. -Si vous avez des questions, veuillez consulter votre administrateur. Toutes les informations nécessaires se trouvent en bas de la barre latérale.`, - }, - dataVerification: { - title: 'Vérification des données', - messageBox: - 'Veuillez vérifier vos données. Si quelque chose ne va pas, informez votre administrateur après avoir terminé.', - form: { - fields: { - firstName: { - label: 'Prénom', - }, - lastName: { - label: 'Nom de famille', - }, - email: { - label: 'E-mail', - }, - phone: { - label: 'Numéro de téléphone', - }, - }, - }, - }, - password: { - title: 'Créer un mot de passe', - form: { - fields: { - password: { - label: 'Créer un nouveau mot de passe', - }, - repeat: { - label: 'Confirmer le nouveau mot de passe', - errors: { - matching: `Les mots de passe ne correspondent pas`, - }, - }, - }, - }, - }, - deviceSetup: { - desktopSetup: { - title: 'Configurer cet appareil', - controls: { - create: "Configurer l'appareil", - success: "L'appareil est configuré", - }, - messages: { - deviceConfigured: "L'appareil est configuré", - }, - }, - optionalMessage: `* Cette étape est OPTIONNELLE. Vous pouvez l'ignorer si vous le souhaitez. Cela peut être configuré plus tard.`, - cards: { - device: { - title: 'Configurer votre appareil pour le VPN', - create: { - submit: 'Créer la configuration', - messageBox: - 'Veuillez noter que vous devez télécharger la configuration, car nous ne stockons pas votre clé privée. Après la fermeture de cette boîte de dialogue, vous ne pourrez plus obtenir votre fichier de configuration complet (avec les clés privées, uniquement un modèle vierge).', - form: { - fields: { - name: { - label: "Nom de l'appareil", - }, - public: { - label: 'Ma clé publique', - }, - toggle: { - generate: 'Générer une paire de clés', - own: 'Utiliser ma propre clé publique', - }, - }, - }, - }, - config: { - messageBox: { - auto: ` -

- Veuillez noter que vous devez télécharger la configuration, - car nous ne stockons pas votre clé privée. Après la fermeture de - cette boîte de dialogue, vous ne pourrez plus obtenir - votre fichier de configuration complet (avec les clés privées, uniquement un modèle vierge). -

-`, - manual: ` -

- Veuillez noter que la configuration fournie ici inclut la clé publique en lieu et place de la clé privée. Vous devrez la remplacer pour que la configuration fonctionne correctement. -

-`, - }, - deviceNameLabel: 'Nom de mon appareil', - cardTitle: - "Utilisez le fichier de configuration fourni ci-dessous en scannant le code QR ou en l'important comme fichier sur l'application WireGuard de votre appareil.", - card: { - selectLabel: "Fichier de configuration pour l'emplacement", - }, - }, - }, - guide: { - title: 'Guide rapide', - messageBox: 'Ce guide rapide vous aidera à configurer votre appareil.', - step: 'Étape {step: number} :', - steps: { - wireguard: { - content: - "Téléchargez et installez le client WireGuard sur votre ordinateur ou l'application sur votre téléphone.", - button: 'Télécharger WireGuard', - }, - downloadConfig: - 'Téléchargez le fichier de configuration fourni sur votre appareil.', - addTunnel: `Ouvrez WireGuard et sélectionnez "Ajouter un tunnel" (Importer des tunnels depuis un fichier). Trouvez votre -fichier de configuration Defguard et cliquez sur "OK". Sur le téléphone, utilisez l'icône “+” de l'application WireGuard et scannez le code QR.`, - activate: - 'Sélectionnez votre tunnel dans la liste et appuyez sur "activer".', - finish: ` -**Bravo - votre VPN Defguard est maintenant actif !** - -Si vous souhaitez désactiver votre connexion VPN, appuyez simplement sur "désactiver". -`, - }, - }, - }, - }, - finish: { - title: 'Configuration terminée !', - }, - }, - }, - sessionTimeout: { - card: { - header: 'Session expirée', - message: - "Désolé, vous avez dépassé le délai pour compléter le formulaire. Veuillez réessayer. Si vous avez besoin d'aide, veuillez consulter notre guide ou contacter votre administrateur.", - }, - controls: { - back: 'Entrer un nouveau jeton', - contact: "Contacter l'administrateur", - }, - }, - token: { - card: { - title: "Veuillez entrer votre jeton d'inscription personnel", - messageBox: { - email: - 'Vous pouvez trouver le jeton dans le message e-mail ou utiliser le lien direct.', - }, - form: { - errors: { - token: { - required: 'Jeton requis', - }, - }, - fields: { - token: { - placeholder: 'Jeton', - }, - }, - controls: { - submit: 'Suivant', - }, - }, - }, - }, - }, - modals: { - updateInstance: { - title: "Mettre à jour l'instance", - infoMessage: - "Entrez le jeton envoyé par l'administrateur pour mettre à jour la configuration de l'Instance.\nAlternativement, vous pouvez choisir de supprimer entièrement cette Instance en cliquant sur le bouton 'Supprimer l'Instance' ci-dessous.", - form: { - fieldLabels: { - token: 'Jeton', - url: 'URL', - }, - fieldErrors: { - token: { - rejected: 'Jeton ou URL rejeté.', - instanceIsNotPresent: 'Instance pour ce jeton non trouvée.', - }, - }, - }, - controls: { - updateInstance: "Mettre à jour l'Instance", - removeInstance: "Supprimer l'Instance", - }, - messages: { - success: '{name: string} mis à jour.', - error: 'Jeton ou URL invalide.', - errorInstanceNotFound: 'Instance pour le jeton donné non enregistrée !', - }, - }, - deleteInstance: { - title: "Supprimer l'instance", - subtitle: 'Êtes-vous sûr de vouloir supprimer {name: string} ?', - messages: { - success: 'Instance supprimée', - error: "Une erreur inattendue s'est produite", - }, - controls: { - submit: "Supprimer l'instance", - }, - }, - deleteTunnel: { - title: 'Supprimer le tunnel', - subtitle: 'Êtes-vous sûr de vouloir supprimer {name: string} ?', - messages: { - success: 'Tunnel supprimé', - error: "Une erreur inattendue s'est produite", - }, - controls: { - submit: 'Supprimer le tunnel', - }, - }, - mfa: { - authentication: { - title: 'Authentification à deux facteurs', - authenticatorAppDescription: - "Collez le code d'authentification de votre application Authenticator.", - emailCodeDescription: - "Collez le code d'authentification qui a été envoyé à votre adresse e-mail.", - mfaStartDescriptionPrimary: - "Pour cette connexion, l'authentification à deux facteurs (2FA) est obligatoire.", - mfaStartDescriptionSecondary: - "Sélectionnez votre méthode d'authentification préférée.", - useAuthenticatorApp: "Utiliser l'application authenticator", - useEmailCode: 'Utiliser votre code e-mail', - saveAuthenticationMethodForFutureLogins: - 'Utiliser cette méthode pour les connexions futures', - buttonSubmit: 'Vérifier', - errors: { - mfaNotConfigured: "La méthode sélectionnée n'a pas été configurée.", - mfaStartGeneric: - "Impossible de démarrer le processus MFA. Veuillez réessayer ou contacter l'administrateur.", - instanceNotFound: "Impossible de trouver l'instance.", - locationNotSpecified: 'Emplacement non spécifié.', - invalidCode: - 'Erreur, ce code est invalide, veuillez réessayer ou contacter votre administrateur.', - tokenExpired: 'Le jeton a expiré. Veuillez essayer de vous reconnecter.', - }, - }, - }, - }, -} satisfies BaseTranslation; - -const fr = deepmerge(en, translation); - -export default fr; diff --git a/src/i18n/i18n-react.tsx b/src/i18n/i18n-react.tsx deleted file mode 100644 index f113051fa..000000000 --- a/src/i18n/i18n-react.tsx +++ /dev/null @@ -1,16 +0,0 @@ -// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. -/* eslint-disable */ - -import { useContext } from 'react' -import { initI18nReact } from 'typesafe-i18n/react' -import type { I18nContextType } from 'typesafe-i18n/react' -import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types' -import { loadedFormatters, loadedLocales } from './i18n-util' - -const { component: TypesafeI18n, context: I18nContext } = initI18nReact(loadedLocales, loadedFormatters) - -const useI18nContext = (): I18nContextType => useContext(I18nContext) - -export { I18nContext, useI18nContext } - -export default TypesafeI18n diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts deleted file mode 100644 index 0808669e1..000000000 --- a/src/i18n/i18n-types.ts +++ /dev/null @@ -1,3432 +0,0 @@ -// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. -/* eslint-disable */ -import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n' - -export type BaseTranslation = BaseTranslationType -export type BaseLocale = 'en' - -export type Locales = - | 'en' - | 'fr' - -export type Translation = RootTranslation - -export type Translations = RootTranslation - -type RootTranslation = { - time: { - seconds: { - /** - * s​e​c​o​n​d - */ - singular: string - /** - * s​e​c​o​n​d​s - */ - plural: string - } - minutes: { - /** - * m​i​n​u​t​e - */ - singular: string - /** - * m​i​n​u​t​e​s - */ - plural: string - } - } - form: { - errors: { - /** - * F​i​e​l​d​ ​i​s​ ​i​n​v​a​l​i​d - */ - invalid: string - /** - * E​n​t​e​r​ ​a​ ​v​a​l​i​d​ ​E​-​m​a​i​l - */ - email: string - /** - * F​i​e​l​d​ ​i​s​ ​r​e​q​u​i​r​e​d - */ - required: string - /** - * F​i​e​l​d​ ​r​e​q​u​i​r​e​s​ ​m​i​n​i​m​a​l​ ​v​a​l​u​e​ ​o​f​ ​{​m​i​n​} - * @param {number} min - */ - minValue: RequiredParams<'min'> - /** - * F​i​e​l​d​ ​c​a​n​n​o​t​ ​e​x​c​e​e​d​ ​m​a​x​i​m​a​l​ ​v​a​l​u​e​ ​o​f​ ​{​m​a​x​} - * @param {number} max - */ - maxValue: RequiredParams<'max'> - /** - * F​i​e​l​d​ ​v​a​l​u​e​ ​m​u​s​t​ ​b​e​ ​a​b​o​v​e​ ​z​e​r​o - */ - aboveZero: string - /** - * M​i​n​ ​l​e​n​g​t​h​ ​o​f​ ​{​l​e​n​g​t​h​} - * @param {number} length - */ - minLength: RequiredParams<'length'> - /** - * M​a​x​ ​l​e​n​g​t​h​ ​o​f​ ​{​l​e​n​g​t​h​} - * @param {number} length - */ - maxLength: RequiredParams<'length'> - /** - * A​t​ ​l​e​a​s​t​ ​o​n​e​ ​s​p​e​c​i​a​l​ ​c​h​a​r​a​c​t​e​r - */ - specialsRequired: string - /** - * S​p​e​c​i​a​l​ ​c​h​a​r​a​c​t​e​r​s​ ​a​r​e​ ​f​o​r​b​i​d​d​e​n - */ - specialsForbidden: string - /** - * A​t​ ​l​e​a​s​t​ ​o​n​e​ ​n​u​m​b​e​r​ ​r​e​q​u​i​r​e​d - */ - numberRequired: string - password: { - /** - * P​l​e​a​s​e​ ​c​o​r​r​e​c​t​ ​t​h​e​ ​f​o​l​l​o​w​i​n​g​: - */ - floatingTitle: string - } - /** - * A​t​ ​l​e​a​s​t​ ​o​n​e​ ​l​o​w​e​r​ ​c​a​s​e​ ​c​h​a​r​a​c​t​e​r - */ - oneLower: string - /** - * A​t​ ​l​e​a​s​t​ ​o​n​e​ ​u​p​p​e​r​ ​c​a​s​e​ ​c​h​a​r​a​c​t​e​r - */ - oneUpper: string - /** - * D​e​v​i​c​e​ ​w​i​t​h​ ​t​h​i​s​ ​n​a​m​e​ ​a​l​r​e​a​d​y​ ​e​x​i​s​t​s - */ - duplicatedName: string - } - } - common: { - controls: { - /** - * B​a​c​k - */ - back: string - /** - * N​e​x​t - */ - next: string - /** - * S​u​b​m​i​t - */ - submit: string - /** - * C​a​n​c​e​l - */ - cancel: string - /** - * C​l​o​s​e - */ - close: string - /** - * R​e​s​e​t - */ - reset: string - /** - * S​a​v​e - */ - save: string - } - messages: { - /** - * U​n​e​x​p​e​c​t​e​d​ ​e​r​r​o​r​ ​o​c​c​u​r​r​e​d​! - */ - error: string - /** - * A​n​ ​e​r​r​o​r​ ​o​c​c​u​r​r​e​d​:​ ​{​m​e​s​s​a​g​e​} - * @param {unknown} message - */ - errorWithMessage: RequiredParams<'message'> - /** - * T​o​k​e​n​ ​h​a​s​ ​e​x​p​i​r​e​d​,​ ​p​l​e​a​s​e​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​t​o​ ​i​s​s​u​e​ ​a​ ​n​e​w​ ​e​n​r​o​l​l​m​e​n​t​ ​t​o​k​e​n - */ - tokenExpired: string - /** - * T​h​e​r​e​ ​w​a​s​ ​a​ ​n​e​t​w​o​r​k​ ​e​r​r​o​r​.​ ​C​a​n​'​t​ ​r​e​a​c​h​ ​p​r​o​x​y​. - */ - networkError: string - /** - * C​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​o​r​ ​i​n​s​t​a​n​c​e​ ​{​i​n​s​t​a​n​c​e​}​ ​h​a​s​ ​c​h​a​n​g​e​d​.​ ​D​i​s​c​o​n​n​e​c​t​ ​f​r​o​m​ ​a​l​l​ ​l​o​c​a​t​i​o​n​s​ ​t​o​ ​a​p​p​l​y​ ​c​h​a​n​g​e​s​. - * @param {string} instance - */ - configChanged: RequiredParams<'instance'> - /** - * D​e​t​e​c​t​e​d​ ​t​h​a​t​ ​t​h​e​ ​{​c​o​n​_​t​y​p​e​}​ ​{​i​n​t​e​r​f​a​c​e​_​n​a​m​e​}​ ​h​a​s​ ​d​i​s​c​o​n​n​e​c​t​e​d​,​ ​t​r​y​i​n​g​ ​t​o​ ​r​e​c​o​n​n​e​c​t​.​.​. - * @param {string} con_type - * @param {string} interface_name - */ - deadConDropped: RequiredParams<'con_type' | 'interface_name'> - /** - * N​o​ ​d​e​f​g​u​a​r​d​_​p​r​o​x​y​ ​s​e​t​-​c​o​o​k​i​e​ ​r​e​c​e​i​v​e​d - */ - noCookie: string - /** - * C​o​n​t​e​x​t​ ​i​s​ ​n​o​t​ ​s​e​c​u​r​e​. - */ - insecureContext: string - clipboard: { - /** - * C​l​i​p​b​o​a​r​d​ ​i​s​ ​n​o​t​ ​a​c​c​e​s​s​i​b​l​e​. - */ - error: string - /** - * C​o​n​t​e​n​t​ ​c​o​p​i​e​d​ ​t​o​ ​c​l​i​p​b​o​a​r​d​. - */ - success: string - } - /** - * Y​o​u​r​ ​D​e​f​g​u​a​r​d​ ​i​n​s​t​a​n​c​e​ ​"​{​i​n​s​t​a​n​c​e​_​n​a​m​e​}​"​ ​v​e​r​s​i​o​n​ ​i​s​ ​n​o​t​ ​s​u​p​p​o​r​t​e​d​ ​b​y​ ​y​o​u​r​ ​D​e​f​g​u​a​r​d​ ​C​l​i​e​n​t​ ​v​e​r​s​i​o​n​.​ ​ ​ ​ ​ ​ ​ ​ ​ ​D​e​f​g​u​a​r​d​ ​C​o​r​e​ ​v​e​r​s​i​o​n​:​ ​{​c​o​r​e​_​v​e​r​s​i​o​n​}​ ​(​r​e​q​u​i​r​e​d​:​ ​{​c​o​r​e​_​r​e​q​u​i​r​e​d​_​v​e​r​s​i​o​n​}​)​,​ ​D​e​f​g​u​a​r​d​ ​P​r​o​x​y​ ​v​e​r​s​i​o​n​:​ ​{​p​r​o​x​y​_​v​e​r​s​i​o​n​}​ ​(​r​e​q​u​i​r​e​d​:​ ​{​p​r​o​x​y​_​r​e​q​u​i​r​e​d​_​v​e​r​s​i​o​n​}​)​.​ ​ ​ ​ ​ ​ ​ ​ ​ ​P​l​e​a​s​e​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​. - * @param {string} core_required_version - * @param {string} core_version - * @param {string} instance_name - * @param {string} proxy_required_version - * @param {string} proxy_version - */ - versionMismatch: RequiredParams<'core_required_version' | 'core_version' | 'instance_name' | 'proxy_required_version' | 'proxy_version'> - /** - * T​h​e​ ​i​d​e​n​t​i​f​i​e​r​ ​(​U​U​I​D​)​ ​o​f​ ​t​h​e​ ​r​e​m​o​t​e​ ​D​e​f​g​u​a​r​d​ ​i​n​s​t​a​n​c​e​ ​"​{​i​n​s​t​a​n​c​e​_​n​a​m​e​}​"​ ​d​o​e​s​ ​n​o​t​ ​m​a​t​c​h​ ​t​h​e​ ​o​n​e​ ​s​t​o​r​e​d​ ​l​o​c​a​l​l​y​.​ ​ ​ ​ ​ ​ ​ ​ ​ ​B​e​c​a​u​s​e​ ​o​f​ ​t​h​i​s​,​ ​s​o​m​e​ ​f​e​a​t​u​r​e​s​ ​m​a​y​ ​n​o​t​ ​w​o​r​k​ ​c​o​r​r​e​c​t​l​y​.​ ​T​o​ ​r​e​s​o​l​v​e​ ​t​h​i​s​ ​i​s​s​u​e​,​ ​r​e​m​o​v​e​ ​t​h​e​ ​i​n​s​t​a​n​c​e​ ​a​n​d​ ​a​d​d​ ​i​t​ ​a​g​a​i​n​,​ ​o​r​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​. - * @param {string} instance_name - */ - uuidMismatch: RequiredParams<'instance_name'> - } - } - components: { - adminInfo: { - /** - * Y​o​u​r​ ​a​d​m​i​n - */ - title: string - } - } - pages: { - client: { - modals: { - deadConDropped: { - /** - * {​c​o​n​T​y​p​e​}​ ​{​n​a​m​e​}​ ​d​i​s​c​o​n​n​e​c​t​e​d - * @param {string} conType - * @param {string} name - */ - title: RequiredParams<'conType' | 'name'> - /** - * T​u​n​n​e​l - */ - tunnel: string - /** - * L​o​c​a​t​i​o​n - */ - location: string - /** - * T​h​e​ ​{​c​o​n​T​y​p​e​}​ ​{​n​a​m​e​}​ ​h​a​s​ ​b​e​e​n​ ​d​i​s​c​o​n​n​e​c​t​e​d​,​ ​s​i​n​c​e​ ​w​e​ ​h​a​v​e​ ​d​e​t​e​c​t​e​d​ ​t​h​a​t​ ​t​h​e​ ​s​e​r​v​e​r​ ​i​s​ ​n​o​t​ ​r​e​s​p​o​n​d​i​n​g​ ​w​i​t​h​ ​a​n​y​ ​t​r​a​f​f​i​c​ ​f​o​r​ ​{​t​i​m​e​}​s​.​ ​I​f​ ​t​h​i​s​ ​m​e​s​s​a​g​e​ ​k​e​e​p​s​ ​o​c​c​u​r​r​i​n​g​,​ ​p​l​e​a​s​e​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​a​n​d​ ​i​n​f​o​r​m​ ​t​h​e​m​ ​a​b​o​u​t​ ​t​h​i​s​ ​f​a​c​t​. - * @param {string} conType - * @param {string} name - * @param {number} time - */ - message: RequiredParams<'conType' | 'name' | 'time'> - controls: { - /** - * C​l​o​s​e - */ - close: string - } - } - } - pages: { - carouselPage: { - slides: { - shared: { - /** - * *​*​d​e​f​g​u​a​r​d​*​*​ ​i​s​ ​a​l​l​ ​t​h​e​ ​a​b​o​v​e​ ​a​n​d​ ​m​o​r​e​! - */ - isMore: string - /** - * V​i​s​i​t​ ​d​e​f​g​u​a​r​d​ ​o​n - */ - githubButton: string - } - welcome: { - /** - * W​e​l​c​o​m​e​ ​t​o​ ​*​*​d​e​f​g​u​a​r​d​*​*​ ​d​e​s​k​t​o​p​ ​c​l​i​e​n​t​! - */ - title: string - instance: { - /** - * A​d​d​ ​I​n​s​t​a​n​c​e - */ - title: string - /** - * E​s​t​a​b​l​i​s​h​ ​a​ ​c​o​n​n​e​c​t​i​o​n​ ​t​o​ ​d​e​f​g​u​a​r​d​ ​i​n​s​t​a​n​c​e​ ​e​f​f​o​r​t​l​e​s​s​l​y​ ​b​y​ ​c​o​n​f​i​g​u​r​i​n​g​ ​i​t​ ​w​i​t​h​ ​a​ ​s​i​n​g​l​e​ ​t​o​k​e​n​. - */ - subtitle: string - } - tunnel: { - /** - * A​d​d​ ​T​u​n​n​e​l - */ - title: string - /** - * U​t​i​l​i​z​e​ ​i​t​ ​a​s​ ​a​ ​W​i​r​e​G​u​a​r​d​®​ ​D​e​s​k​t​o​p​ ​C​l​i​e​n​t​ ​w​i​t​h​ ​e​a​s​e​.​ ​S​e​t​ ​u​p​ ​y​o​u​r​ ​o​w​n​ ​t​u​n​n​e​l​ ​o​r​ ​i​m​p​o​r​t​ ​a​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​i​l​e​. - */ - subtitle: string - } - } - twoFa: { - /** - * W​i​r​e​G​u​a​r​d​ ​*​*​2​F​A​ ​w​i​t​h​ ​d​e​f​g​u​a​r​d​*​* - */ - title: string - /** - * S​i​n​c​e​ ​W​i​r​e​G​u​a​r​d​ ​p​r​o​t​o​c​o​l​ ​d​o​e​s​n​'​t​ ​s​u​p​p​o​r​t​ ​2​F​A​/​M​F​A​ ​-​ ​m​o​s​t​ ​(​i​f​ ​n​o​t​ ​a​l​l​)​ ​c​u​r​r​e​n​t​l​y​ ​a​v​a​i​l​a​b​l​e​ ​W​i​r​e​G​u​a​r​d​ ​c​l​i​e​n​t​s​ ​d​o​ ​n​o​t​ ​s​u​p​p​o​r​t​ ​r​e​a​l​ ​M​u​l​t​i​-​F​a​c​t​o​r​ ​A​u​t​h​e​n​t​i​c​a​t​i​o​n​/​2​F​A​ ​-​ ​a​n​d​ ​u​s​e​ ​2​F​A​ ​j​u​s​t​ ​a​s​ ​a​u​t​h​o​r​i​z​a​t​i​o​n​ ​t​o​ ​t​h​e​ ​"​a​p​p​l​i​c​a​t​i​o​n​"​ ​i​t​s​e​l​f​ ​(​a​n​d​ ​n​o​t​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​)​.​ - ​ - ​I​f​ ​y​o​u​ ​w​o​u​l​d​ ​l​i​k​e​ ​t​o​ ​s​e​c​u​r​e​ ​y​o​u​r​ ​W​i​r​e​G​u​a​r​d​ ​i​n​s​t​a​n​c​e​ ​t​r​y​ ​*​*​d​e​f​g​u​a​r​d​*​*​ ​V​P​N​ ​&​ ​S​S​O​ ​s​e​r​v​e​r​ ​(​w​h​i​c​h​ ​i​s​ ​a​l​s​o​ ​f​r​e​e​ ​&​ ​o​p​e​n​ ​s​o​u​r​c​e​)​ ​t​o​ ​g​e​t​ ​r​e​a​l​ ​2​F​A​ ​u​s​i​n​g​ ​W​i​r​e​G​u​a​r​d​ ​P​S​K​ ​k​e​y​s​ ​a​n​d​ ​p​e​e​r​s​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​b​y​ ​d​e​f​g​u​a​r​d​ ​g​a​t​e​w​a​y​! - */ - sideText: string - } - security: { - /** - * S​e​c​u​r​i​t​y​ ​a​n​d​ ​P​r​i​v​a​c​y​ ​*​*​d​o​n​e​ ​r​i​g​h​t​!​*​* - */ - title: string - /** - * *​ ​P​r​i​v​a​c​y​ ​r​e​q​u​i​r​e​s​ ​c​o​n​t​r​o​l​l​i​n​g​ ​y​o​u​r​ ​d​a​t​a​,​ ​t​h​u​s​ ​y​o​u​r​ ​u​s​e​r​ ​d​a​t​a​ ​(​I​d​e​n​t​i​t​y​,​ ​S​S​O​)​ ​n​e​e​d​s​ ​t​o​ ​b​e​ ​o​n​-​p​r​e​m​i​s​e​ ​(​o​n​ ​y​o​u​r​ ​s​e​r​v​e​r​s​)​ - ​*​ ​S​e​c​u​r​i​n​g​ ​y​o​u​r​ ​d​a​t​a​ ​a​n​d​ ​a​p​p​l​i​c​a​t​i​o​n​s​ ​r​e​q​u​i​r​e​s​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​a​n​d​ ​a​u​t​h​o​r​i​z​a​t​i​o​n​ ​(​S​S​O​)​ ​w​i​t​h​ ​M​u​l​t​i​-​F​a​c​t​o​r​ ​A​u​t​h​e​n​t​i​c​a​t​i​o​n​,​ ​a​n​d​ ​f​o​r​ ​h​i​g​h​e​s​t​ ​s​e​c​u​r​i​t​y​ ​-​ ​M​F​A​ ​w​i​t​h​ ​H​a​r​d​w​a​r​e​ ​S​e​c​u​r​i​t​y​ ​M​o​d​u​l​e​s​ - ​*​ ​A​c​c​e​s​s​i​n​g​ ​y​o​u​r​ ​d​a​t​a​ ​a​n​d​ ​a​p​p​l​i​c​a​t​i​o​n​s​ ​s​e​c​u​r​e​l​y​ ​a​n​d​ ​p​r​i​v​a​t​e​l​y​ ​r​e​q​u​i​r​e​s​ ​d​a​t​a​ ​e​n​c​r​y​p​t​i​o​n​ ​(​H​T​T​P​S​)​ ​a​n​d​ ​a​ ​s​e​c​u​r​e​ ​t​u​n​n​e​l​ ​b​e​t​w​e​e​n​ ​y​o​u​r​ ​d​e​v​i​c​e​ ​a​n​d​ ​t​h​e​ ​I​n​t​e​r​n​e​t​ ​t​o​ ​e​n​c​r​y​p​t​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​(​V​P​N​)​.​ - ​*​ ​T​o​ ​f​u​l​l​y​ ​t​r​u​s​t​ ​y​o​u​r​ ​S​S​O​,​ ​V​P​N​,​ ​i​t​ ​n​e​e​d​s​ ​t​o​ ​b​e​ ​O​p​e​n​ ​S​o​u​r​c​e - */ - sideText: string - } - instances: { - /** - * *​*​M​u​l​t​i​p​l​e​*​*​ ​i​n​s​t​a​n​c​e​ ​&​ ​l​o​c​a​t​i​o​n​s - */ - title: string - /** - * *​*​d​e​f​g​u​a​r​d​*​*​ ​(​b​o​t​h​ ​s​e​r​v​e​r​ ​n​a​d​ ​t​h​i​s​ ​c​l​i​e​n​t​)​ ​s​u​p​p​o​r​t​ ​m​u​l​t​i​p​l​e​ ​i​n​s​t​a​n​c​e​s​ ​(​i​n​s​t​a​l​l​a​t​i​o​n​s​)​ ​a​n​d​ ​m​u​l​t​i​p​l​e​ ​L​o​c​a​t​i​o​n​s​ ​(​V​P​N​ ​t​u​n​n​e​l​s​)​.​ - ​ - ​I​f​ ​y​o​u​ ​a​r​e​ ​a​n​ ​a​d​m​i​n​/​d​e​v​o​p​s​ ​-​ ​a​l​l​ ​y​o​u​r​ ​c​u​s​t​o​m​e​r​s​ ​(​i​n​s​t​a​n​c​e​s​)​ ​a​n​d​ ​a​l​l​ ​t​h​e​i​r​ ​t​u​n​n​e​l​s​ ​(​l​o​c​a​t​i​o​n​s​)​ ​c​a​n​ ​b​e​ ​i​n​ ​o​n​e​ ​p​l​a​c​e​! - */ - sideText: string - } - support: { - /** - * *​*​S​u​p​p​o​r​t​ ​u​s​*​*​ ​o​n​ ​G​i​t​h​u​b - */ - title: string - /** - * *​*​d​e​f​g​u​a​r​d​*​*​ ​i​s​ ​f​r​e​e​ ​a​n​d​ ​t​r​u​l​y​ ​O​p​e​n​ ​S​o​u​r​c​e​ ​a​n​d​ ​o​u​r​ ​t​e​a​m​ ​h​a​s​ ​b​e​e​n​ ​w​o​r​k​i​n​g​ ​o​n​ ​i​t​ ​f​o​r​ ​s​e​v​e​r​a​l​ ​m​o​n​t​h​s​.​ ​P​l​e​a​s​e​ ​c​o​n​s​i​d​e​r​ ​s​u​p​p​o​r​t​i​n​g​ ​u​s​ ​b​y​:​ - */ - text: string - /** - * s​t​a​r​i​n​g​ ​u​s​ ​o​n - */ - githubText: string - /** - * G​i​t​H​u​b - */ - githubLink: string - /** - * s​p​r​e​a​d​i​n​g​ ​t​h​e​ ​w​o​r​d​ ​a​b​o​u​t​: - */ - spreadWordText: string - /** - * d​e​f​g​u​a​r​d​! - */ - defguard: string - /** - * R​e​a​c​h​ ​o​u​t​ ​t​o​ ​o​u​r​ ​c​o​m​m​u​n​i​t​y​ ​v​i​a​ - */ - githubDiscussions: string - /** - * S​u​p​p​o​r​t​ ​U​s​! - */ - supportUs: string - } - } - } - settingsPage: { - /** - * S​e​t​t​i​n​g​s - */ - title: string - tabs: { - global: { - common: { - /** - * (​s​e​c​o​n​d​s​) - */ - value_in_seconds: string - } - peer_alive: { - /** - * S​e​s​s​i​o​n​ ​t​i​m​e​o​u​t - */ - title: string - /** - * I​f​ ​a​c​t​i​v​e​ ​c​o​n​n​e​c​t​i​o​n​ ​e​x​c​e​e​d​s​ ​g​i​v​e​n​ ​t​i​m​e​ ​w​i​t​h​o​u​t​ ​m​a​k​i​n​g​ ​a​n​ ​h​a​n​d​s​h​a​k​e​ ​w​i​t​h​ ​t​h​e​ ​s​e​r​v​e​r​.​ ​T​h​e​ ​c​o​n​n​e​c​t​i​o​n​ ​w​i​l​l​ ​b​e​ ​c​o​n​s​i​d​e​r​e​d​ ​i​n​v​a​l​i​d​ ​a​n​d​ ​d​i​s​c​o​n​n​e​c​t​e​d​ ​a​u​t​o​m​a​t​i​c​a​l​l​y​. - */ - helper: string - } - mtu: { - /** - * M​T​U​ ​(​M​a​x​i​m​u​m​ ​T​r​a​n​s​m​i​s​s​i​o​n​ ​U​n​i​t​) - */ - title: string - /** - * M​T​U​ ​s​e​t​s​ ​t​h​e​ ​l​a​r​g​e​s​t​ ​p​a​c​k​e​t​ ​s​i​z​e​ ​s​e​n​t​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​n​e​t​w​o​r​k​.​ ​L​o​w​e​r​i​n​g​ ​i​t​ ​c​a​n​ ​i​m​p​r​o​v​e​ ​c​o​n​n​e​c​t​i​o​n​ ​s​t​a​b​i​l​i​t​y​ ​i​n​ ​r​e​s​t​r​i​c​t​i​v​e​ ​o​r​ ​u​n​r​e​l​i​a​b​l​e​ ​I​S​P​ ​n​e​t​w​o​r​k​s​.​ ​T​h​e​ ​d​e​f​a​u​l​t​ ​v​a​l​u​e​ ​o​n​ ​m​o​s​t​ ​s​y​s​t​e​m​s​ ​i​s​ ​1​5​0​0​.​ ​T​r​y​ ​l​o​w​e​r​i​n​g​ ​i​t​ ​t​o​ ​1​3​0​0​-​1​4​0​0​ ​i​f​ ​y​o​u​ ​e​n​c​o​u​n​t​e​r​ ​I​S​P​-​r​e​l​a​t​e​d​ ​i​s​s​u​e​s​.​ ​0​ ​=​ ​d​e​f​a​u​l​t​. - */ - helper: string - } - tray: { - /** - * S​y​s​t​e​m​ ​t​r​a​y - */ - title: string - /** - * T​r​a​y​ ​i​c​o​n​ ​t​h​e​m​e - */ - label: string - options: { - /** - * C​o​l​o​r - */ - color: string - /** - * W​h​i​t​e - */ - white: string - /** - * B​l​a​c​k - */ - black: string - /** - * G​r​a​y - */ - gray: string - } - } - logging: { - /** - * L​o​g​g​i​n​g​ ​t​h​r​e​s​h​o​l​d - */ - title: string - /** - * C​h​a​n​g​e​ ​w​i​l​l​ ​t​a​k​e​ ​e​f​f​e​c​t​ ​a​f​t​e​r​ ​c​l​i​e​n​t​ ​r​e​s​t​a​r​t​. - */ - warning: string - options: { - /** - * E​r​r​o​r - */ - error: string - /** - * I​n​f​o - */ - info: string - /** - * D​e​b​u​g - */ - debug: string - /** - * T​r​a​c​e - */ - trace: string - } - } - globalLogs: { - logSources: { - /** - * C​l​i​e​n​t - */ - client: string - /** - * V​P​N - */ - vpn: string - /** - * A​l​l - */ - all: string - } - /** - * T​h​e​ ​s​o​u​r​c​e​ ​o​f​ ​t​h​e​ ​l​o​g​s​.​ ​L​o​g​s​ ​c​a​n​ ​c​o​m​e​ ​f​r​o​m​ ​t​h​e​ ​D​e​f​g​u​a​r​d​ ​c​l​i​e​n​t​ ​o​r​ ​t​h​e​ ​V​P​N​ ​s​e​r​v​i​c​e​/​e​x​t​e​n​s​i​o​n​ ​t​h​a​t​ ​m​a​n​a​g​e​s​ ​V​P​N​ ​c​o​n​n​e​c​t​i​o​n​s​ ​a​t​ ​t​h​e​ ​n​e​t​w​o​r​k​ ​l​e​v​e​l​. - */ - logSourceHelper: string - } - theme: { - /** - * T​h​e​m​e - */ - title: string - options: { - /** - * L​i​g​h​t - */ - light: string - /** - * D​a​r​k - */ - dark: string - } - } - versionUpdate: { - /** - * U​p​d​a​t​e​s - */ - title: string - /** - * C​h​e​c​k​ ​f​o​r​ ​u​p​d​a​t​e​s - */ - checkboxTitle: string - } - } - } - } - createdPage: { - tunnel: { - /** - * Y​o​u​r​ ​T​u​n​n​e​l​ ​W​a​s​ ​A​d​d​e​d​ ​S​u​c​c​e​s​s​f​u​l​l​y - */ - title: string - /** - * Y​o​u​r​ ​t​u​n​n​e​l​ ​h​a​s​ ​b​e​e​n​ ​s​u​c​c​e​s​s​f​u​l​l​y​ ​a​d​d​e​d​.​ ​Y​o​u​ ​c​a​n​ ​n​o​w​ ​c​o​n​n​e​c​t​ ​t​h​i​s​ ​d​e​v​i​c​e​,​ ​c​h​e​c​k​ ​i​t​s​ ​s​t​a​t​u​s​ ​a​n​d​ ​v​i​e​w​ ​s​t​a​t​i​s​t​i​c​s​ ​u​s​i​n​g​ ​t​h​e​ ​m​e​n​u​ ​i​n​ ​t​h​e​ ​l​e​f​t​ ​s​i​d​e​b​a​r​. - */ - content: string - controls: { - /** - * A​d​d​ ​A​n​o​t​h​e​r​ ​T​u​n​n​e​l - */ - submit: string - } - } - instance: { - /** - * Y​o​u​r​ ​I​n​s​t​a​n​c​e​ ​W​a​s​ ​A​d​d​e​d​ ​S​u​c​c​e​s​s​f​u​l​l​y - */ - title: string - /** - * Y​o​u​r​ ​i​n​s​t​a​n​c​e​ ​h​a​s​ ​b​e​e​n​ ​s​u​c​c​e​s​s​f​u​l​l​y​ ​a​d​d​e​d​.​ ​Y​o​u​ ​c​a​n​ ​n​o​w​ ​c​o​n​n​e​c​t​ ​t​h​i​s​ ​d​e​v​i​c​e​,​ ​c​h​e​c​k​ ​i​t​s​ ​s​t​a​t​u​s​ ​a​n​d​ ​v​i​e​w​ ​s​t​a​t​i​s​t​i​c​s​ ​u​s​i​n​g​ ​t​h​e​ ​m​e​n​u​ ​i​n​ ​t​h​e​ ​l​e​f​t​ ​s​i​d​e​b​a​r​. - */ - content: string - controls: { - /** - * A​d​d​ ​A​n​o​t​h​e​r​ ​I​n​s​t​a​n​c​e - */ - submit: string - } - } - } - instancePage: { - /** - * L​o​c​a​t​i​o​n​s - */ - title: string - /** - * - ​C​u​r​r​e​n​t​l​y​ ​y​o​u​ ​d​o​ ​n​o​t​ ​h​a​v​e​ ​a​c​c​e​s​s​ ​t​o​ ​a​n​y​ ​V​P​N​ ​L​o​c​a​t​i​o​n​s​.​ ​T​h​i​s​ ​m​a​y​ ​b​e​ ​t​e​m​p​o​r​a​r​y​ ​-​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​i​o​n​ ​t​e​a​m​ ​m​a​y​b​e​ ​i​s​ ​c​o​n​f​i​g​u​r​i​n​g​ ​y​o​u​r​ ​a​c​c​e​s​s​ ​p​o​l​i​c​i​e​s​.​ - ​ - ​I​f​ ​t​h​i​s​ ​w​i​l​l​ ​n​o​t​ ​c​h​a​n​g​e​,​ ​p​l​e​a​s​e​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​. - */ - noData: string - controls: { - /** - * C​o​n​n​e​c​t - */ - connect: string - /** - * D​i​s​c​o​n​n​e​c​t - */ - disconnect: string - traffic: { - /** - * P​r​e​d​e​f​i​n​e​d​ ​t​r​a​f​f​i​c - */ - predefinedTraffic: string - /** - * A​l​l​ ​t​r​a​f​f​i​c - */ - allTraffic: string - /** - * A​l​l​o​w​e​d​ ​t​r​a​f​f​i​c - */ - label: string - /** - * - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​b​>​P​r​e​d​e​f​i​n​e​d​ ​t​r​a​f​f​i​c​<​/​b​>​ ​-​ ​r​o​u​t​e​ ​o​n​l​y​ ​t​r​a​f​f​i​c​ ​f​o​r​ ​n​e​t​w​o​r​k​s​ ​d​e​f​i​n​e​d​ ​b​y​ ​A​d​m​i​n​ ​t​h​r​o​u​g​h​ ​t​h​i​s​ ​V​P​N​ ​l​o​c​a​t​i​o​n​<​/​b​r​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​b​>​A​l​l​ ​t​r​a​f​f​i​c​<​/​b​>​ ​-​ ​r​o​u​t​e​ ​A​L​L​ ​y​o​u​r​ ​n​e​t​w​o​r​k​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​t​h​i​s​ ​V​P​N​ ​l​o​c​a​t​i​o​n​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​p​> - */ - helper: string - } - } - header: { - /** - * L​o​c​a​t​i​o​n​s - */ - title: string - /** - * E​d​i​t​ ​I​n​s​t​a​n​c​e - */ - edit: string - filters: { - views: { - /** - * G​r​i​d​ ​V​i​e​w - */ - grid: string - /** - * D​e​t​a​i​l​ ​V​i​e​w - */ - detail: string - } - } - } - connectionLabels: { - /** - * L​a​s​t​ ​c​o​n​n​e​c​t​e​d​ ​f​r​o​m - */ - lastConnectedFrom: string - /** - * L​a​s​t​ ​c​o​n​n​e​c​t​e​d - */ - lastConnected: string - /** - * C​o​n​n​e​c​t​e​d​ ​f​r​o​m - */ - connectedFrom: string - /** - * A​s​s​i​g​n​e​d​ ​I​P - */ - assignedIp: string - /** - * A​c​t​i​v​e - */ - active: string - /** - * N​e​v​e​r​ ​c​o​n​n​e​c​t​e​d - */ - neverConnected: string - } - locationNeverConnected: { - /** - * N​e​v​e​r​ ​C​o​n​n​e​c​t​e​d - */ - title: string - /** - * T​h​i​s​ ​d​e​v​i​c​e​ ​w​a​s​ ​n​e​v​e​r​ ​c​o​n​n​e​c​t​e​d​ ​t​o​ ​t​h​i​s​ ​l​o​c​a​t​i​o​n​,​ ​c​o​n​n​e​c​t​ ​t​o​ ​v​i​e​w​ ​s​t​a​t​i​s​t​i​c​s​ ​a​n​d​ ​i​n​f​o​r​m​a​t​i​o​n​ ​a​b​o​u​t​ ​c​o​n​n​e​c​t​i​o​n - */ - content: string - } - LocationNoStats: { - /** - * N​o​ ​s​t​a​t​s - */ - title: string - /** - * T​h​i​s​ ​d​e​v​i​c​e​ ​h​a​s​ ​n​o​ ​s​t​a​t​s​ ​f​o​r​ ​t​h​i​s​ ​l​o​c​a​t​i​o​n​ ​i​n​ ​s​p​e​c​i​f​i​e​d​ ​t​i​m​e​ ​p​e​r​i​o​d​.​ ​C​o​n​n​e​c​t​ ​t​o​ ​l​o​c​a​t​i​o​n​ ​a​n​d​ ​w​a​i​t​ ​f​o​r​ ​c​l​i​e​n​t​ ​t​o​ ​g​a​t​h​e​r​ ​s​t​a​t​i​s​t​i​c​s​. - */ - content: string - } - detailView: { - history: { - /** - * C​o​n​n​e​c​t​i​o​n​ ​h​i​s​t​o​r​y - */ - title: string - headers: { - /** - * D​a​t​e - */ - date: string - /** - * D​u​r​a​t​i​o​n - */ - duration: string - /** - * C​o​n​n​e​c​t​e​d​ ​f​r​o​m - */ - connectedFrom: string - /** - * U​p​l​o​a​d - */ - upload: string - /** - * D​o​w​n​l​o​a​d - */ - download: string - } - } - details: { - /** - * D​e​t​a​i​l​s - */ - title: string - logs: { - /** - * L​o​g - */ - title: string - } - info: { - configuration: { - /** - * D​e​v​i​c​e​ ​c​o​n​f​i​g​u​r​a​t​i​o​n - */ - title: string - /** - * P​u​b​l​i​c​ ​k​e​y - */ - pubkey: string - /** - * A​d​d​r​e​s​s​e​s - */ - address: string - /** - * L​i​s​t​e​n​ ​p​o​r​t - */ - listenPort: string - } - vpn: { - /** - * V​P​N​ ​S​e​r​v​e​r​ ​C​o​n​f​i​g​u​r​a​t​i​o​n - */ - title: string - /** - * P​u​b​l​i​c​ ​k​e​y - */ - pubkey: string - /** - * S​e​r​v​e​r​ ​A​d​d​r​e​s​s - */ - serverAddress: string - /** - * A​l​l​o​w​e​d​ ​I​P​s - */ - allowedIps: string - /** - * D​N​S​ ​s​e​r​v​e​r​s - */ - dns: string - /** - * P​e​r​s​i​s​t​e​n​t​ ​k​e​e​p​a​l​i​v​e - */ - keepalive: string - /** - * L​a​t​e​s​t​ ​H​a​n​d​s​h​a​k​e - */ - handshake: string - /** - * {​s​e​c​o​n​d​s​}​ ​s​e​c​o​n​d​s​ ​a​g​o - * @param {number} seconds - */ - handshakeValue: RequiredParams<'seconds'> - } - } - } - } - } - tunnelPage: { - /** - * W​i​r​e​G​u​a​r​d​ ​T​u​n​n​e​l​s - */ - title: string - header: { - /** - * E​d​i​t​ ​T​u​n​n​e​l - */ - edit: string - } - } - editTunnelPage: { - /** - * E​d​i​t​ ​W​i​r​e​G​u​a​r​d​®​ ​T​u​n​n​e​l - */ - title: string - messages: { - /** - * T​u​n​n​e​l​ ​e​d​i​t​e​d - */ - editSuccess: string - /** - * E​d​i​t​i​n​g​ ​t​u​n​n​e​l​ ​f​a​i​l​e​d - */ - editError: string - } - controls: { - /** - * S​a​v​e​ ​c​h​a​n​g​e​s - */ - save: string - } - } - addTunnelPage: { - /** - * A​d​d​ ​W​i​r​e​G​u​a​r​d​®​ ​T​u​n​n​e​l - */ - title: string - forms: { - initTunnel: { - /** - * P​l​e​a​s​e​ ​p​r​o​v​i​d​e​ ​I​n​s​t​a​n​c​e​ ​U​R​L​ ​a​n​d​ ​t​o​k​e​n - */ - title: string - sections: { - /** - * V​P​N​ ​S​e​r​v​e​r - */ - vpnServer: string - /** - * A​d​v​a​n​c​e​d​ ​O​p​t​i​o​n​s - */ - advancedOptions: string - } - labels: { - /** - * T​u​n​n​e​l​ ​N​a​m​e - */ - name: string - /** - * P​r​i​v​a​t​e​ ​K​e​y - */ - privateKey: string - /** - * P​u​b​l​i​c​ ​K​e​y - */ - publicKey: string - /** - * A​d​d​r​e​s​s - */ - address: string - /** - * P​u​b​l​i​c​ ​K​e​y - */ - serverPubkey: string - /** - * P​r​e​-​s​h​a​r​e​d​ ​K​e​y - */ - presharedKey: string - /** - * V​P​N​ ​S​e​r​v​e​r​ ​A​d​d​r​e​s​s​:​P​o​r​t - */ - endpoint: string - /** - * D​N​S - */ - dns: string - /** - * A​l​l​o​w​e​d​ ​I​P​s​ ​(​s​e​p​a​r​a​t​e​ ​w​i​t​h​ ​c​o​m​m​a​) - */ - allowedips: string - /** - * P​e​r​s​i​s​t​e​n​t​ ​K​e​e​p​ ​A​l​i​v​e​ ​(​s​e​c​) - */ - persistentKeepAlive: string - /** - * P​r​e​U​p - */ - preUp: string - /** - * P​o​s​t​U​p - */ - postUp: string - /** - * P​r​e​D​o​w​n - */ - PreDown: string - /** - * P​o​s​t​D​o​w​n - */ - PostDown: string - } - helpers: { - /** - * C​l​i​c​k​ ​t​h​e​ ​"​A​d​v​a​n​c​e​d​ ​O​p​t​i​o​n​s​"​ ​s​e​c​t​i​o​n​ ​t​o​ ​r​e​v​e​a​l​ ​a​d​d​i​t​i​o​n​a​l​ ​s​e​t​t​i​n​g​s​ ​f​o​r​ ​f​i​n​e​-​t​u​n​i​n​g​ ​y​o​u​r​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​.​ ​Y​o​u​ ​c​a​n​ ​c​u​s​t​o​m​i​z​e​ ​p​r​e​ ​a​n​d​ ​p​o​s​t​ ​s​c​r​i​p​t​s​,​ ​a​m​o​n​g​ ​o​t​h​e​r​ ​o​p​t​i​o​n​s​. - */ - advancedOptions: string - /** - * A​ ​u​n​i​q​u​e​ ​n​a​m​e​ ​f​o​r​ ​y​o​u​r​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​ ​t​o​ ​i​d​e​n​t​i​f​y​ ​i​t​ ​e​a​s​i​l​y​. - */ - name: string - /** - * T​h​e​ ​p​u​b​l​i​c​ ​k​e​y​ ​a​s​s​o​c​i​a​t​e​d​ ​w​i​t​h​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​ ​f​o​r​ ​s​e​c​u​r​e​ ​c​o​m​m​u​n​i​c​a​t​i​o​n​. - */ - pubkey: string - /** - * T​h​e​ ​p​r​i​v​a​t​e​ ​k​e​y​ ​a​s​s​o​c​i​a​t​e​d​ ​w​i​t​h​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​ ​f​o​r​ ​s​e​c​u​r​e​ ​c​o​m​m​u​n​i​c​a​t​i​o​n​. - */ - prvkey: string - /** - * T​h​e​ ​I​P​ ​a​d​d​r​e​s​s​ ​a​s​s​i​g​n​e​d​ ​t​o​ ​t​h​i​s​ ​W​i​r​e​G​u​a​r​d​ ​c​l​i​e​n​t​ ​w​i​t​h​i​n​ ​t​h​e​ ​V​P​N​ ​n​e​t​w​o​r​k​. - */ - address: string - /** - * T​h​e​ ​p​u​b​l​i​c​ ​k​e​y​ ​o​f​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​s​e​r​v​e​r​ ​f​o​r​ ​s​e​c​u​r​e​ ​c​o​m​m​u​n​i​c​a​t​i​o​n​. - */ - serverPubkey: string - /** - * O​p​t​i​o​n​a​l​ ​s​y​m​m​e​t​r​i​c​ ​s​e​c​r​e​t​ ​k​e​y​ ​f​o​r​ ​e​n​h​a​n​c​e​d​ ​s​e​c​u​r​i​t​y​. - */ - presharedKey: string - /** - * A​ ​c​o​m​m​a​-​s​e​p​a​r​a​t​e​d​ ​l​i​s​t​ ​o​f​ ​I​P​ ​a​d​d​r​e​s​s​e​s​ ​o​r​ ​C​I​D​R​ ​r​a​n​g​e​s​ ​t​h​a​t​ ​a​r​e​ ​a​l​l​o​w​e​d​ ​f​o​r​ ​c​o​m​m​u​n​i​c​a​t​i​o​n​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​t​u​n​n​e​l​. - */ - allowedIps: string - /** - * T​h​e​ ​a​d​d​r​e​s​s​ ​a​n​d​ ​p​o​r​t​ ​o​f​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​s​e​r​v​e​r​,​ ​t​y​p​i​c​a​l​l​y​ ​i​n​ ​t​h​e​ ​f​o​r​m​a​t​ ​"​h​o​s​t​n​a​m​e​:​p​o​r​t​"​. - */ - endpoint: string - /** - * T​h​e​ ​D​N​S​ ​(​D​o​m​a​i​n​ ​N​a​m​e​ ​S​y​s​t​e​m​)​ ​s​e​r​v​e​r​ ​t​h​a​t​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​ ​s​h​o​u​l​d​ ​u​s​e​ ​f​o​r​ ​n​a​m​e​ ​r​e​s​o​l​u​t​i​o​n​.​ ​R​i​g​h​t​ ​n​o​w​ ​w​e​ ​o​n​l​y​ ​s​u​p​p​o​r​t​ ​D​N​S​ ​s​e​r​v​e​r​ ​I​P​,​ ​i​n​ ​t​h​e​ ​f​e​a​t​u​r​e​ ​w​e​ ​w​i​l​l​ ​s​u​p​p​o​r​t​ ​d​o​m​a​i​n​ ​s​e​a​r​c​h​. - */ - dns: string - /** - * T​h​e​ ​i​n​t​e​r​v​a​l​ ​(​i​n​ ​s​e​c​o​n​d​s​)​ ​f​o​r​ ​s​e​n​d​i​n​g​ ​p​e​r​i​o​d​i​c​ ​k​e​e​p​-​a​l​i​v​e​ ​m​e​s​s​a​g​e​s​ ​t​o​ ​e​n​s​u​r​e​ ​t​h​e​ ​t​u​n​n​e​l​ ​s​t​a​y​s​ ​a​c​t​i​v​e​.​ ​A​d​j​u​s​t​ ​a​s​ ​n​e​e​d​e​d​. - */ - persistentKeepAlive: string - /** - * I​f​ ​e​n​a​b​l​e​d​,​ ​a​l​l​ ​n​e​t​w​o​r​k​ ​t​r​a​f​f​i​c​ ​w​i​l​l​ ​b​e​ ​r​o​u​t​e​d​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​. - */ - routeAllTraffic: string - /** - * S​h​e​l​l​ ​c​o​m​m​a​n​d​s​ ​o​r​ ​s​c​r​i​p​t​s​ ​t​o​ ​b​e​ ​e​x​e​c​u​t​e​d​ ​b​e​f​o​r​e​ ​b​r​i​n​g​i​n​g​ ​u​p​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​. - */ - preUp: string - /** - * S​h​e​l​l​ ​c​o​m​m​a​n​d​s​ ​o​r​ ​s​c​r​i​p​t​s​ ​t​o​ ​b​e​ ​e​x​e​c​u​t​e​d​ ​a​f​t​e​r​ ​b​r​i​n​g​i​n​g​ ​u​p​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​. - */ - postUp: string - /** - * S​h​e​l​l​ ​c​o​m​m​a​n​d​s​ ​o​r​ ​s​c​r​i​p​t​s​ ​t​o​ ​b​e​ ​e​x​e​c​u​t​e​d​ ​b​e​f​o​r​e​ ​t​e​a​r​i​n​g​ ​d​o​w​n​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​. - */ - preDown: string - /** - * S​h​e​l​l​ ​c​o​m​m​a​n​d​s​ ​o​r​ ​s​c​r​i​p​t​s​ ​t​o​ ​b​e​ ​e​x​e​c​u​t​e​d​ ​a​f​t​e​r​ ​t​e​a​r​i​n​g​ ​d​o​w​n​ ​t​h​e​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l​. - */ - postDown: string - } - /** - * A​d​d​ ​T​u​n​n​e​l - */ - submit: string - messages: { - /** - * E​r​r​o​r​ ​p​a​r​s​i​n​g​ ​c​o​n​f​i​g​ ​f​i​l​e - */ - configError: string - /** - * T​u​n​n​e​l​ ​a​d​d​e​d - */ - addSuccess: string - /** - * C​r​e​a​t​i​n​g​ ​t​u​n​n​e​l​ ​f​a​i​l​e​d - */ - addError: string - } - controls: { - /** - * I​m​p​o​r​t​ ​C​o​n​f​i​g​ ​F​i​l​e - */ - importConfig: string - /** - * G​e​n​e​r​a​t​e​ ​P​r​i​v​a​t​e​ ​K​e​y - */ - generatePrvkey: string - } - } - } - guide: { - /** - * A​d​d​i​n​g​ ​W​i​r​e​G​u​a​r​d​ ​t​u​n​n​e​l - */ - title: string - /** - * <​p​>​T​o​ ​e​s​t​a​b​l​i​s​h​ ​s​e​c​u​r​e​ ​c​o​m​m​u​n​i​c​a​t​i​o​n​ ​b​e​t​w​e​e​n​ ​t​w​o​ ​o​r​ ​m​o​r​e​ ​d​e​v​i​c​e​s​ ​o​v​e​r​ ​t​h​e​ ​i​n​t​e​r​n​e​t​ ​c​r​e​a​t​e​ ​a​ ​v​i​r​t​u​a​l​ ​p​r​i​v​a​t​e​ ​n​e​t​w​o​r​k​ ​b​y​ ​c​o​n​f​i​g​u​r​i​n​g​ ​y​o​u​r​ ​t​u​n​n​e​l​.​<​/​p​>​<​p​>​I​f​ ​y​o​u​ ​d​o​n​’​t​ ​s​e​e​ ​o​p​t​i​o​n​s​ ​l​i​k​e​ ​T​a​b​l​e​ ​o​r​ ​M​T​U​ ​i​t​ ​m​e​a​n​s​ ​w​e​ ​d​o​ ​n​o​t​ ​s​u​p​p​o​r​t​ ​i​t​ ​f​o​r​ ​n​o​w​,​ ​b​u​t​ ​w​i​l​l​ ​b​e​ ​a​d​d​e​d​ ​l​a​t​e​r​.​<​/​p​> - */ - subTitle: string - card: { - /** - * S​e​t​t​i​n​g​ ​U​p​ ​A​ ​n​e​w​ ​T​u​n​n​e​l​: - */ - title: string - /** - * - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​1​.​ ​I​m​p​o​r​t​ ​C​o​n​f​i​g​u​r​a​t​i​o​n​ ​F​i​l​e​<​/​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​d​i​v​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​u​l​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​l​i​>​ ​C​l​i​c​k​ ​o​n​ ​t​h​e​ ​"​I​m​p​o​r​t​ ​C​o​n​f​i​g​ ​F​i​l​e​"​ ​b​u​t​t​o​n​.​<​/​l​i​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​l​i​>​ ​N​a​v​i​g​a​t​e​ ​t​o​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​i​l​e​ ​u​s​i​n​g​ ​t​h​e​ ​f​i​l​e​ ​s​e​l​e​c​t​i​o​n​ ​d​i​a​l​o​g​.​<​/​l​i​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​l​i​>​ ​S​e​l​e​c​t​ ​t​h​e​ ​.​c​o​n​f​ ​f​i​l​e​ ​y​o​u​ ​r​e​c​e​i​v​e​d​ ​o​r​ ​c​r​e​a​t​e​d​.​<​/​l​i​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​u​l​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​d​i​v​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​2​.​ ​O​r​ ​F​i​l​l​ ​i​n​ ​F​o​r​m​ ​o​n​ ​t​h​e​ ​L​e​f​t​<​/​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​d​i​v​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​u​l​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​l​i​>​ ​E​n​t​e​r​ ​a​ ​n​a​m​e​ ​f​o​r​ ​t​h​e​ ​t​u​n​n​e​l​.​<​/​l​i​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​l​i​>​ ​P​r​o​v​i​d​e​ ​e​s​s​e​n​t​i​a​l​ ​d​e​t​a​i​l​s​ ​s​u​c​h​ ​a​s​ ​t​h​e​ ​p​r​i​v​a​t​e​ ​k​e​y​,​ ​p​u​b​l​i​c​ ​k​e​y​,​ ​a​n​d​ ​e​n​d​p​o​i​n​t​ ​(​s​e​r​v​e​r​ ​a​d​d​r​e​s​s​)​.​<​/​l​i​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​u​l​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​d​i​v​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​F​o​r​ ​m​o​r​e​ ​h​e​l​p​,​ ​p​l​e​a​s​e​ ​v​i​s​i​t​ ​d​e​f​g​u​a​r​d​ ​h​e​l​p​ ​(​h​t​t​p​s​:​/​/​d​o​c​s​.​d​e​f​g​u​a​r​d​.​n​e​t​)​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ - */ - content: string - } - } - } - addInstancePage: { - /** - * A​d​d​ ​I​n​s​t​a​n​c​e - */ - title: string - forms: { - initInstance: { - /** - * P​l​e​a​s​e​ ​p​r​o​v​i​d​e​ ​I​n​s​t​a​n​c​e​ ​U​R​L​ ​a​n​d​ ​t​o​k​e​n - */ - title: string - labels: { - /** - * I​n​s​t​a​n​c​e​ ​U​R​L - */ - url: string - /** - * T​o​k​e​n - */ - token: string - } - /** - * A​d​d​ ​I​n​s​t​a​n​c​e - */ - submit: string - } - device: { - /** - * N​a​m​e​ ​t​h​i​s​ ​d​e​v​i​c​e - */ - title: string - labels: { - /** - * N​a​m​e - */ - name: string - } - /** - * F​i​n​i​s​h - */ - submit: string - messages: { - /** - * D​e​v​i​c​e​ ​a​d​d​e​d - */ - addSuccess: string - } - } - } - guide: { - /** - * A​d​d​i​n​g​ ​I​n​s​t​a​n​c​e​s​ ​a​n​d​ ​c​o​n​n​e​c​t​i​n​g​ ​t​o​ ​V​P​N​ ​l​o​c​a​t​i​o​n​s - */ - title: string - /** - * I​n​ ​o​r​d​e​r​ ​t​o​ ​a​c​t​i​v​a​t​e​ ​t​h​i​s​ ​d​e​v​i​c​e​ ​a​n​d​ ​a​c​c​e​s​s​ ​a​l​l​ ​V​P​N​ ​l​o​c​a​t​i​o​n​s​,​ ​y​o​u​ ​m​u​s​t​ ​p​r​o​v​i​d​e​ ​t​h​e​ ​U​R​L​ ​t​o​ ​y​o​u​r​ ​d​e​f​g​u​a​r​d​ ​i​n​s​t​a​n​c​e​ ​a​n​d​ ​e​n​t​e​r​ ​t​h​e​ ​a​c​t​i​v​a​t​i​o​n​ ​t​o​k​e​n​. - */ - subTitle: string - card: { - /** - * Y​o​u​ ​c​a​n​ ​o​b​t​a​i​n​ ​t​h​e​ ​t​o​k​e​n​ ​b​y - */ - title: string - /** - * - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​1​.​ ​I​n​v​o​k​i​n​g​ ​R​e​m​o​t​e​ ​D​e​s​k​t​o​p​ ​a​c​t​i​v​a​t​i​o​n​ ​p​r​o​c​e​s​s​ ​y​o​u​r​s​e​l​f​<​/​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​d​i​v​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​I​f​ ​y​o​u​ ​h​a​v​e​ ​a​c​c​e​s​s​ ​t​o​ ​y​o​u​r​ ​d​e​f​g​u​a​r​d​ ​i​n​s​t​a​n​c​e​ ​(​e​i​t​h​e​r​ ​y​o​u​ ​a​r​e​ ​a​t​ ​h​o​m​e​/​o​f​f​i​c​e​ ​w​h​e​r​e​ ​d​e​f​g​u​a​r​d​ ​i​s​ ​a​c​c​e​s​s​i​b​l​e​)​,​ ​g​o​ ​t​o​ ​d​e​f​g​u​a​r​d​ ​-​>​ ​y​o​u​r​ ​p​r​o​f​i​l​e​ ​-​>​ ​"​A​d​d​ ​d​e​v​i​c​e​"​ ​a​n​d​ ​c​h​o​o​s​e​:​ ​A​c​t​i​v​a​t​e​ ​D​e​f​g​u​a​r​d​ ​C​l​i​e​n​t​.​ ​T​h​e​n​ ​s​e​l​e​c​t​ ​i​f​ ​y​o​u​ ​w​i​s​h​ ​t​o​ ​h​a​v​e​ ​t​h​e​ ​t​o​k​e​n​ ​s​e​n​t​ ​t​o​ ​y​o​u​ ​b​y​ ​e​m​a​i​l​ ​o​r​ ​j​u​s​t​ ​c​o​p​y​ ​i​t​ ​f​r​o​m​ ​d​e​f​g​u​a​r​d​.​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​d​i​v​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​2​.​ ​A​c​t​i​v​a​t​i​n​g​ ​r​e​m​o​t​e​l​y​ ​b​y​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​<​/​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​d​i​v​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​I​f​ ​y​o​u​ ​d​o​ ​n​o​t​ ​h​a​v​e​ ​a​c​c​e​s​s​ ​t​o​ ​d​e​f​g​u​a​r​d​ ​-​ ​p​l​e​a​s​e​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​(​i​n​ ​y​o​u​r​ ​o​n​b​o​a​r​d​i​n​g​ ​m​e​s​s​a​g​e​/​e​m​a​i​l​ ​t​h​e​r​e​ ​w​e​r​e​ ​t​h​e​ ​a​d​m​i​n​ ​c​o​n​t​a​c​t​ ​d​e​t​a​i​l​s​)​ ​a​n​d​ ​a​s​k​ ​f​o​r​ ​R​e​m​o​t​e​ ​d​e​s​k​t​o​p​ ​a​c​t​i​v​a​t​i​o​n​ ​-​ ​b​e​s​t​ ​t​o​ ​s​e​n​d​ ​y​o​u​ ​t​h​e​ ​a​c​t​i​v​a​t​i​o​n​ ​e​m​a​i​l​,​ ​f​r​o​m​ ​w​h​i​c​h​ ​y​o​u​ ​c​a​n​ ​c​o​p​y​ ​t​h​e​ ​i​n​s​t​a​n​c​e​ ​U​R​L​ ​&​ ​t​o​k​e​n​.​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​d​i​v​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​F​o​r​ ​m​o​r​e​ ​h​e​l​p​,​ ​p​l​e​a​s​e​ ​v​i​s​i​t​ ​d​e​f​g​u​a​r​d​ ​h​e​l​p​ ​(​h​t​t​p​s​:​/​/​d​o​c​s​.​d​e​f​g​u​a​r​d​.​n​e​t​)​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ - */ - content: string - } - } - } - } - sideBar: { - /** - * d​e​f​g​u​a​r​d​ ​I​n​s​t​a​n​c​e​s - */ - instances: string - /** - * A​d​d​ ​I​n​s​t​a​n​c​e - */ - addInstance: string - /** - * A​d​d​ ​T​u​n​n​e​l - */ - addTunnel: string - /** - * W​i​r​e​G​u​a​r​d​ ​T​u​n​n​e​l​s - */ - tunnels: string - /** - * S​e​t​t​i​n​g​s - */ - settings: string - copyright: { - /** - * C​o​p​y​r​i​g​h​t​ ​©​ ​2​0​2​3 - */ - copyright: string - /** - * A​p​p​l​i​c​a​t​i​o​n​ ​v​e​r​s​i​o​n​:​ ​{​v​e​r​s​i​o​n​} - * @param {string} version - */ - appVersion: RequiredParams<'version'> - } - /** - * A​p​p​l​i​c​a​t​i​o​n​ ​v​e​r​s​i​o​n​:​ - */ - applicationVersion: string - } - newApplicationVersion: { - /** - * N​e​w​ ​v​e​r​s​i​o​n​ ​a​v​a​i​l​a​b​l​e - */ - header: string - /** - * D​i​s​m​i​s​s - */ - dismiss: string - /** - * S​e​e​ ​w​h​a​t​'​s​ ​n​e​w - */ - releaseNotes: string - } - } - enrollment: { - sideBar: { - /** - * E​n​r​o​l​l​m​e​n​t - */ - title: string - steps: { - /** - * W​e​l​c​o​m​e - */ - welcome: string - /** - * D​a​t​a​ ​v​e​r​i​f​i​c​a​t​i​o​n - */ - verification: string - /** - * C​r​e​a​t​e​ ​p​a​s​s​w​o​r​d - */ - password: string - /** - * C​o​n​f​i​g​u​r​e​ ​V​P​N - */ - vpn: string - /** - * F​i​n​i​s​h - */ - finish: string - /** - * C​o​n​f​i​g​u​r​e​ ​M​F​A - */ - mfa: string - /** - * C​h​o​o​s​e​ ​m​e​t​h​o​d - */ - mfaChoice: string - /** - * C​o​m​p​l​e​t​e​ ​m​e​t​h​o​d - */ - mfaSetup: string - /** - * R​e​c​o​v​e​r​y​ ​c​o​d​e​s - */ - mfaRecovery: string - } - /** - * A​p​p​l​i​c​a​t​i​o​n​ ​v​e​r​s​i​o​n - */ - appVersion: string - } - stepsIndicator: { - /** - * S​t​e​p - */ - step: string - /** - * o​f - */ - of: string - } - /** - * T​i​m​e​ ​l​e​f​t - */ - timeLeft: string - steps: { - welcome: { - /** - * H​e​l​l​o​,​ ​{​n​a​m​e​} - * @param {string} name - */ - title: RequiredParams<'name'> - /** - * - ​I​n​ ​o​r​d​e​r​ ​t​o​ ​g​a​i​n​ ​a​c​c​e​s​s​ ​t​o​ ​t​h​e​ ​c​o​m​p​a​n​y​ ​i​n​f​r​a​s​t​r​u​c​t​u​r​e​,​ ​w​e​ ​r​e​q​u​i​r​e​ ​y​o​u​ ​t​o​ ​c​o​m​p​l​e​t​e​ ​t​h​i​s​ ​e​n​r​o​l​l​m​e​n​t​ ​p​r​o​c​e​s​s​.​ ​D​u​r​i​n​g​ ​t​h​i​s​ ​p​r​o​c​e​s​s​,​ ​y​o​u​ ​w​i​l​l​ ​n​e​e​d​ ​t​o​:​ - ​ - ​1​.​ ​V​e​r​i​f​y​ ​y​o​u​r​ ​d​a​t​a​ - ​2​.​ ​C​r​e​a​t​e​ ​y​o​u​r​ ​p​a​s​s​w​o​r​d​ - ​3​.​ ​C​o​n​f​i​g​u​r​e​ ​V​P​N​ ​d​e​v​i​c​e​ - ​ - ​Y​o​u​ ​h​a​v​e​ ​a​ ​t​i​m​e​ ​l​i​m​i​t​ ​o​f​ ​*​*​{​t​i​m​e​}​ ​m​i​n​u​t​e​s​*​*​ ​t​o​ ​c​o​m​p​l​e​t​e​ ​t​h​i​s​ ​p​r​o​c​e​s​s​.​ - ​I​f​ ​y​o​u​ ​h​a​v​e​ ​a​n​y​ ​q​u​e​s​t​i​o​n​s​,​ ​p​l​e​a​s​e​ ​c​o​n​s​u​l​t​ ​y​o​u​r​ ​a​s​s​i​g​n​e​d​ ​a​d​m​i​n​.​A​l​l​ ​n​e​c​e​s​s​a​r​y​ ​i​n​f​o​r​m​a​t​i​o​n​ ​c​a​n​ ​b​e​ ​f​o​u​n​d​ ​a​t​ ​t​h​e​ ​b​o​t​t​o​m​ ​o​f​ ​t​h​e​ ​s​i​d​e​b​a​r​. - * @param {string} time - */ - explanation: RequiredParams<'time'> - } - dataVerification: { - /** - * D​a​t​a​ ​v​e​r​i​f​i​c​a​t​i​o​n - */ - title: string - /** - * P​l​e​a​s​e​,​ ​c​h​e​c​k​ ​y​o​u​r​ ​d​a​t​a​.​ ​I​f​ ​a​n​y​t​h​i​n​g​ ​i​s​ ​w​r​o​n​g​,​ ​n​o​t​i​f​y​ ​y​o​u​r​ ​a​d​m​i​n​ ​a​f​t​e​r​ ​y​o​u​ ​c​o​m​p​l​e​t​e​ ​t​h​e​ ​p​r​o​c​e​s​s​. - */ - messageBox: string - form: { - fields: { - firstName: { - /** - * N​a​m​e - */ - label: string - } - lastName: { - /** - * L​a​s​t​ ​n​a​m​e - */ - label: string - } - email: { - /** - * E​-​m​a​i​l - */ - label: string - } - phone: { - /** - * P​h​o​n​e​ ​n​u​m​b​e​r - */ - label: string - } - } - } - } - password: { - /** - * C​r​e​a​t​e​ ​p​a​s​s​w​o​r​d - */ - title: string - form: { - fields: { - password: { - /** - * C​r​e​a​t​e​ ​n​e​w​ ​p​a​s​s​w​o​r​d - */ - label: string - } - repeat: { - /** - * C​o​n​f​i​r​m​ ​n​e​w​ ​p​a​s​s​w​o​r​d - */ - label: string - errors: { - /** - * P​a​s​s​w​o​r​d​s​ ​a​r​e​n​'​t​ ​m​a​t​c​h​i​n​g - */ - matching: string - } - } - } - } - } - deviceSetup: { - desktopSetup: { - /** - * C​o​n​f​i​g​u​r​e​ ​t​h​i​s​ ​d​e​v​i​c​e - */ - title: string - controls: { - /** - * C​o​n​f​i​g​u​r​e​ ​d​e​v​i​c​e - */ - create: string - /** - * D​e​v​i​c​e​ ​i​s​ ​c​o​n​f​i​g​u​r​e​d - */ - success: string - } - messages: { - /** - * D​e​v​i​c​e​ ​i​s​ ​c​o​n​f​i​g​u​r​e​d - */ - deviceConfigured: string - } - } - /** - * *​ ​T​h​i​s​ ​s​t​e​p​ ​i​s​ ​O​P​T​I​O​N​A​L​.​ ​Y​o​u​ ​c​a​n​ ​s​k​i​p​ ​i​t​ ​i​f​ ​y​o​u​ ​w​i​s​h​.​ ​T​h​i​s​ ​c​a​n​ ​b​e​ ​c​o​n​f​i​g​u​r​e​d​ ​l​a​t​e​r​ ​i​n​ ​d​e​f​g​u​a​r​d​. - */ - optionalMessage: string - cards: { - device: { - /** - * C​o​n​f​i​g​u​r​e​ ​y​o​u​r​ ​d​e​v​i​c​e​ ​f​o​r​ ​V​P​N - */ - title: string - create: { - /** - * C​r​e​a​t​e​ ​C​o​n​f​i​g​u​r​a​t​i​o​n - */ - submit: string - /** - * P​l​e​a​s​e​ ​b​e​ ​a​d​v​i​s​e​d​ ​t​h​a​t​ ​y​o​u​ ​h​a​v​e​ ​t​o​ ​d​o​w​n​l​o​a​d​ ​t​h​e​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​n​o​w​,​ ​s​i​n​c​e​ ​w​e​ ​d​o​ ​n​o​t​ ​s​t​o​r​e​ ​y​o​u​r​ ​p​r​i​v​a​t​e​ ​k​e​y​.​ ​A​f​t​e​r​ ​t​h​i​s​ ​d​i​a​l​o​g​ ​i​s​ ​c​l​o​s​e​d​,​ ​y​o​u​ ​w​i​l​l​ ​n​o​t​ ​b​e​ ​a​b​l​e​ ​t​o​ ​g​e​t​ ​y​o​u​r​ ​f​u​l​l​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​i​l​e​ ​(​w​i​t​h​ ​p​r​i​v​a​t​e​ ​k​e​y​s​,​ ​o​n​l​y​ ​b​l​a​n​k​ ​t​e​m​p​l​a​t​e​)​. - */ - messageBox: string - form: { - fields: { - name: { - /** - * D​e​v​i​c​e​ ​N​a​m​e - */ - label: string - } - 'public': { - /** - * M​y​ ​P​u​b​l​i​c​ ​K​e​y - */ - label: string - } - toggle: { - /** - * G​e​n​e​r​a​t​e​ ​k​e​y​ ​p​a​i​r - */ - generate: string - /** - * U​s​e​ ​m​y​ ​o​w​n​ ​p​u​b​l​i​c​ ​k​e​y - */ - own: string - } - } - } - } - config: { - messageBox: { - /** - * - ​ ​ ​ ​ ​ ​ ​ ​<​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​P​l​e​a​s​e​ ​b​e​ ​a​d​v​i​s​e​d​ ​t​h​a​t​ ​y​o​u​ ​h​a​v​e​ ​t​o​ ​d​o​w​n​l​o​a​d​ ​t​h​e​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​n​o​w​,​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​s​i​n​c​e​ ​<​s​t​r​o​n​g​>​w​e​ ​d​o​ ​n​o​t​<​/​s​t​r​o​n​g​>​ ​s​t​o​r​e​ ​y​o​u​r​ ​p​r​i​v​a​t​e​ ​k​e​y​.​ ​A​f​t​e​r​ ​t​h​i​s​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​d​i​a​l​o​g​ ​i​s​ ​c​l​o​s​e​d​,​ ​y​o​u​ ​<​s​t​r​o​n​g​>​w​i​l​l​ ​n​o​t​ ​b​e​ ​a​b​l​e​<​/​s​t​r​o​n​g​>​ ​t​o​ ​g​e​t​ ​y​o​u​r​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​f​u​l​l​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​i​l​e​ ​(​w​i​t​h​ ​p​r​i​v​a​t​e​ ​k​e​y​s​,​ ​o​n​l​y​ ​b​l​a​n​k​ ​t​e​m​p​l​a​t​e​)​.​ - ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​p​>​ - - */ - auto: string - /** - * - ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​P​l​e​a​s​e​ ​b​e​ ​a​d​v​i​s​e​d​ ​t​h​a​t​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​p​r​o​v​i​d​e​d​ ​h​e​r​e​ ​<​s​t​r​o​n​g​>​ ​d​o​e​s​ ​n​o​t​ ​i​n​c​l​u​d​e​ ​p​r​i​v​a​t​e​ ​k​e​y​ ​a​n​d​ ​u​s​e​s​ ​p​u​b​l​i​c​ ​k​e​y​ ​t​o​ ​f​i​l​l​ ​i​t​'​s​ ​p​l​a​c​e​ ​<​/​s​t​r​o​n​g​>​ ​y​o​u​ ​w​i​l​l​ ​n​e​e​d​ ​t​o​ ​r​e​p​l​a​c​e​ ​i​t​ ​o​n​ ​y​o​u​r​ ​o​w​n​ ​f​o​r​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​t​o​ ​w​o​r​k​ ​p​r​o​p​e​r​l​y​.​ - ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​p​>​ - - */ - manual: string - } - /** - * M​y​ ​D​e​v​i​c​e​ ​N​a​m​e - */ - deviceNameLabel: string - /** - * U​s​e​ ​p​r​o​v​i​d​e​d​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​i​l​e​ ​b​e​l​o​w​ ​b​y​ ​s​c​a​n​n​i​n​g​ ​Q​R​ ​C​o​d​e​ ​o​r​ ​i​m​p​o​r​t​i​n​g​ ​i​t​ ​a​s​ ​f​i​l​e​ ​o​n​ ​y​o​u​r​ ​d​e​v​i​c​e​s​ ​W​i​r​e​G​u​a​r​d​ ​a​p​p​. - */ - cardTitle: string - card: { - /** - * C​o​n​f​i​g​ ​f​i​l​e​ ​f​o​r​ ​l​o​c​a​t​i​o​n - */ - selectLabel: string - } - } - } - guide: { - /** - * Q​u​i​c​k​ ​G​u​i​d​e - */ - title: string - /** - * T​h​i​s​ ​q​u​i​c​k​ ​g​u​i​d​e​ ​w​i​l​l​ ​h​e​l​p​ ​y​o​u​ ​w​i​t​h​ ​d​e​v​i​c​e​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​. - */ - messageBox: string - /** - * S​t​e​p​ ​{​s​t​e​p​}​: - * @param {number} step - */ - step: RequiredParams<'step'> - steps: { - wireguard: { - /** - * D​o​w​n​l​o​a​d​ ​a​n​d​ ​i​n​s​t​a​l​l​ ​W​i​r​e​G​u​a​r​d​ ​c​l​i​e​n​t​ ​o​n​ ​y​o​u​r​ ​c​o​m​p​u​t​e​r​ ​o​r​ ​a​p​p​ ​o​n​ ​p​h​o​n​e​. - */ - content: string - /** - * D​o​w​n​l​o​a​d​ ​W​i​r​e​G​u​a​r​d - */ - button: string - } - /** - * D​o​w​n​l​o​a​d​ ​p​r​o​v​i​d​e​d​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​i​l​e​ ​t​o​ ​y​o​u​r​ ​d​e​v​i​c​e​. - */ - downloadConfig: string - /** - * O​p​e​n​ ​W​i​r​e​G​u​a​r​d​ ​a​n​d​ ​s​e​l​e​c​t​ ​"​A​d​d​ ​T​u​n​n​e​l​"​ ​(​I​m​p​o​r​t​ ​t​u​n​n​e​l​(​s​)​ ​f​r​o​m​ ​f​i​l​e​)​.​ ​F​i​n​d​ ​y​o​u​r​ - ​D​e​f​g​u​a​r​d​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​i​l​e​ ​a​n​d​ ​h​i​t​ ​"​O​K​"​.​ ​O​n​ ​p​h​o​n​e​ ​u​s​e​ ​W​i​r​e​G​u​a​r​d​ ​a​p​p​ ​“​+​”​ ​i​c​o​n​ ​a​n​d​ ​s​c​a​n​ ​Q​R​ ​c​o​d​e​. - */ - addTunnel: string - /** - * S​e​l​e​c​t​ ​y​o​u​r​ ​t​u​n​n​e​l​ ​f​r​o​m​ ​t​h​e​ ​l​i​s​t​ ​a​n​d​ ​p​r​e​s​s​ ​"​a​c​t​i​v​a​t​e​"​. - */ - activate: string - /** - * - ​*​*​G​r​e​a​t​ ​w​o​r​k​ ​-​ ​y​o​u​r​ ​D​e​f​g​u​a​r​d​ ​V​P​N​ ​i​s​ ​n​o​w​ ​a​c​t​i​v​e​!​*​*​ - ​ - ​I​f​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​i​s​e​n​g​a​g​e​ ​y​o​u​r​ ​V​P​N​ ​c​o​n​n​e​c​t​i​o​n​,​ ​s​i​m​p​l​y​ ​p​r​e​s​s​ ​"​d​e​a​c​t​i​v​a​t​e​"​.​ - - */ - finish: string - } - } - } - } - finish: { - /** - * C​o​n​f​i​g​u​r​a​t​i​o​n​ ​c​o​m​p​l​e​t​e​d​! - */ - title: string - } - } - } - sessionTimeout: { - card: { - /** - * S​e​s​s​i​o​n​ ​t​i​m​e​d​ ​o​u​t - */ - header: string - /** - * S​o​r​r​y​,​ ​y​o​u​ ​h​a​v​e​ ​e​x​c​e​e​d​e​d​ ​t​h​e​ ​t​i​m​e​ ​l​i​m​i​t​ ​t​o​ ​c​o​m​p​l​e​t​e​ ​t​h​e​ ​p​r​o​c​e​s​s​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​.​ ​I​f​ ​y​o​u​ ​n​e​e​d​ ​a​s​s​i​s​t​a​n​c​e​,​ ​p​l​e​a​s​e​ ​w​a​t​c​h​ ​o​u​r​ ​g​u​i​d​e​ ​o​r​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​. - */ - message: string - } - controls: { - /** - * E​n​t​e​r​ ​n​e​w​ ​t​o​k​e​n - */ - back: string - /** - * C​o​n​t​a​c​t​ ​a​d​m​i​n - */ - contact: string - } - } - token: { - card: { - /** - * P​l​e​a​s​e​,​ ​e​n​t​e​r​ ​y​o​u​r​ ​p​e​r​s​o​n​a​l​ ​e​n​r​o​l​l​m​e​n​t​ ​t​o​k​e​n - */ - title: string - messageBox: { - /** - * Y​o​u​ ​c​a​n​ ​f​i​n​d​ ​t​o​k​e​n​ ​i​n​ ​e​-​m​a​i​l​ ​m​e​s​s​a​g​e​ ​o​r​ ​u​s​e​ ​d​i​r​e​c​t​ ​l​i​n​k​. - */ - email: string - } - form: { - errors: { - token: { - /** - * T​o​k​e​n​ ​i​s​ ​r​e​q​u​i​r​e​d - */ - required: string - } - } - fields: { - token: { - /** - * T​o​k​e​n - */ - placeholder: string - } - } - controls: { - /** - * N​e​x​t - */ - submit: string - } - } - } - } - } - modals: { - updateInstance: { - /** - * U​p​d​a​t​e​ ​i​n​s​t​a​n​c​e - */ - title: string - /** - * E​n​t​e​r​ ​t​h​e​ ​t​o​k​e​n​ ​s​e​n​t​ ​b​y​ ​t​h​e​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​t​o​ ​u​p​d​a​t​e​ ​t​h​e​ ​I​n​s​t​a​n​c​e​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​.​ - ​A​l​t​e​r​n​a​t​i​v​e​l​y​,​ ​y​o​u​ ​c​a​n​ ​c​h​o​o​s​e​ ​t​o​ ​r​e​m​o​v​e​ ​t​h​i​s​ ​I​n​s​t​a​n​c​e​ ​e​n​t​i​r​e​l​y​ ​b​y​ ​c​l​i​c​k​i​n​g​ ​t​h​e​ ​'​R​e​m​o​v​e​ ​I​n​s​t​a​n​c​e​'​ ​b​u​t​t​o​n​ ​b​e​l​o​w​. - */ - infoMessage: string - form: { - fieldLabels: { - /** - * T​o​k​e​n - */ - token: string - /** - * U​R​L - */ - url: string - } - fieldErrors: { - token: { - /** - * T​o​k​e​n​ ​o​r​ ​U​R​L​ ​r​e​j​e​c​t​e​d​. - */ - rejected: string - /** - * I​n​s​t​a​n​c​e​ ​f​o​r​ ​t​h​i​s​ ​t​o​k​e​n​ ​w​a​s​ ​n​o​t​ ​f​o​u​n​d​. - */ - instanceIsNotPresent: string - } - } - } - controls: { - /** - * U​p​d​a​t​e​ ​I​n​s​t​a​n​c​e - */ - updateInstance: string - /** - * R​e​m​o​v​e​ ​I​n​s​t​a​n​c​e - */ - removeInstance: string - } - messages: { - /** - * {​n​a​m​e​}​ ​u​p​d​a​t​e​d​. - * @param {string} name - */ - success: RequiredParams<'name'> - /** - * T​o​k​e​n​ ​o​r​ ​U​R​L​ ​i​s​ ​i​n​v​a​l​i​d​. - */ - error: string - /** - * I​n​s​t​a​n​c​e​ ​f​o​r​ ​g​i​v​e​n​ ​t​o​k​e​n​ ​i​s​ ​n​o​t​ ​r​e​g​i​s​t​e​r​e​d​ ​! - */ - errorInstanceNotFound: string - } - } - deleteInstance: { - /** - * D​e​l​e​t​e​ ​i​n​s​t​a​n​c​e - */ - title: string - /** - * A​r​e​ ​y​o​u​ ​s​u​r​e​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​e​l​e​t​e​ ​{​n​a​m​e​}​? - * @param {string} name - */ - subtitle: RequiredParams<'name'> - messages: { - /** - * I​n​s​t​a​n​c​e​ ​d​e​l​e​t​e​d - */ - success: string - /** - * U​n​e​x​p​e​c​t​e​d​ ​e​r​r​o​r​ ​o​c​c​u​r​r​e​d - */ - error: string - } - controls: { - /** - * D​e​l​e​t​e​ ​i​n​s​t​a​n​c​e - */ - submit: string - } - } - deleteTunnel: { - /** - * D​e​l​e​t​e​ ​t​u​n​n​e​l - */ - title: string - /** - * A​r​e​ ​y​o​u​ ​s​u​r​e​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​e​l​e​t​e​ ​{​n​a​m​e​}​? - * @param {string} name - */ - subtitle: RequiredParams<'name'> - messages: { - /** - * T​u​n​n​e​l​ ​d​e​l​e​t​e​d - */ - success: string - /** - * U​n​e​x​p​e​c​t​e​d​ ​e​r​r​o​r​ ​o​c​c​u​r​r​e​d - */ - error: string - } - controls: { - /** - * D​e​l​e​t​e​ ​t​u​n​n​e​l - */ - submit: string - } - } - mfa: { - authentication: { - /** - * T​w​o​-​f​a​c​t​o​r​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n - */ - title: string - /** - * P​a​s​t​e​ ​t​h​e​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​c​o​d​e​ ​f​r​o​m​ ​y​o​u​r​ ​A​u​t​h​e​n​t​i​c​a​t​o​r​ ​A​p​p​l​i​c​a​t​i​o​n​. - */ - authenticatorAppDescription: string - /** - * P​a​s​t​e​ ​t​h​e​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​c​o​d​e​ ​t​h​a​t​ ​w​a​s​ ​s​e​n​t​ ​t​o​ ​y​o​u​r​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s​. - */ - emailCodeDescription: string - /** - * F​o​r​ ​t​h​i​s​ ​c​o​n​n​e​c​t​i​o​n​,​ ​t​w​o​-​f​a​c​t​o​r​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​(​2​F​A​)​ ​i​s​ ​m​a​n​d​a​t​o​r​y​. - */ - mfaStartDescriptionPrimary: string - /** - * S​e​l​e​c​t​ ​y​o​u​r​ ​p​r​e​f​e​r​r​e​d​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​m​e​t​h​o​d​. - */ - mfaStartDescriptionSecondary: string - /** - * U​s​e​ ​a​u​t​h​e​n​t​i​c​a​t​o​r​ ​a​p​p - */ - useAuthenticatorApp: string - /** - * U​s​e​ ​y​o​u​r​ ​e​m​a​i​l​ ​c​o​d​e - */ - useEmailCode: string - /** - * U​s​e​ ​t​h​i​s​ ​m​e​t​h​o​d​ ​f​o​r​ ​f​u​t​u​r​e​ ​l​o​g​i​n​s - */ - saveAuthenticationMethodForFutureLogins: string - /** - * V​e​r​i​f​y - */ - buttonSubmit: string - openidLogin: { - /** - * I​n​ ​o​r​d​e​r​ ​t​o​ ​c​o​n​n​e​c​t​ ​t​o​ ​t​h​e​ ​V​P​N​ ​p​l​e​a​s​e​ ​l​o​g​i​n​ ​w​i​t​h​ ​{​p​r​o​v​i​d​e​r​}​.​ ​T​o​ ​d​o​ ​s​o​,​ ​p​l​e​a​s​e​ ​c​l​i​c​k​ ​"​A​u​t​h​e​n​t​i​c​a​t​e​ ​w​i​t​h​ ​{​p​r​o​v​i​d​e​r​}​"​ ​b​u​t​t​o​n​ ​b​e​l​o​w​. - * @param {unknown} provider - */ - description: RequiredParams<'provider' | 'provider'> - /** - * *​*​T​h​i​s​ ​w​i​l​l​ ​o​p​e​n​ ​a​ ​n​e​w​ ​w​i​n​d​o​w​ ​i​n​ ​y​o​u​r​ ​W​e​b​ ​B​r​o​w​s​e​r​*​*​ ​a​n​d​ ​a​u​t​o​m​a​t​i​c​a​l​l​y​ ​r​e​d​i​r​e​c​t​ ​y​o​u​ ​t​o​ ​t​h​e​ ​{​p​r​o​v​i​d​e​r​}​ ​l​o​g​i​n​ ​p​a​g​e​.​ ​A​f​t​e​r​ ​a​u​t​h​e​n​t​i​c​a​t​i​n​g​ ​w​i​t​h​ ​{​p​r​o​v​i​d​e​r​}​ ​p​l​e​a​s​e​ ​g​e​t​ ​b​a​c​k​ ​h​e​r​e​. - * @param {unknown} provider - */ - browserWarning: RequiredParams<'provider' | 'provider'> - /** - * A​u​t​h​e​n​t​i​c​a​t​e​ ​w​i​t​h​ ​{​p​r​o​v​i​d​e​r​} - * @param {unknown} provider - */ - buttonText: RequiredParams<'provider'> - } - openidPending: { - /** - * W​a​i​t​i​n​g​ ​f​o​r​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​i​n​ ​y​o​u​r​ ​b​r​o​w​s​e​r​.​.​. - */ - description: string - /** - * T​r​y​ ​a​g​a​i​n - */ - tryAgain: string - /** - * T​h​e​r​e​ ​w​a​s​ ​a​n​ ​e​r​r​o​r​ ​d​u​r​i​n​g​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​.​ ​U​s​e​ ​t​h​e​ ​t​r​y​ ​a​g​a​i​n​ ​b​u​t​t​o​n​ ​b​e​l​o​w​ ​t​o​ ​r​e​t​r​y​ ​t​h​e​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​p​r​o​c​e​s​s​. - */ - errorDescription: string - } - openidUnavailable: { - /** - * T​h​e​ ​O​p​e​n​I​D​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​i​s​ ​c​u​r​r​e​n​t​l​y​ ​u​n​a​v​a​i​l​a​b​l​e​.​ ​T​h​i​s​ ​m​a​y​ ​b​e​ ​d​u​e​ ​t​o​ ​a​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​i​s​s​u​e​ ​o​r​ ​t​h​e​ ​D​e​f​g​u​a​r​d​ ​i​n​s​t​a​n​c​e​ ​i​s​ ​d​o​w​n​.​ ​P​l​e​a​s​e​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​o​r​ ​t​r​y​ ​a​g​a​i​n​ ​l​a​t​e​r​. - */ - description: string - /** - * T​r​y​ ​a​g​a​i​n - */ - tryAgain: string - } - errors: { - /** - * S​e​l​e​c​t​e​d​ ​m​e​t​h​o​d​ ​h​a​s​ ​n​o​t​ ​b​e​e​n​ ​c​o​n​f​i​g​u​r​e​d​. - */ - mfaNotConfigured: string - /** - * C​o​u​l​d​ ​n​o​t​ ​s​t​a​r​t​ ​M​F​A​ ​p​r​o​c​e​s​s​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​ ​o​r​ ​c​o​n​t​a​c​t​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​. - */ - mfaStartGeneric: string - /** - * C​o​u​l​d​ ​n​o​t​ ​f​i​n​i​s​h​ ​M​F​A​ ​p​r​o​c​e​s​s​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​ ​o​r​ ​c​o​n​t​a​c​t​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​. - */ - mfaFinishGeneric: string - /** - * C​o​u​l​d​ ​n​o​t​ ​f​i​n​d​ ​i​n​s​t​a​n​c​e​. - */ - instanceNotFound: string - /** - * L​o​c​a​t​i​o​n​ ​i​s​ ​n​o​t​ ​s​p​e​c​i​f​i​e​d​. - */ - locationNotSpecified: string - /** - * E​r​r​o​r​,​ ​t​h​i​s​ ​c​o​d​e​ ​i​s​ ​i​n​v​a​l​i​d​,​ ​t​r​y​ ​a​g​a​i​n​ ​o​r​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​. - */ - invalidCode: string - /** - * T​o​k​e​n​ ​h​a​s​ ​e​x​p​i​r​e​d​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​t​o​ ​c​o​n​n​e​c​t​ ​a​g​a​i​n​. - */ - tokenExpired: string - /** - * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​t​o​o​k​ ​t​o​o​ ​l​o​n​g​ ​a​n​d​ ​t​i​m​e​d​ ​o​u​t​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​c​o​n​n​e​c​t​i​n​g​ ​a​g​a​i​n​. - */ - authenticationTimeout: string - /** - * E​r​r​o​r​:​ ​Y​o​u​r​ ​l​o​g​i​n​ ​s​e​s​s​i​o​n​ ​m​i​g​h​t​ ​h​a​v​e​ ​b​e​e​n​ ​i​n​v​a​l​i​d​a​t​e​d​ ​o​r​ ​e​x​p​i​r​e​d​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​. - */ - sessionInvalidated: string - } - } - } - } -} - -export type TranslationFunctions = { - time: { - seconds: { - /** - * second - */ - singular: () => LocalizedString - /** - * seconds - */ - plural: () => LocalizedString - } - minutes: { - /** - * minute - */ - singular: () => LocalizedString - /** - * minutes - */ - plural: () => LocalizedString - } - } - form: { - errors: { - /** - * Field is invalid - */ - invalid: () => LocalizedString - /** - * Enter a valid E-mail - */ - email: () => LocalizedString - /** - * Field is required - */ - required: () => LocalizedString - /** - * Field requires minimal value of {min} - */ - minValue: (arg: { min: number }) => LocalizedString - /** - * Field cannot exceed maximal value of {max} - */ - maxValue: (arg: { max: number }) => LocalizedString - /** - * Field value must be above zero - */ - aboveZero: () => LocalizedString - /** - * Min length of {length} - */ - minLength: (arg: { length: number }) => LocalizedString - /** - * Max length of {length} - */ - maxLength: (arg: { length: number }) => LocalizedString - /** - * At least one special character - */ - specialsRequired: () => LocalizedString - /** - * Special characters are forbidden - */ - specialsForbidden: () => LocalizedString - /** - * At least one number required - */ - numberRequired: () => LocalizedString - password: { - /** - * Please correct the following: - */ - floatingTitle: () => LocalizedString - } - /** - * At least one lower case character - */ - oneLower: () => LocalizedString - /** - * At least one upper case character - */ - oneUpper: () => LocalizedString - /** - * Device with this name already exists - */ - duplicatedName: () => LocalizedString - } - } - common: { - controls: { - /** - * Back - */ - back: () => LocalizedString - /** - * Next - */ - next: () => LocalizedString - /** - * Submit - */ - submit: () => LocalizedString - /** - * Cancel - */ - cancel: () => LocalizedString - /** - * Close - */ - close: () => LocalizedString - /** - * Reset - */ - reset: () => LocalizedString - /** - * Save - */ - save: () => LocalizedString - } - messages: { - /** - * Unexpected error occurred! - */ - error: () => LocalizedString - /** - * An error occurred: {message} - */ - errorWithMessage: (arg: { message: unknown }) => LocalizedString - /** - * Token has expired, please contact your administrator to issue a new enrollment token - */ - tokenExpired: () => LocalizedString - /** - * There was a network error. Can't reach proxy. - */ - networkError: () => LocalizedString - /** - * Configuration for instance {instance} has changed. Disconnect from all locations to apply changes. - */ - configChanged: (arg: { instance: string }) => LocalizedString - /** - * Detected that the {con_type} {interface_name} has disconnected, trying to reconnect... - */ - deadConDropped: (arg: { con_type: string, interface_name: string }) => LocalizedString - /** - * No defguard_proxy set-cookie received - */ - noCookie: () => LocalizedString - /** - * Context is not secure. - */ - insecureContext: () => LocalizedString - clipboard: { - /** - * Clipboard is not accessible. - */ - error: () => LocalizedString - /** - * Content copied to clipboard. - */ - success: () => LocalizedString - } - /** - * Your Defguard instance "{instance_name}" version is not supported by your Defguard Client version. Defguard Core version: {core_version} (required: {core_required_version}), Defguard Proxy version: {proxy_version} (required: {proxy_required_version}). Please contact your administrator. - */ - versionMismatch: (arg: { core_required_version: string, core_version: string, instance_name: string, proxy_required_version: string, proxy_version: string }) => LocalizedString - /** - * The identifier (UUID) of the remote Defguard instance "{instance_name}" does not match the one stored locally. Because of this, some features may not work correctly. To resolve this issue, remove the instance and add it again, or contact your administrator. - */ - uuidMismatch: (arg: { instance_name: string }) => LocalizedString - } - } - components: { - adminInfo: { - /** - * Your admin - */ - title: () => LocalizedString - } - } - pages: { - client: { - modals: { - deadConDropped: { - /** - * {conType} {name} disconnected - */ - title: (arg: { conType: string, name: string }) => LocalizedString - /** - * Tunnel - */ - tunnel: () => LocalizedString - /** - * Location - */ - location: () => LocalizedString - /** - * The {conType} {name} has been disconnected, since we have detected that the server is not responding with any traffic for {time}s. If this message keeps occurring, please contact your administrator and inform them about this fact. - */ - message: (arg: { conType: string, name: string, time: number }) => LocalizedString - controls: { - /** - * Close - */ - close: () => LocalizedString - } - } - } - pages: { - carouselPage: { - slides: { - shared: { - /** - * **defguard** is all the above and more! - */ - isMore: () => LocalizedString - /** - * Visit defguard on - */ - githubButton: () => LocalizedString - } - welcome: { - /** - * Welcome to **defguard** desktop client! - */ - title: () => LocalizedString - instance: { - /** - * Add Instance - */ - title: () => LocalizedString - /** - * Establish a connection to defguard instance effortlessly by configuring it with a single token. - */ - subtitle: () => LocalizedString - } - tunnel: { - /** - * Add Tunnel - */ - title: () => LocalizedString - /** - * Utilize it as a WireGuard® Desktop Client with ease. Set up your own tunnel or import a configuration file. - */ - subtitle: () => LocalizedString - } - } - twoFa: { - /** - * WireGuard **2FA with defguard** - */ - title: () => LocalizedString - /** - * Since WireGuard protocol doesn't support 2FA/MFA - most (if not all) currently available WireGuard clients do not support real Multi-Factor Authentication/2FA - and use 2FA just as authorization to the "application" itself (and not WireGuard tunnel). - - If you would like to secure your WireGuard instance try **defguard** VPN & SSO server (which is also free & open source) to get real 2FA using WireGuard PSK keys and peers configuration by defguard gateway! - */ - sideText: () => LocalizedString - } - security: { - /** - * Security and Privacy **done right!** - */ - title: () => LocalizedString - /** - * * Privacy requires controlling your data, thus your user data (Identity, SSO) needs to be on-premise (on your servers) - * Securing your data and applications requires authentication and authorization (SSO) with Multi-Factor Authentication, and for highest security - MFA with Hardware Security Modules - * Accessing your data and applications securely and privately requires data encryption (HTTPS) and a secure tunnel between your device and the Internet to encrypt all traffic (VPN). - * To fully trust your SSO, VPN, it needs to be Open Source - */ - sideText: () => LocalizedString - } - instances: { - /** - * **Multiple** instance & locations - */ - title: () => LocalizedString - /** - * **defguard** (both server nad this client) support multiple instances (installations) and multiple Locations (VPN tunnels). - - If you are an admin/devops - all your customers (instances) and all their tunnels (locations) can be in one place! - */ - sideText: () => LocalizedString - } - support: { - /** - * **Support us** on Github - */ - title: () => LocalizedString - /** - * **defguard** is free and truly Open Source and our team has been working on it for several months. Please consider supporting us by: - */ - text: () => LocalizedString - /** - * staring us on - */ - githubText: () => LocalizedString - /** - * GitHub - */ - githubLink: () => LocalizedString - /** - * spreading the word about: - */ - spreadWordText: () => LocalizedString - /** - * defguard! - */ - defguard: () => LocalizedString - /** - * Reach out to our community via - */ - githubDiscussions: () => LocalizedString - /** - * Support Us! - */ - supportUs: () => LocalizedString - } - } - } - settingsPage: { - /** - * Settings - */ - title: () => LocalizedString - tabs: { - global: { - common: { - /** - * (seconds) - */ - value_in_seconds: () => LocalizedString - } - peer_alive: { - /** - * Session timeout - */ - title: () => LocalizedString - /** - * If active connection exceeds given time without making an handshake with the server. The connection will be considered invalid and disconnected automatically. - */ - helper: () => LocalizedString - } - mtu: { - /** - * MTU (Maximum Transmission Unit) - */ - title: () => LocalizedString - /** - * MTU sets the largest packet size sent through the network. Lowering it can improve connection stability in restrictive or unreliable ISP networks. The default value on most systems is 1500. Try lowering it to 1300-1400 if you encounter ISP-related issues. 0 = default. - */ - helper: () => LocalizedString - } - tray: { - /** - * System tray - */ - title: () => LocalizedString - /** - * Tray icon theme - */ - label: () => LocalizedString - options: { - /** - * Color - */ - color: () => LocalizedString - /** - * White - */ - white: () => LocalizedString - /** - * Black - */ - black: () => LocalizedString - /** - * Gray - */ - gray: () => LocalizedString - } - } - logging: { - /** - * Logging threshold - */ - title: () => LocalizedString - /** - * Change will take effect after client restart. - */ - warning: () => LocalizedString - options: { - /** - * Error - */ - error: () => LocalizedString - /** - * Info - */ - info: () => LocalizedString - /** - * Debug - */ - debug: () => LocalizedString - /** - * Trace - */ - trace: () => LocalizedString - } - } - globalLogs: { - logSources: { - /** - * Client - */ - client: () => LocalizedString - /** - * VPN - */ - vpn: () => LocalizedString - /** - * All - */ - all: () => LocalizedString - } - /** - * The source of the logs. Logs can come from the Defguard client or the VPN service/extension that manages VPN connections at the network level. - */ - logSourceHelper: () => LocalizedString - } - theme: { - /** - * Theme - */ - title: () => LocalizedString - options: { - /** - * Light - */ - light: () => LocalizedString - /** - * Dark - */ - dark: () => LocalizedString - } - } - versionUpdate: { - /** - * Updates - */ - title: () => LocalizedString - /** - * Check for updates - */ - checkboxTitle: () => LocalizedString - } - } - } - } - createdPage: { - tunnel: { - /** - * Your Tunnel Was Added Successfully - */ - title: () => LocalizedString - /** - * Your tunnel has been successfully added. You can now connect this device, check its status and view statistics using the menu in the left sidebar. - */ - content: () => LocalizedString - controls: { - /** - * Add Another Tunnel - */ - submit: () => LocalizedString - } - } - instance: { - /** - * Your Instance Was Added Successfully - */ - title: () => LocalizedString - /** - * Your instance has been successfully added. You can now connect this device, check its status and view statistics using the menu in the left sidebar. - */ - content: () => LocalizedString - controls: { - /** - * Add Another Instance - */ - submit: () => LocalizedString - } - } - } - instancePage: { - /** - * Locations - */ - title: () => LocalizedString - /** - * - Currently you do not have access to any VPN Locations. This may be temporary - your administration team maybe is configuring your access policies. - - If this will not change, please contact your administrator. - */ - noData: () => LocalizedString - controls: { - /** - * Connect - */ - connect: () => LocalizedString - /** - * Disconnect - */ - disconnect: () => LocalizedString - traffic: { - /** - * Predefined traffic - */ - predefinedTraffic: () => LocalizedString - /** - * All traffic - */ - allTraffic: () => LocalizedString - /** - * Allowed traffic - */ - label: () => LocalizedString - /** - * -

- Predefined traffic - route only traffic for networks defined by Admin through this VPN location
- All traffic - route ALL your network traffic through this VPN location -

- */ - helper: () => LocalizedString - } - } - header: { - /** - * Locations - */ - title: () => LocalizedString - /** - * Edit Instance - */ - edit: () => LocalizedString - filters: { - views: { - /** - * Grid View - */ - grid: () => LocalizedString - /** - * Detail View - */ - detail: () => LocalizedString - } - } - } - connectionLabels: { - /** - * Last connected from - */ - lastConnectedFrom: () => LocalizedString - /** - * Last connected - */ - lastConnected: () => LocalizedString - /** - * Connected from - */ - connectedFrom: () => LocalizedString - /** - * Assigned IP - */ - assignedIp: () => LocalizedString - /** - * Active - */ - active: () => LocalizedString - /** - * Never connected - */ - neverConnected: () => LocalizedString - } - locationNeverConnected: { - /** - * Never Connected - */ - title: () => LocalizedString - /** - * This device was never connected to this location, connect to view statistics and information about connection - */ - content: () => LocalizedString - } - LocationNoStats: { - /** - * No stats - */ - title: () => LocalizedString - /** - * This device has no stats for this location in specified time period. Connect to location and wait for client to gather statistics. - */ - content: () => LocalizedString - } - detailView: { - history: { - /** - * Connection history - */ - title: () => LocalizedString - headers: { - /** - * Date - */ - date: () => LocalizedString - /** - * Duration - */ - duration: () => LocalizedString - /** - * Connected from - */ - connectedFrom: () => LocalizedString - /** - * Upload - */ - upload: () => LocalizedString - /** - * Download - */ - download: () => LocalizedString - } - } - details: { - /** - * Details - */ - title: () => LocalizedString - logs: { - /** - * Log - */ - title: () => LocalizedString - } - info: { - configuration: { - /** - * Device configuration - */ - title: () => LocalizedString - /** - * Public key - */ - pubkey: () => LocalizedString - /** - * Addresses - */ - address: () => LocalizedString - /** - * Listen port - */ - listenPort: () => LocalizedString - } - vpn: { - /** - * VPN Server Configuration - */ - title: () => LocalizedString - /** - * Public key - */ - pubkey: () => LocalizedString - /** - * Server Address - */ - serverAddress: () => LocalizedString - /** - * Allowed IPs - */ - allowedIps: () => LocalizedString - /** - * DNS servers - */ - dns: () => LocalizedString - /** - * Persistent keepalive - */ - keepalive: () => LocalizedString - /** - * Latest Handshake - */ - handshake: () => LocalizedString - /** - * {seconds} seconds ago - */ - handshakeValue: (arg: { seconds: number }) => LocalizedString - } - } - } - } - } - tunnelPage: { - /** - * WireGuard Tunnels - */ - title: () => LocalizedString - header: { - /** - * Edit Tunnel - */ - edit: () => LocalizedString - } - } - editTunnelPage: { - /** - * Edit WireGuard® Tunnel - */ - title: () => LocalizedString - messages: { - /** - * Tunnel edited - */ - editSuccess: () => LocalizedString - /** - * Editing tunnel failed - */ - editError: () => LocalizedString - } - controls: { - /** - * Save changes - */ - save: () => LocalizedString - } - } - addTunnelPage: { - /** - * Add WireGuard® Tunnel - */ - title: () => LocalizedString - forms: { - initTunnel: { - /** - * Please provide Instance URL and token - */ - title: () => LocalizedString - sections: { - /** - * VPN Server - */ - vpnServer: () => LocalizedString - /** - * Advanced Options - */ - advancedOptions: () => LocalizedString - } - labels: { - /** - * Tunnel Name - */ - name: () => LocalizedString - /** - * Private Key - */ - privateKey: () => LocalizedString - /** - * Public Key - */ - publicKey: () => LocalizedString - /** - * Address - */ - address: () => LocalizedString - /** - * Public Key - */ - serverPubkey: () => LocalizedString - /** - * Pre-shared Key - */ - presharedKey: () => LocalizedString - /** - * VPN Server Address:Port - */ - endpoint: () => LocalizedString - /** - * DNS - */ - dns: () => LocalizedString - /** - * Allowed IPs (separate with comma) - */ - allowedips: () => LocalizedString - /** - * Persistent Keep Alive (sec) - */ - persistentKeepAlive: () => LocalizedString - /** - * PreUp - */ - preUp: () => LocalizedString - /** - * PostUp - */ - postUp: () => LocalizedString - /** - * PreDown - */ - PreDown: () => LocalizedString - /** - * PostDown - */ - PostDown: () => LocalizedString - } - helpers: { - /** - * Click the "Advanced Options" section to reveal additional settings for fine-tuning your WireGuard tunnel configuration. You can customize pre and post scripts, among other options. - */ - advancedOptions: () => LocalizedString - /** - * A unique name for your WireGuard tunnel to identify it easily. - */ - name: () => LocalizedString - /** - * The public key associated with the WireGuard tunnel for secure communication. - */ - pubkey: () => LocalizedString - /** - * The private key associated with the WireGuard tunnel for secure communication. - */ - prvkey: () => LocalizedString - /** - * The IP address assigned to this WireGuard client within the VPN network. - */ - address: () => LocalizedString - /** - * The public key of the WireGuard server for secure communication. - */ - serverPubkey: () => LocalizedString - /** - * Optional symmetric secret key for enhanced security. - */ - presharedKey: () => LocalizedString - /** - * A comma-separated list of IP addresses or CIDR ranges that are allowed for communication through the tunnel. - */ - allowedIps: () => LocalizedString - /** - * The address and port of the WireGuard server, typically in the format "hostname:port". - */ - endpoint: () => LocalizedString - /** - * The DNS (Domain Name System) server that the WireGuard tunnel should use for name resolution. Right now we only support DNS server IP, in the feature we will support domain search. - */ - dns: () => LocalizedString - /** - * The interval (in seconds) for sending periodic keep-alive messages to ensure the tunnel stays active. Adjust as needed. - */ - persistentKeepAlive: () => LocalizedString - /** - * If enabled, all network traffic will be routed through the WireGuard tunnel. - */ - routeAllTraffic: () => LocalizedString - /** - * Shell commands or scripts to be executed before bringing up the WireGuard tunnel. - */ - preUp: () => LocalizedString - /** - * Shell commands or scripts to be executed after bringing up the WireGuard tunnel. - */ - postUp: () => LocalizedString - /** - * Shell commands or scripts to be executed before tearing down the WireGuard tunnel. - */ - preDown: () => LocalizedString - /** - * Shell commands or scripts to be executed after tearing down the WireGuard tunnel. - */ - postDown: () => LocalizedString - } - /** - * Add Tunnel - */ - submit: () => LocalizedString - messages: { - /** - * Error parsing config file - */ - configError: () => LocalizedString - /** - * Tunnel added - */ - addSuccess: () => LocalizedString - /** - * Creating tunnel failed - */ - addError: () => LocalizedString - } - controls: { - /** - * Import Config File - */ - importConfig: () => LocalizedString - /** - * Generate Private Key - */ - generatePrvkey: () => LocalizedString - } - } - } - guide: { - /** - * Adding WireGuard tunnel - */ - title: () => LocalizedString - /** - *

To establish secure communication between two or more devices over the internet create a virtual private network by configuring your tunnel.

If you don’t see options like Table or MTU it means we do not support it for now, but will be added later.

- */ - subTitle: () => LocalizedString - card: { - /** - * Setting Up A new Tunnel: - */ - title: () => LocalizedString - /** - * -

1. Import Configuration File

-
-
    -
  • Click on the "Import Config File" button.
  • -
  • Navigate to configuration file using the file selection dialog.
  • -
  • Select the .conf file you received or created.
  • -
-
-

2. Or Fill in Form on the Left

-
-
    -
  • Enter a name for the tunnel.
  • -
  • Provide essential details such as the private key, public key, and endpoint (server address).
  • -
-
-

- For more help, please visit defguard help (https://docs.defguard.net) -

- - */ - content: () => LocalizedString - } - } - } - addInstancePage: { - /** - * Add Instance - */ - title: () => LocalizedString - forms: { - initInstance: { - /** - * Please provide Instance URL and token - */ - title: () => LocalizedString - labels: { - /** - * Instance URL - */ - url: () => LocalizedString - /** - * Token - */ - token: () => LocalizedString - } - /** - * Add Instance - */ - submit: () => LocalizedString - } - device: { - /** - * Name this device - */ - title: () => LocalizedString - labels: { - /** - * Name - */ - name: () => LocalizedString - } - /** - * Finish - */ - submit: () => LocalizedString - messages: { - /** - * Device added - */ - addSuccess: () => LocalizedString - } - } - } - guide: { - /** - * Adding Instances and connecting to VPN locations - */ - title: () => LocalizedString - /** - * In order to activate this device and access all VPN locations, you must provide the URL to your defguard instance and enter the activation token. - */ - subTitle: () => LocalizedString - card: { - /** - * You can obtain the token by - */ - title: () => LocalizedString - /** - * -

1. Invoking Remote Desktop activation process yourself

-
-

- If you have access to your defguard instance (either you are at home/office where defguard is accessible), go to defguard -> your profile -> "Add device" and choose: Activate Defguard Client. Then select if you wish to have the token sent to you by email or just copy it from defguard. -

-
-

2. Activating remotely by your administrator

-
-

- If you do not have access to defguard - please contact your administrator (in your onboarding message/email there were the admin contact details) and ask for Remote desktop activation - best to send you the activation email, from which you can copy the instance URL & token. -

-
-

- For more help, please visit defguard help (https://docs.defguard.net) -

- - */ - content: () => LocalizedString - } - } - } - } - sideBar: { - /** - * defguard Instances - */ - instances: () => LocalizedString - /** - * Add Instance - */ - addInstance: () => LocalizedString - /** - * Add Tunnel - */ - addTunnel: () => LocalizedString - /** - * WireGuard Tunnels - */ - tunnels: () => LocalizedString - /** - * Settings - */ - settings: () => LocalizedString - copyright: { - /** - * Copyright © 2023 - */ - copyright: () => LocalizedString - /** - * Application version: {version} - */ - appVersion: (arg: { version: string }) => LocalizedString - } - /** - * Application version: - */ - applicationVersion: () => LocalizedString - } - newApplicationVersion: { - /** - * New version available - */ - header: () => LocalizedString - /** - * Dismiss - */ - dismiss: () => LocalizedString - /** - * See what's new - */ - releaseNotes: () => LocalizedString - } - } - enrollment: { - sideBar: { - /** - * Enrollment - */ - title: () => LocalizedString - steps: { - /** - * Welcome - */ - welcome: () => LocalizedString - /** - * Data verification - */ - verification: () => LocalizedString - /** - * Create password - */ - password: () => LocalizedString - /** - * Configure VPN - */ - vpn: () => LocalizedString - /** - * Finish - */ - finish: () => LocalizedString - /** - * Configure MFA - */ - mfa: () => LocalizedString - /** - * Choose method - */ - mfaChoice: () => LocalizedString - /** - * Complete method - */ - mfaSetup: () => LocalizedString - /** - * Recovery codes - */ - mfaRecovery: () => LocalizedString - } - /** - * Application version - */ - appVersion: () => LocalizedString - } - stepsIndicator: { - /** - * Step - */ - step: () => LocalizedString - /** - * of - */ - of: () => LocalizedString - } - /** - * Time left - */ - timeLeft: () => LocalizedString - steps: { - welcome: { - /** - * Hello, {name} - */ - title: (arg: { name: string }) => LocalizedString - /** - * - In order to gain access to the company infrastructure, we require you to complete this enrollment process. During this process, you will need to: - - 1. Verify your data - 2. Create your password - 3. Configure VPN device - - You have a time limit of **{time} minutes** to complete this process. - If you have any questions, please consult your assigned admin.All necessary information can be found at the bottom of the sidebar. - */ - explanation: (arg: { time: string }) => LocalizedString - } - dataVerification: { - /** - * Data verification - */ - title: () => LocalizedString - /** - * Please, check your data. If anything is wrong, notify your admin after you complete the process. - */ - messageBox: () => LocalizedString - form: { - fields: { - firstName: { - /** - * Name - */ - label: () => LocalizedString - } - lastName: { - /** - * Last name - */ - label: () => LocalizedString - } - email: { - /** - * E-mail - */ - label: () => LocalizedString - } - phone: { - /** - * Phone number - */ - label: () => LocalizedString - } - } - } - } - password: { - /** - * Create password - */ - title: () => LocalizedString - form: { - fields: { - password: { - /** - * Create new password - */ - label: () => LocalizedString - } - repeat: { - /** - * Confirm new password - */ - label: () => LocalizedString - errors: { - /** - * Passwords aren't matching - */ - matching: () => LocalizedString - } - } - } - } - } - deviceSetup: { - desktopSetup: { - /** - * Configure this device - */ - title: () => LocalizedString - controls: { - /** - * Configure device - */ - create: () => LocalizedString - /** - * Device is configured - */ - success: () => LocalizedString - } - messages: { - /** - * Device is configured - */ - deviceConfigured: () => LocalizedString - } - } - /** - * * This step is OPTIONAL. You can skip it if you wish. This can be configured later in defguard. - */ - optionalMessage: () => LocalizedString - cards: { - device: { - /** - * Configure your device for VPN - */ - title: () => LocalizedString - create: { - /** - * Create Configuration - */ - submit: () => LocalizedString - /** - * Please be advised that you have to download the configuration now, since we do not store your private key. After this dialog is closed, you will not be able to get your full configuration file (with private keys, only blank template). - */ - messageBox: () => LocalizedString - form: { - fields: { - name: { - /** - * Device Name - */ - label: () => LocalizedString - } - 'public': { - /** - * My Public Key - */ - label: () => LocalizedString - } - toggle: { - /** - * Generate key pair - */ - generate: () => LocalizedString - /** - * Use my own public key - */ - own: () => LocalizedString - } - } - } - } - config: { - messageBox: { - /** - * -

- Please be advised that you have to download the configuration now, - since we do not store your private key. After this - dialog is closed, you will not be able to get your - full configuration file (with private keys, only blank template). -

- - */ - auto: () => LocalizedString - /** - * -

- Please be advised that configuration provided here does not include private key and uses public key to fill it's place you will need to replace it on your own for configuration to work properly. -

- - */ - manual: () => LocalizedString - } - /** - * My Device Name - */ - deviceNameLabel: () => LocalizedString - /** - * Use provided configuration file below by scanning QR Code or importing it as file on your devices WireGuard app. - */ - cardTitle: () => LocalizedString - card: { - /** - * Config file for location - */ - selectLabel: () => LocalizedString - } - } - } - guide: { - /** - * Quick Guide - */ - title: () => LocalizedString - /** - * This quick guide will help you with device configuration. - */ - messageBox: () => LocalizedString - /** - * Step {step}: - */ - step: (arg: { step: number }) => LocalizedString - steps: { - wireguard: { - /** - * Download and install WireGuard client on your computer or app on phone. - */ - content: () => LocalizedString - /** - * Download WireGuard - */ - button: () => LocalizedString - } - /** - * Download provided configuration file to your device. - */ - downloadConfig: () => LocalizedString - /** - * Open WireGuard and select "Add Tunnel" (Import tunnel(s) from file). Find your - Defguard configuration file and hit "OK". On phone use WireGuard app “+” icon and scan QR code. - */ - addTunnel: () => LocalizedString - /** - * Select your tunnel from the list and press "activate". - */ - activate: () => LocalizedString - /** - * - **Great work - your Defguard VPN is now active!** - - If you want to disengage your VPN connection, simply press "deactivate". - - */ - finish: () => LocalizedString - } - } - } - } - finish: { - /** - * Configuration completed! - */ - title: () => LocalizedString - } - } - } - sessionTimeout: { - card: { - /** - * Session timed out - */ - header: () => LocalizedString - /** - * Sorry, you have exceeded the time limit to complete the process. Please try again. If you need assistance, please watch our guide or contact your administrator. - */ - message: () => LocalizedString - } - controls: { - /** - * Enter new token - */ - back: () => LocalizedString - /** - * Contact admin - */ - contact: () => LocalizedString - } - } - token: { - card: { - /** - * Please, enter your personal enrollment token - */ - title: () => LocalizedString - messageBox: { - /** - * You can find token in e-mail message or use direct link. - */ - email: () => LocalizedString - } - form: { - errors: { - token: { - /** - * Token is required - */ - required: () => LocalizedString - } - } - fields: { - token: { - /** - * Token - */ - placeholder: () => LocalizedString - } - } - controls: { - /** - * Next - */ - submit: () => LocalizedString - } - } - } - } - } - modals: { - updateInstance: { - /** - * Update instance - */ - title: () => LocalizedString - /** - * Enter the token sent by the administrator to update the Instance configuration. - Alternatively, you can choose to remove this Instance entirely by clicking the 'Remove Instance' button below. - */ - infoMessage: () => LocalizedString - form: { - fieldLabels: { - /** - * Token - */ - token: () => LocalizedString - /** - * URL - */ - url: () => LocalizedString - } - fieldErrors: { - token: { - /** - * Token or URL rejected. - */ - rejected: () => LocalizedString - /** - * Instance for this token was not found. - */ - instanceIsNotPresent: () => LocalizedString - } - } - } - controls: { - /** - * Update Instance - */ - updateInstance: () => LocalizedString - /** - * Remove Instance - */ - removeInstance: () => LocalizedString - } - messages: { - /** - * {name} updated. - */ - success: (arg: { name: string }) => LocalizedString - /** - * Token or URL is invalid. - */ - error: () => LocalizedString - /** - * Instance for given token is not registered ! - */ - errorInstanceNotFound: () => LocalizedString - } - } - deleteInstance: { - /** - * Delete instance - */ - title: () => LocalizedString - /** - * Are you sure you want to delete {name}? - */ - subtitle: (arg: { name: string }) => LocalizedString - messages: { - /** - * Instance deleted - */ - success: () => LocalizedString - /** - * Unexpected error occurred - */ - error: () => LocalizedString - } - controls: { - /** - * Delete instance - */ - submit: () => LocalizedString - } - } - deleteTunnel: { - /** - * Delete tunnel - */ - title: () => LocalizedString - /** - * Are you sure you want to delete {name}? - */ - subtitle: (arg: { name: string }) => LocalizedString - messages: { - /** - * Tunnel deleted - */ - success: () => LocalizedString - /** - * Unexpected error occurred - */ - error: () => LocalizedString - } - controls: { - /** - * Delete tunnel - */ - submit: () => LocalizedString - } - } - mfa: { - authentication: { - /** - * Two-factor authentication - */ - title: () => LocalizedString - /** - * Paste the authentication code from your Authenticator Application. - */ - authenticatorAppDescription: () => LocalizedString - /** - * Paste the authentication code that was sent to your email address. - */ - emailCodeDescription: () => LocalizedString - /** - * For this connection, two-factor authentication (2FA) is mandatory. - */ - mfaStartDescriptionPrimary: () => LocalizedString - /** - * Select your preferred authentication method. - */ - mfaStartDescriptionSecondary: () => LocalizedString - /** - * Use authenticator app - */ - useAuthenticatorApp: () => LocalizedString - /** - * Use your email code - */ - useEmailCode: () => LocalizedString - /** - * Use this method for future logins - */ - saveAuthenticationMethodForFutureLogins: () => LocalizedString - /** - * Verify - */ - buttonSubmit: () => LocalizedString - openidLogin: { - /** - * In order to connect to the VPN please login with {provider}. To do so, please click "Authenticate with {provider}" button below. - */ - description: (arg: { provider: unknown }) => LocalizedString - /** - * **This will open a new window in your Web Browser** and automatically redirect you to the {provider} login page. After authenticating with {provider} please get back here. - */ - browserWarning: (arg: { provider: unknown }) => LocalizedString - /** - * Authenticate with {provider} - */ - buttonText: (arg: { provider: unknown }) => LocalizedString - } - openidPending: { - /** - * Waiting for authentication in your browser... - */ - description: () => LocalizedString - /** - * Try again - */ - tryAgain: () => LocalizedString - /** - * There was an error during authentication. Use the try again button below to retry the authentication process. - */ - errorDescription: () => LocalizedString - } - openidUnavailable: { - /** - * The OpenID authentication is currently unavailable. This may be due to a configuration issue or the Defguard instance is down. Please contact your administrator or try again later. - */ - description: () => LocalizedString - /** - * Try again - */ - tryAgain: () => LocalizedString - } - errors: { - /** - * Selected method has not been configured. - */ - mfaNotConfigured: () => LocalizedString - /** - * Could not start MFA process. Please try again or contact administrator. - */ - mfaStartGeneric: () => LocalizedString - /** - * Could not finish MFA process. Please try again or contact administrator. - */ - mfaFinishGeneric: () => LocalizedString - /** - * Could not find instance. - */ - instanceNotFound: () => LocalizedString - /** - * Location is not specified. - */ - locationNotSpecified: () => LocalizedString - /** - * Error, this code is invalid, try again or contact your administrator. - */ - invalidCode: () => LocalizedString - /** - * Token has expired. Please try to connect again. - */ - tokenExpired: () => LocalizedString - /** - * Authentication took too long and timed out. Please try connecting again. - */ - authenticationTimeout: () => LocalizedString - /** - * Error: Your login session might have been invalidated or expired. Please try again. - */ - sessionInvalidated: () => LocalizedString - } - } - } - } -} - -export type Formatters = {} diff --git a/src/i18n/i18n-util.async.ts b/src/i18n/i18n-util.async.ts deleted file mode 100644 index ee74471d5..000000000 --- a/src/i18n/i18n-util.async.ts +++ /dev/null @@ -1,27 +0,0 @@ -// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. -/* eslint-disable */ - -import { initFormatters } from './formatters' -import type { Locales, Translations } from './i18n-types' -import { loadedFormatters, loadedLocales, locales } from './i18n-util' - -const localeTranslationLoaders = { - en: () => import('./en'), - fr: () => import('./fr'), -} - -const updateDictionary = (locale: Locales, dictionary: Partial): Translations => - loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary } - -export const importLocaleAsync = async (locale: Locales): Promise => - (await localeTranslationLoaders[locale]()).default as unknown as Translations - -export const loadLocaleAsync = async (locale: Locales): Promise => { - updateDictionary(locale, await importLocaleAsync(locale)) - loadFormatters(locale) -} - -export const loadAllLocalesAsync = (): Promise => Promise.all(locales.map(loadLocaleAsync)) - -export const loadFormatters = (locale: Locales): void => - void (loadedFormatters[locale] = initFormatters(locale)) diff --git a/src/i18n/i18n-util.sync.ts b/src/i18n/i18n-util.sync.ts deleted file mode 100644 index fe0ba74f6..000000000 --- a/src/i18n/i18n-util.sync.ts +++ /dev/null @@ -1,26 +0,0 @@ -// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. -/* eslint-disable */ - -import { initFormatters } from './formatters' -import type { Locales, Translations } from './i18n-types' -import { loadedFormatters, loadedLocales, locales } from './i18n-util' - -import en from './en' -import fr from './fr' - -const localeTranslations = { - en, - fr, -} - -export const loadLocale = (locale: Locales): void => { - if (loadedLocales[locale]) return - - loadedLocales[locale] = localeTranslations[locale] as unknown as Translations - loadFormatters(locale) -} - -export const loadAllLocales = (): void => locales.forEach(loadLocale) - -export const loadFormatters = (locale: Locales): void => - void (loadedFormatters[locale] = initFormatters(locale)) diff --git a/src/i18n/i18n-util.ts b/src/i18n/i18n-util.ts deleted file mode 100644 index a4a293240..000000000 --- a/src/i18n/i18n-util.ts +++ /dev/null @@ -1,38 +0,0 @@ -// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. -/* eslint-disable */ - -import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n' -import type { LocaleDetector } from 'typesafe-i18n/detectors' -import type { LocaleTranslationFunctions, TranslateByString } from 'typesafe-i18n' -import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors' -import { initExtendDictionary } from 'typesafe-i18n/utils' -import type { Formatters, Locales, Translations, TranslationFunctions } from './i18n-types' - -export const baseLocale: Locales = 'en' - -export const locales: Locales[] = [ - 'en', - 'fr' -] - -export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales) - -export const loadedLocales: Record = {} as Record - -export const loadedFormatters: Record = {} as Record - -export const extendDictionary = initExtendDictionary() - -export const i18nString = (locale: Locales): TranslateByString => initI18nString(locale, loadedFormatters[locale]) - -export const i18nObject = (locale: Locales): TranslationFunctions => - initI18nObject( - locale, - loadedLocales[locale], - loadedFormatters[locale] - ) - -export const i18n = (): LocaleTranslationFunctions => - initI18n(loadedLocales, loadedFormatters) - -export const detectLocale = (...detectors: LocaleDetector[]): Locales => detectLocaleFn(baseLocale, locales, ...detectors) diff --git a/src/main.tsx b/src/main.tsx deleted file mode 100644 index 96d5cc633..000000000 --- a/src/main.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { error } from '@tauri-apps/plugin-log'; -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; - -import { App } from './components/App/App'; -import { errorDetail } from './shared/utils/errorDetail'; - -// Forward uncaught JS errors to the Tauri backend log -window.onerror = (message, source, lineno, colno, err) => { - const detail = err?.stack ?? `${message} (${source}:${lineno}:${colno})`; - error(`[uncaught error] ${detail}`); - // returning false lets the error propagate to the browser DevTools console - return false; -}; - -// Forward unhandled promise rejections to the Tauri backend log -window.addEventListener('unhandledrejection', (event) => { - error(`[unhandled rejection] ${errorDetail(event.reason)}`); -}); - -const rootElement = document.getElementById('root') as HTMLElement; - -const root = createRoot(rootElement); - -root.render( - - - , -); diff --git a/src/pages/client/ClientPage.tsx b/src/pages/client/ClientPage.tsx deleted file mode 100644 index 9358bf446..000000000 --- a/src/pages/client/ClientPage.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import './style.scss'; - -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { listen } from '@tauri-apps/api/event'; -import { useEffect } from 'react'; -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { shallow } from 'zustand/shallow'; -import AutoProvisioningManager from '../../components/AutoProvisioningManager'; -import { useI18nContext } from '../../i18n/i18n-react'; -import { DeepLinkProvider } from '../../shared/components/providers/DeepLinkProvider'; -import { useToaster } from '../../shared/defguard-ui/hooks/toasts/useToaster'; -import { routes } from '../../shared/routes'; -import { clientApi } from './clientAPI/clientApi'; -import { ClientSideBar } from './components/ClientSideBar/ClientSideBar'; -import { MfaModalProvider } from './components/MfaModalProvider'; -import { DeadConDroppedModal } from './components/modals/DeadConDroppedModal/DeadConDroppedModal'; -import { useDeadConDroppedModal } from './components/modals/DeadConDroppedModal/store'; -import { useClientFlags } from './hooks/useClientFlags'; -import { useClientStore } from './hooks/useClientStore'; -import { useMFAModal } from './pages/ClientInstancePage/components/LocationsList/modals/MFAModal/useMFAModal'; -import { clientQueryKeys } from './query'; -import { - ClientConnectionType, - type CommonWireguardFields, - type DeadConDroppedPayload, - TauriEventKey, -} from './types'; - -const { getInstances, getTunnels, getAppConfig } = clientApi; - -export const ClientPage = () => { - const queryClient = useQueryClient(); - const [setInstances, setTunnels, setClientState] = useClientStore( - (state) => [state.setInstances, state.setTunnels, state.setState], - shallow, - ); - const navigate = useNavigate(); - const firstLaunch = useClientFlags((state) => state.firstStart); - const [listChecked, setListChecked] = useClientStore((state) => [ - state.listChecked, - state.setListChecked, - ]); - const location = useLocation(); - const toaster = useToaster(); - const openDeadConDroppedModal = useDeadConDroppedModal((s) => s.open); - const openMFAModal = useMFAModal((state) => state.open); - const { LL } = useI18nContext(); - - const { data: instances } = useQuery({ - queryFn: getInstances, - queryKey: [clientQueryKeys.getInstances], - refetchOnMount: true, - refetchOnWindowFocus: false, - }); - - const { data: tunnels } = useQuery({ - queryFn: getTunnels, - queryKey: [clientQueryKeys.getTunnels], - refetchOnMount: true, - refetchOnWindowFocus: false, - }); - - const { data: appConfig } = useQuery({ - queryFn: getAppConfig, - queryKey: [clientQueryKeys.getApplicationConfig], - refetchOnMount: true, - refetchOnWindowFocus: false, - }); - - // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater - useEffect(() => { - const appConfigChanged = listen(TauriEventKey.APPLICATION_CONFIG_CHANGED, () => { - queryClient.invalidateQueries({ - queryKey: [clientQueryKeys.getApplicationConfig], - }); - }); - const instanceUpdate = listen(TauriEventKey.INSTANCE_UPDATE, () => { - const invalidate = [ - clientQueryKeys.getInstances, - clientQueryKeys.getLocations, - clientQueryKeys.getTunnels, - ]; - invalidate.forEach((key) => { - queryClient.invalidateQueries({ - queryKey: [key], - }); - }); - }); - - const verionMismatch = listen(TauriEventKey.VERSION_MISMATCH, (data) => { - const payload = data.payload as { - instance_name: string; - instance_id: number; - core_version: string; - proxy_version: string; - core_required_version: string; - proxy_required_version: string; - core_compatible: boolean; - proxy_compatible: boolean; - }; - toaster.error( - LL.common.messages.versionMismatch({ - instance_name: payload.instance_name, - core_version: payload.core_version, - proxy_version: payload.proxy_version, - core_required_version: payload.core_required_version, - proxy_required_version: payload.proxy_required_version, - }), - { lifetime: -1 }, - ); - }); - - const uuidMismatch = listen<{ - instance_name: string; - local_uuid: string; - core_uuid: string; - }>(TauriEventKey.UUID_MISMATCH, (data) => { - const payload = data.payload; - toaster.error( - LL.common.messages.uuidMismatch({ - instance_name: payload.instance_name, - }), - { lifetime: -1 }, - ); - }); - - const locationUpdate = listen(TauriEventKey.LOCATION_UPDATE, () => { - const invalidate = [clientQueryKeys.getLocations, clientQueryKeys.getTunnels]; - invalidate.forEach((key) => { - queryClient.invalidateQueries({ - queryKey: [key], - }); - }); - }); - - const connectionChanged = listen(TauriEventKey.CONNECTION_CHANGED, () => { - const invalidate = [ - clientQueryKeys.getLocations, - clientQueryKeys.getConnections, - clientQueryKeys.getActiveConnection, - clientQueryKeys.getConnectionHistory, - clientQueryKeys.getLocationStats, - clientQueryKeys.getInstances, - clientQueryKeys.getTunnels, - ]; - invalidate.forEach((key) => { - queryClient.invalidateQueries({ - queryKey: [key], - }); - }); - }); - - const configChanged = listen(TauriEventKey.CONFIG_CHANGED, (data) => { - const instance = data.payload as string; - toaster.info(LL.common.messages.configChanged({ instance })); - }); - - const deadConnectionDropped = listen( - TauriEventKey.DEAD_CONNECTION_DROPPED, - (data) => { - openDeadConDroppedModal(data.payload); - }, - ); - - const deadConnectionReconnected = listen( - TauriEventKey.DEAD_CONNECTION_RECONNECTED, - (data) => { - toaster.warning( - LL.common.messages.deadConDropped({ - interface_name: data.payload.name, - con_type: data.payload.con_type, - }), - { - lifetime: -1, - }, - ); - }, - ); - - const mfaTrigger = listen( - TauriEventKey.MFA_TRIGGER, - (data) => { - // Set connection type, as it is not transferred from Rust and MFA is only for locations. - data.payload.connection_type = ClientConnectionType.LOCATION; - openMFAModal(data.payload); - }, - ); - - return () => { - deadConnectionDropped.then((cleanup) => cleanup()); - deadConnectionReconnected.then((cleanup) => cleanup()); - configChanged.then((cleanup) => cleanup()); - connectionChanged.then((cleanup) => cleanup()); - instanceUpdate.then((cleanup) => cleanup()); - locationUpdate.then((cleanup) => cleanup()); - appConfigChanged.then((cleanup) => cleanup()); - mfaTrigger.then((cleanup) => cleanup()); - verionMismatch.then((cleanup) => cleanup()); - uuidMismatch.then((cleanup) => cleanup()); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // update store - useEffect(() => { - if (instances) { - setListChecked(true); - setInstances(instances); - } - if (tunnels) { - setListChecked(true); - setTunnels(tunnels); - } - }, [instances, setInstances, tunnels, setTunnels, setListChecked]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater - useEffect(() => { - if (appConfig) { - setClientState({ appConfig }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [appConfig]); - - // navigate to carousel on first app Launch - useEffect(() => { - if (!location.pathname.includes(routes.client.carousel) && firstLaunch) { - navigate(routes.client.carousel, { replace: true }); - } - }, [firstLaunch, navigate, location.pathname]); - - useEffect(() => { - if (listChecked && instances?.length === 0 && tunnels?.length === 0) { - navigate(routes.client.carousel, { replace: true }); - } - }, [navigate, listChecked, instances, tunnels]); - - return ( - - - - - - - - - - - ); -}; diff --git a/src/pages/client/clientAPI/clientApi.ts b/src/pages/client/clientAPI/clientApi.ts deleted file mode 100644 index 3fab2b3c7..000000000 --- a/src/pages/client/clientAPI/clientApi.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { InvokeArgs } from '@tauri-apps/api/core'; -import { invoke } from '@tauri-apps/api/core'; -import { debug, error, trace } from '@tauri-apps/plugin-log'; -import pTimeout, { TimeoutError } from 'p-timeout'; - -import type { NewApplicationVersionInfo } from '../../../shared/hooks/api/types'; -import type { - CommonWireguardFields, - Connection, - DefguardInstance, - LocationStats, - Tunnel, -} from '../types'; -import type { - AppConfig, - ConnectionRequest, - GetLocationsRequest, - LocationDetails, - LocationDetailsRequest, - ProvisioningConfig, - RoutingRequest, - SaveConfigRequest, - SaveDeviceConfigResponse, - StatsRequest, - TauriCommandKey, - TunnelRequest, - UpdateInstanceRequest, -} from './types'; - -// Streamlines logging for invokes -async function invokeWrapper( - command: TauriCommandKey, - args?: InvokeArgs, - timeout: number = 10000, -): Promise { - debug(`Invoking "${command}" on the frontend`); - try { - const res = await pTimeout(invoke(command, args), { - milliseconds: timeout, - }); - debug(`"${command}" completed on the frontend`); - trace(`"${command}" returned: ${JSON.stringify(res)}`); - return res; - // TODO: handle more error types ? - } catch (e) { - let message: string = `Invoking "${command}" failed due to unknown error: ${JSON.stringify( - e, - )}`; - trace(message); - if (e instanceof TimeoutError) { - message = `Invoking "${command}" timed out after ${timeout / 1000} seconds`; - } - error(message); - return Promise.reject(message); - } -} - -const saveConfig = async (data: SaveConfigRequest): Promise => - invokeWrapper('save_device_config', data); - -const getInstances = async (): Promise => - invokeWrapper('all_instances'); - -const getLocations = async ( - data: GetLocationsRequest, -): Promise => invokeWrapper('all_locations', data); - -const connect = async (data: ConnectionRequest): Promise => - invokeWrapper('connect', data); - -const disconnect = async (data: ConnectionRequest): Promise => - invokeWrapper('disconnect', data); - -const getLocationStats = async (data: StatsRequest): Promise => - invokeWrapper('location_stats', data); - -const getLastConnection = async (data: ConnectionRequest): Promise => - invokeWrapper('last_connection', data); - -const getConnectionHistory = async (data: ConnectionRequest): Promise => - invokeWrapper('all_connections', data); - -const getActiveConnection = async (data: ConnectionRequest): Promise => - invokeWrapper('active_connection', data); - -const updateLocationRouting = async (data: RoutingRequest): Promise => - invokeWrapper('update_location_routing', data); - -const deleteInstance = async (id: number): Promise => - invokeWrapper('delete_instance', { instanceId: id }); - -const updateInstance = async (data: UpdateInstanceRequest): Promise => - invokeWrapper('update_instance', data); - -const parseTunnelConfig = async (filename: string, config: string) => - invokeWrapper('parse_tunnel_config', { filename: filename, config: config }); - -const saveTunnel = async (tunnel: TunnelRequest) => - invokeWrapper('save_tunnel', { tunnel: tunnel }); - -const updateTunnel = async (tunnel: TunnelRequest) => - invokeWrapper('update_tunnel', { tunnel: tunnel }); - -const getLocationDetails = async ( - data: LocationDetailsRequest, -): Promise => invokeWrapper('location_interface_details', data); - -const getTunnels = async (): Promise => - invokeWrapper('all_tunnels'); - -// opens given link in system default browser -const openLink = async (link: string): Promise => - invokeWrapper('open_link', { link }); - -const getTunnelDetails = async (id: number): Promise => - invokeWrapper('tunnel_details', { tunnelId: id }); - -const deleteTunnel = async (id: number): Promise => - invokeWrapper('delete_tunnel', { tunnelId: id }); - -const getLatestAppVersion = async (): Promise => - invokeWrapper('get_latest_app_version'); - -const startGlobalLogWatcher = async (): Promise => - invokeWrapper('start_global_logwatcher'); - -const stopGlobalLogWatcher = async (): Promise => - invokeWrapper('stop_global_logwatcher'); - -const getAppConfig = async (): Promise => - invokeWrapper('command_get_app_config'); - -const getProvisioningConfig = async (): Promise => - invokeWrapper('get_provisioning_config'); - -const getPlatformHeader = async (): Promise => - invokeWrapper('get_platform_header'); - -const setAppConfig = async ( - appConfig: Partial, - emitEvent: boolean, -): Promise => - invokeWrapper('command_set_app_config', { - configPatch: appConfig, - emitEvent, - }); - -export const clientApi = { - getAppConfig, - setAppConfig, - getInstances, - getTunnels, - getLocations, - connect, - disconnect, - getLocationStats, - getLastConnection, - getConnectionHistory, - getActiveConnection, - saveConfig, - updateLocationRouting, - deleteInstance, - deleteTunnel, - getLocationDetails, - updateInstance, - parseTunnelConfig, - saveTunnel, - updateTunnel, - openLink, - getTunnelDetails, - getLatestAppVersion, - startGlobalLogWatcher, - stopGlobalLogWatcher, - getProvisioningConfig, - getPlatformHeader, -}; diff --git a/src/pages/client/clientAPI/types.ts b/src/pages/client/clientAPI/types.ts deleted file mode 100644 index 0b970e95d..000000000 --- a/src/pages/client/clientAPI/types.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { ThemeKey } from '../../../shared/defguard-ui/hooks/theme/types'; -import type { CreateDeviceResponse } from '../../../shared/hooks/api/types'; -import type { ClientConnectionType, DefguardInstance, DefguardLocation } from '../types'; - -export type GetLocationsRequest = { - instanceId: number; -}; - -export type ConnectionRequest = { - locationId: number; - connectionType: ClientConnectionType; - presharedKey?: string; -}; - -export type RoutingRequest = { - locationId: number; - connectionType: ClientConnectionType; - routeAllTraffic?: boolean; -}; - -export type StatsRequest = { - locationId: number; - connectionType: ClientConnectionType; - from?: string; -}; - -export type SaveConfigRequest = { - privateKey: string; - response: CreateDeviceResponse; -}; - -export type UpdateInstanceRequest = { - instanceId: number; - response: CreateDeviceResponse; -}; - -export type SaveDeviceConfigResponse = { - instance: DefguardInstance; - locations: DefguardLocation[]; -}; -export type SaveTunnelRequest = { - privateKey: string; - response: CreateDeviceResponse; -}; - -export type TrayIconTheme = 'color' | 'white' | 'black' | 'gray'; - -export const availableTrayThemes: TrayIconTheme[] = ['color', 'white', 'gray', 'black']; - -export type LogLevel = 'ERROR' | 'INFO' | 'DEBUG' | 'TRACE' | 'WARN'; - -export const availableLogLevels: LogLevel[] = ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE']; - -export type GlobalLogLevel = 'ERROR' | 'INFO' | 'DEBUG'; -export type LogSource = 'Client' | 'VPN' | 'All'; - -export type ClientView = 'grid' | 'detail'; - -export type LogItemField = { - message: string; - interface_name?: string; -}; - -export type LogItem = { - // datetime UTC - timestamp: string; - level: LogLevel; - target: string; - fields: LogItemField; - source: LogSource; -}; - -export type InterfaceLogsRequest = { - locationId: DefguardLocation['id']; -}; - -export type AppConfig = { - theme: ThemeKey; - log_level: LogLevel; - tray_theme: TrayIconTheme; - check_for_updates: boolean; - peer_alive_period: number; - mtu: number; -}; - -export type PlatformInfo = { - client_version: string; - platform_info: string; -}; - -export type ProvisioningConfig = { - enrollment_token: string; - enrollment_url: string; -}; - -export type LocationDetails = { - location_id: number; - name: string; - pubkey: string; - address: string; - dns?: string; - listen_port: number; - peer_pubkey: string; - peer_endpoint: string; - allowed_ips: string; - persistent_keepalive_interval?: number; - last_handshake?: number; -}; - -export type TunnelRequest = { - name: string; - pubkey: string; - prvkey: string; - address: string; - server_pubkey: string; - allowed_ips?: string; - endpoint: string; - dns?: string; - persistent_keep_alive: number; - pre_up?: string; - post_up?: string; - pre_down?: string; - post_down?: string; -}; - -export type LocationDetailsRequest = { - locationId: number; - connectionType: ClientConnectionType; -}; - -export type TauriCommandKey = - | 'all_instances' - | 'all_locations' - | 'connect' - | 'disconnect' - | 'location_stats' - | 'last_connection' - | 'all_connections' - | 'active_connection' - | 'save_device_config' - | 'update_location_routing' - | 'delete_instance' - | 'update_instance' - | 'parse_tunnel_config' - | 'save_tunnel' - | 'update_tunnel' - | 'all_tunnels' - | 'tunnel_details' - | 'delete_tunnel' - | 'location_interface_details' - | 'open_link' - | 'get_latest_app_version' - | 'start_global_logwatcher' - | 'stop_global_logwatcher' - | 'command_get_app_config' - | 'command_set_app_config' - | 'get_provisioning_config' - | 'get_platform_header'; diff --git a/src/pages/client/components/ClientSideBar/ClientSideBar.tsx b/src/pages/client/components/ClientSideBar/ClientSideBar.tsx deleted file mode 100644 index 7e8a9c946..000000000 --- a/src/pages/client/components/ClientSideBar/ClientSideBar.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import './style.scss'; - -import { getVersion } from '@tauri-apps/api/app'; -import classNames from 'classnames'; -import { useEffect, useState } from 'react'; -import { useMatch, useNavigate } from 'react-router-dom'; - -import { useI18nContext } from '../../../../i18n/i18n-react'; -import { IconDefguard } from '../../../../shared/components/icons/IconDefguard/IconDeguard'; -import SvgDefguardLogoCollapsed from '../../../../shared/components/svg/DefguardLogoCollapsed'; -import SvgDefguardLogoText from '../../../../shared/components/svg/DefguardLogoText'; -import SvgIconNavConnections from '../../../../shared/components/svg/IconNavConnections'; -import SvgIconNavVpn from '../../../../shared/components/svg/IconNavVpn'; -import { Divider } from '../../../../shared/defguard-ui/components/Layout/Divider/Divider'; -import { IconContainer } from '../../../../shared/defguard-ui/components/Layout/IconContainer/IconContainer'; -import SvgIconPlus from '../../../../shared/defguard-ui/components/svg/IconPlus'; -import SvgIconSettings from '../../../../shared/defguard-ui/components/svg/IconSettings'; -import { routes } from '../../../../shared/routes'; -import { useClientStore } from '../../hooks/useClientStore'; -import { useAddInstanceStore } from '../../pages/ClientAddInstancePage/hooks/useAddInstanceStore'; -import { ClientConnectionType } from '../../types'; -import { ClientBarItem } from './components/ClientBarItem/ClientBarItem'; -import { NewApplicationVersionAvailableInfo } from './components/NewApplicationVersionAvailableInfo/NewApplicationVersionAvailableInfo'; - -export const ClientSideBar = () => { - const navigate = useNavigate(); - const { LL } = useI18nContext(); - const [selectedInstance, instances, tunnels, setClientStore] = useClientStore( - (state) => [state.selectedInstance, state.instances, state.tunnels, state.setState], - ); - const tunnelPathActive = - selectedInstance?.id === undefined && - selectedInstance?.type === ClientConnectionType.TUNNEL; - - return ( -
-
navigate(routes.client.carousel, { replace: true })} - > - - -
-
navigate(routes.client.carousel, { replace: true })} - > - -
-
-
{ - navigate(routes.client.carousel, { replace: true }); - }} - > - -

{LL.pages.client.sideBar.instances()}

-
- {instances.map((instance) => ( - - ))} - -
- -
-
{ - setClientStore({ - selectedInstance: { - id: undefined, - type: ClientConnectionType.TUNNEL, - }, - }); - navigate(routes.client.base, { replace: true }); - }} - > - -

{LL.pages.client.sideBar.tunnels()}

-
- {tunnels.map((tunnel) => ( - - ))} - -
- - - - -
-
-
- ); -}; - -const FooterApplicationInfo = () => { - const { LL } = useI18nContext(); - const [appVersion, setAppVersion] = useState('-'); - - useEffect(() => { - const getAppVersion = async () => { - const version = await getVersion().catch(() => { - return ''; - }); - setAppVersion(version); - }; - - getAppVersion(); - }, []); - - return ( -
- ); -}; - -const SettingsNav = () => { - const { LL } = useI18nContext(); - const navigate = useNavigate(); - const pathActive = useMatch(routes.client.settings); - return ( -
{ - navigate(routes.client.settings, { replace: true }); - }} - > - -

{LL.pages.client.sideBar.settings()}

-
- ); -}; - -const AddInstance = () => { - const { LL } = useI18nContext(); - const navigate = useNavigate(); - const resetStore = useAddInstanceStore((s) => s.reset); - return ( -
{ - resetStore(); - navigate(routes.client.addInstance, { replace: true }); - }} - > - - - -

{LL.pages.client.sideBar.addInstance()}

-
- ); -}; -const AddTunnel = () => { - const { LL } = useI18nContext(); - const navigate = useNavigate(); - return ( -
{ - navigate(routes.client.addTunnel, { replace: true }); - }} - > - - - -

{LL.pages.client.sideBar.addTunnel()}

-
- ); -}; diff --git a/src/pages/client/components/ClientSideBar/components/ClientBarItem/ClientBarItem.tsx b/src/pages/client/components/ClientSideBar/components/ClientBarItem/ClientBarItem.tsx deleted file mode 100644 index 59492fb91..000000000 --- a/src/pages/client/components/ClientSideBar/components/ClientBarItem/ClientBarItem.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { autoUpdate, useFloating } from '@floating-ui/react'; -import classNames from 'classnames'; -import { isUndefined } from 'lodash-es'; -import { useMemo } from 'react'; -import { useMatch, useNavigate } from 'react-router-dom'; - -import SvgIconConnection from '../../../../../../shared/defguard-ui/components/svg/IconConnection'; -import { routes } from '../../../../../../shared/routes'; -import { useClientStore } from '../../../../hooks/useClientStore'; -import type { ClientConnectionType } from '../../../../types'; - -type Props = { - itemType: ClientConnectionType; - itemId: number; - label: string; - active?: boolean; -}; - -export const ClientBarItem = ({ itemType, itemId, label, active = false }: Props) => { - const instancePage = useMatch('/client/instance/'); - const navigate = useNavigate(); - const setClientStore = useClientStore((state) => state.setState); - const selectedInstance = useClientStore((state) => state.selectedInstance); - const itemSelected = useMemo(() => { - return ( - !isUndefined(selectedInstance) && - !isUndefined(selectedInstance?.id) && - selectedInstance.id === itemId && - selectedInstance.type === itemType - ); - }, [selectedInstance, itemType, itemId]); - - const cn = classNames('client-bar-item', 'clickable', { - active: itemSelected, - connected: active, - }); - - const { refs, floatingStyles } = useFloating({ - placement: 'right', - whileElementsMounted: (refElement, floatingElement, updateFunc) => - autoUpdate(refElement, floatingElement, updateFunc), - }); - - return ( - <> -
{ - setClientStore({ - selectedInstance: { - id: itemId, - type: itemType, - }, - }); - if (!instancePage) { - navigate(routes.client.instancePage, { replace: true }); - } - }} - > - -

{label}

-
- -

{label[0]}

-
-
- {active && ( -
- )} - - ); -}; diff --git a/src/pages/client/components/ClientSideBar/components/NewApplicationVersionAvailableInfo/NewApplicationVersionAvailableInfo.tsx b/src/pages/client/components/ClientSideBar/components/NewApplicationVersionAvailableInfo/NewApplicationVersionAvailableInfo.tsx deleted file mode 100644 index 1e21bb20b..000000000 --- a/src/pages/client/components/ClientSideBar/components/NewApplicationVersionAvailableInfo/NewApplicationVersionAvailableInfo.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import './style.scss'; - -import { shallow } from 'zustand/shallow'; - -import { useApplicationUpdateStore } from '../../../../../../components/ApplicationUpdateManager/useApplicationUpdateStore'; -import { useNewAppVersionAvailable } from '../../../../../../components/ApplicationUpdateManager/useNewAppVersionAvailable'; -import { useI18nContext } from '../../../../../../i18n/i18n-react'; -import { clientApi } from '../../../../../../pages/client/clientAPI/clientApi'; -import SvgIconDownload from '../../../../../../shared/defguard-ui/components/svg/IconDownload'; -import { useClientStore } from '../../../../../client/hooks/useClientStore'; - -const { openLink } = clientApi; - -export const NewApplicationVersionAvailableInfo = () => { - const { LL } = useI18nContext(); - const { newAppVersionAvailable } = useNewAppVersionAvailable(); - const checkForUpdates = useClientStore((state) => state.appConfig.check_for_updates); - - const dismissed = useApplicationUpdateStore((state) => state.dismissed, shallow); - const setValues = useApplicationUpdateStore((state) => state.setValues, shallow); - - const [latestVersion, releaseDate, releaseNotesUrl, updateUrl] = - useApplicationUpdateStore( - (state) => [ - state.latestVersion, - state.releaseDate, - state.releaseNotesUrl, - state.updateUrl, - ], - shallow, - ); - - if ( - dismissed || - !checkForUpdates || - !newAppVersionAvailable || - !latestVersion || - !releaseDate || - !releaseNotesUrl || - !updateUrl - ) - return null; - - return ( -
-
-

- {LL.pages.client.newApplicationVersion.header()} {latestVersion} -

- openLink(updateUrl)} - /> -
-
-

setValues({ dismissed: true })}> - {LL.pages.client.newApplicationVersion.dismiss()} -

-

openLink(releaseNotesUrl)}> - {LL.pages.client.newApplicationVersion.releaseNotes()} -

-
-
-

{LL.pages.client.newApplicationVersion.header()}

-

{latestVersion}

- openLink(updateUrl)} - /> -
-

openLink(releaseNotesUrl)}> - {LL.pages.client.newApplicationVersion.releaseNotes()} -

-

setValues({ dismissed: true })}> - {LL.pages.client.newApplicationVersion.dismiss()} -

-
-
-
- ); -}; diff --git a/src/pages/client/components/ClientSideBar/components/NewApplicationVersionAvailableInfo/style.scss b/src/pages/client/components/ClientSideBar/components/NewApplicationVersionAvailableInfo/style.scss deleted file mode 100644 index eb3983246..000000000 --- a/src/pages/client/components/ClientSideBar/components/NewApplicationVersionAvailableInfo/style.scss +++ /dev/null @@ -1,84 +0,0 @@ -#settings-new-application-version-available { - flex-direction: column; - width: 100%; - background-color: var(--surface-frame-bg); - - @include media-breakpoint-down(lg) { - background-color: transparent; - } - - & > .new-version-header { - padding: 20px; - padding-bottom: 0; - display: flex; - justify-content: space-between; - align-items: center; - - @include media-breakpoint-down(lg) { - display: none; - } - - & > .new-version-download-icon { - cursor: pointer; - } - - & > h3 { - margin-right: 5px; - @include typography(markdown-h6); - color: var(--text-body-primary); - } - } - - & > .new-version-subheader { - display: flex; - padding: 20px; - padding-top: 8px; - justify-content: space-between; - color: var(--text-body-primary); - font-weight: 300; - font-size: 14px; - - @include media-breakpoint-down(lg) { - display: none; - } - - & > p { - cursor: pointer; - @include typography(app-copyright); - font-size: 12px; - } - } - - & > .settings-new-application-version-mobile { - display: flex; - flex-direction: column; - align-items: center; - margin-bottom: 20px; - background-color: var(--surface-frame-bg); - padding-top: 10px; - padding-bottom: 5px; - - @include media-breakpoint-up(lg) { - display: none; - } - - & > p, - & > div > p { - text-align: center; - @include typography(app-copyright); - color: var(--text-body-primary); - } - - & > svg { - height: 32px; - width: 32px; - cursor: pointer; - } - - & > div > p { - cursor: pointer; - line-height: 11px; - margin-bottom: 5px; - } - } -} diff --git a/src/pages/client/components/ClientSideBar/style.scss b/src/pages/client/components/ClientSideBar/style.scss deleted file mode 100644 index ad97b1956..000000000 --- a/src/pages/client/components/ClientSideBar/style.scss +++ /dev/null @@ -1,296 +0,0 @@ -#client-page-side { - z-index: 2; - position: fixed; - inset: 0; - height: 100%; - max-height: 100vh; - max-height: 100dvh; - overflow-x: hidden; - overflow-y: auto; - width: 70px; - background-color: var(--surface-nav-bg); - border-right: 1px solid var(--border-primary); - display: flex; - flex-flow: column; - - @include media-breakpoint-up(lg) { - width: 270px; - } - - & > .logo-desktop { - display: none; - flex-flow: row nowrap; - align-items: center; - justify-content: center; - height: 108px; - column-gap: 7px; - border-bottom: 1px solid var(--border-primary); - cursor: pointer; - - @include media-breakpoint-up(lg) { - display: flex; - } - - :nth-child(2) { - path { - fill: var(--text-body-primary); - } - } - } - - & > .logo-mobile { - display: flex; - height: 70px; - flex-flow: row nowrap; - border-bottom: 1px solid var(--border-primary); - width: 100%; - align-items: center; - justify-content: center; - box-sizing: border-box; - cursor: pointer; - - @include media-breakpoint-up(lg) { - display: none; - } - - & > svg { - width: 40px; - height: 40px; - } - } - - & > .items { - display: flex; - flex-grow: 1; - height: 45vh; - flex-shrink: 0; - flex-flow: column; - align-items: flex-start; - justify-content: flex-start; - box-sizing: border-box; - row-gap: 15px; - - &.flex-end { - justify-content: flex-end; - - @media (min-height: 600px) { - padding-bottom: 70px; - } - } - - @include media-breakpoint-up(lg) { - row-gap: 0; - } - - @media (min-height: 600px) { - padding-top: 70px; - } - - & > .client-bar-item, - & > div > .client-bar-item { - display: grid; - box-sizing: border-box; - width: 100%; - grid-template-rows: 40px; - grid-template-columns: 40px; - align-items: center; - justify-content: center; - - @include media-breakpoint-up(lg) { - grid-template-rows: 24px; - grid-template-columns: 24px 1fr; - padding: 0 10px 0 32px; - height: 58px; - column-gap: 18px; - justify-items: start; - align-items: center; - justify-content: start; - align-content: center; - } - - & > svg, - & > .icon-wrapper { - margin-bottom: 20px; - grid-column: 1; - grid-row: 1; - width: 40px; - height: 40px; - - @include media-breakpoint-up(lg) { - display: flex; - width: 24px; - height: 24px; - margin-bottom: 0; - } - } - - & > p { - grid-row: 1; - grid-column: 2; - width: 100%; - max-width: 100%; - text-align: left; - user-select: none; - - @include text-overflow-dots; - @include typography(app-side-bar); - - color: var(--text-body-tertiary); - - display: none; - - @include media-breakpoint-up(lg) { - display: block; - } - } - - & > .instance-shorted { - display: flex; - flex-flow: row nowrap; - align-items: center; - justify-content: center; - position: relative; - width: 40px; - height: 40px; - background-color: var(--surface-main-primary); - grid-row: 1; - grid-column: 1; - border-radius: 25%; - - @include media-breakpoint-up(lg) { - display: none; - } - - & > p { - @include typography(app-side-bar); - text-transform: uppercase; - color: var(--text-button-secondary); - } - - & > .connection-icon { - position: absolute; - right: -1px; - top: -2px; - } - } - - & > .connection-icon { - display: none; - - @include media-breakpoint-up(lg) { - display: block; - } - } - - .connection-icon { - path { - stroke: var(--surface-important); - } - } - - &.active { - & > p { - color: var(--text-body-primary); - } - } - - &.connected { - .connection-icon { - path { - stroke: var(--surface-positive-primary); - } - } - } - - &.clickable { - cursor: pointer; - } - - &:not(.active) { - &:hover { - & > p { - color: var(--text-body-primary); - } - } - } - } - - #instances-nav-label { - display: none; - - @include media-breakpoint-up(lg) { - display: grid; - } - } - - #settings-nav-item { - // margin-top: auto; - } - - #add-instance { - @include media-breakpoint-down(lg) { - display: grid; - grid-template-rows: 40px; - grid-template-columns: 40px; - align-items: center; - justify-content: center; - padding: 0; - } - - & > .icon-wrapper { - display: flex; - - svg { - width: 15px; - height: 15px; - } - } - - & > p { - display: none; - - @include media-breakpoint-up(lg) { - display: block; - } - } - } - } -} - -.client-bar-active-item-bar { - width: 2px; - height: 58px; - display: block; - background-color: var(--surface-main-primary); - content: ' '; - z-index: 3; -} - -#footer-application-info { - width: 100%; - padding-top: 20px; - padding-bottom: 20px; - - & > p { - @include typography(app-copyright); - color: var(--text-body-tertiary); - text-align: center; - - @include media-breakpoint-down(lg) { - padding-left: 5px; - padding-right: 5px; - } - - & > a { - color: inherit; - cursor: pointer; - } - } -} - -.client-bar-bottom-menu-container { - display: flex; - flex-direction: column; - width: 100%; - margin-top: auto; -} diff --git a/src/pages/client/components/MfaModalProvider.tsx b/src/pages/client/components/MfaModalProvider.tsx deleted file mode 100644 index 680100c50..000000000 --- a/src/pages/client/components/MfaModalProvider.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { type PropsWithChildren, useEffect } from 'react'; -import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; -import { MFAModal } from '../pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal'; -import { useMFAModal } from '../pages/ClientInstancePage/components/LocationsList/modals/MFAModal/useMFAModal'; -import type { CommonWireguardFields } from '../types'; - -type Props = PropsWithChildren; - -type Payload = { - location?: CommonWireguardFields; -}; - -export const MfaModalProvider = ({ children }: Props) => { - const openMFAModal = useMFAModal((state) => state.open); - // listen for rust backend requesting MFA - - useEffect(() => { - let unlisten: UnlistenFn; - - (async () => { - unlisten = await listen('mfa-trigger', ({ payload: { location } }) => { - if (isPresent(location)) { - openMFAModal(location); - } - }); - })(); - - return () => { - unlisten?.(); - }; - }, [openMFAModal]); - - return ( - <> - {children} - - - ); -}; diff --git a/src/pages/client/components/modals/DeadConDroppedModal/DeadConDroppedModal.tsx b/src/pages/client/components/modals/DeadConDroppedModal/DeadConDroppedModal.tsx deleted file mode 100644 index 6a488f80b..000000000 --- a/src/pages/client/components/modals/DeadConDroppedModal/DeadConDroppedModal.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import './style.scss'; - -import { useMemo } from 'react'; -import { shallow } from 'zustand/shallow'; - -import { useI18nContext } from '../../../../../i18n/i18n-react'; -import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../shared/defguard-ui/components/Layout/Button/types'; -import { ModalWithTitle } from '../../../../../shared/defguard-ui/components/Layout/modals/ModalWithTitle/ModalWithTitle'; -import { ClientConnectionType } from '../../../types'; -import { useDeadConDroppedModal } from './store'; - -export const DeadConDroppedModal = () => { - const { LL } = useI18nContext(); - const localLL = LL.pages.client.modals.deadConDropped; - const isOpen = useDeadConDroppedModal((s) => s.visible); - const payload = useDeadConDroppedModal((s) => s.payload); - const [close, reset] = useDeadConDroppedModal((s) => [s.close, s.reset], shallow); - - return ( - - - - ); -}; - -const ModalContent = () => { - const { LL } = useI18nContext(); - const localLL = LL.pages.client.modals.deadConDropped; - const payload = useDeadConDroppedModal((s) => s.payload); - const close = useDeadConDroppedModal((s) => s.close, shallow); - - const typeString = useMemo(() => { - switch (payload?.con_type) { - case ClientConnectionType.LOCATION: - return localLL.location(); - case ClientConnectionType.TUNNEL: - return localLL.tunnel(); - default: - return ''; - } - }, [localLL, payload?.con_type]); - - const message = useMemo(() => { - if (payload) { - return localLL.message({ - conType: typeString, - name: payload.name, - time: payload.peer_alive_period, - }); - } - }, [localLL, payload, typeString]); - - if (!payload) return null; - return ( - <> -
-

{message}

-
-
-
- - ); -}; diff --git a/src/pages/client/components/modals/DeadConDroppedModal/store.tsx b/src/pages/client/components/modals/DeadConDroppedModal/store.tsx deleted file mode 100644 index f794d8511..000000000 --- a/src/pages/client/components/modals/DeadConDroppedModal/store.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createWithEqualityFn } from 'zustand/traditional'; - -import type { DeadConDroppedPayload } from '../../../types'; - -const defaultValues: StoreValues = { - visible: false, - payload: undefined, -}; - -export const useDeadConDroppedModal = createWithEqualityFn( - (set) => ({ - ...defaultValues, - open: (val) => set({ visible: true, payload: val }), - close: () => set({ visible: false }), - reset: () => set(defaultValues), - }), - Object.is, -); - -type Store = StoreMethods & StoreValues; - -type StoreMethods = { - open: (payload: DeadConDroppedPayload) => void; - close: () => void; - reset: () => void; -}; - -type StoreValues = { - visible: boolean; - payload?: DeadConDroppedPayload; -}; diff --git a/src/pages/client/components/modals/DeadConDroppedModal/style.scss b/src/pages/client/components/modals/DeadConDroppedModal/style.scss deleted file mode 100644 index 310c3c4e3..000000000 --- a/src/pages/client/components/modals/DeadConDroppedModal/style.scss +++ /dev/null @@ -1,32 +0,0 @@ -#dead-con-dropped-modal { - .content { - padding: 20px; - - @include media-breakpoint-up(lg) { - padding: 20px 30px 40px; - } - - & > .message { - min-height: 100px; - - p { - @include typography(app-input); - color: var(--text-body-primary); - padding-bottom: 30px; - } - } - - .controls { - display: flex; - flex-flow: row; - align-items: center; - justify-content: center; - - .btn { - height: 50px; - width: 100%; - max-width: 280px; - } - } - } -} diff --git a/src/pages/client/hooks/useClientFlags.tsx b/src/pages/client/hooks/useClientFlags.tsx deleted file mode 100644 index 0b92a1969..000000000 --- a/src/pages/client/hooks/useClientFlags.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { createJSONStorage, persist } from 'zustand/middleware'; -import { createWithEqualityFn } from 'zustand/traditional'; - -const defaults: StoreValues = { - firstStart: true, -}; - -/*Flags that are persisted via localstorage and are not used by rust backend*/ -export const useClientFlags = createWithEqualityFn()( - persist( - (set) => ({ - ...defaults, - setValues: (vals) => set({ ...vals }), - }), - { - name: 'client-flags', - version: 1, - storage: createJSONStorage(() => localStorage), - }, - ), - Object.is, -); - -type Store = StoreValues & StoreMethods; - -type StoreValues = { - // Is user launching app first time ? - firstStart: boolean; -}; - -type StoreMethods = { - setValues: (values: Partial) => void; -}; diff --git a/src/pages/client/hooks/useClientStore.tsx b/src/pages/client/hooks/useClientStore.tsx deleted file mode 100644 index a722d8c5c..000000000 --- a/src/pages/client/hooks/useClientStore.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { isUndefined, pickBy } from 'lodash-es'; -import { createJSONStorage, persist } from 'zustand/middleware'; -import { createWithEqualityFn } from 'zustand/traditional'; - -import { clientApi } from '../clientAPI/clientApi'; -import type { AppConfig, ClientView, PlatformInfo } from '../clientAPI/types'; -import { - ClientConnectionType, - type CommonWireguardFields, - type DefguardInstance, - type SelectedInstance, -} from '../types'; - -const { getInstances, setAppConfig } = clientApi; - -const persistedKeys: Array = [ - 'selectedInstance', - 'selectedLocation', - 'selectedView', -]; - -// eslint-disable-next-line -const defaultValues: StoreValues = { - instances: [], - tunnels: [], - selectedInstance: undefined, - selectedLocation: undefined, - statsFilter: 1, - listChecked: false, - selectedView: 'grid', - // application config stored in app data json file, ONLY interact with it via store methods. - appConfig: { - log_level: 'INFO', - theme: 'light', - tray_theme: 'color', - check_for_updates: true, - peer_alive_period: 300, - mtu: 0, - }, - platformInfo: { - client_version: '', - platform_info: '', - }, -}; - -export const useClientStore = createWithEqualityFn()( - persist( - (set, get) => ({ - ...defaultValues, - setState: (values) => set({ ...values }), - setInstances: (values) => { - if (isUndefined(get().selectedInstance)) { - return set({ - instances: values, - selectedInstance: { - id: values[0]?.id, - type: ClientConnectionType.LOCATION, - }, - }); - } - return set({ instances: values }); - }, - setTunnels: (values) => { - if (isUndefined(get().selectedInstance)) { - return set({ - tunnels: values, - selectedInstance: { id: values[0]?.id, type: ClientConnectionType.TUNNEL }, - }); - } - return set({ tunnels: values }); - }, - setListChecked: async (values: boolean) => { - return set({ listChecked: values }); - }, - updateInstances: async () => { - const res = await getInstances(); - let selected = get().selectedInstance; - // check if currently selected instances is in updated instances - if (!isUndefined(selected) && res.length && selected.id) { - if (!res.map((i) => i.id).includes(selected.id)) { - selected = { id: res[0].id, type: ClientConnectionType.LOCATION }; - } - } - if (isUndefined(selected) && res.length) { - selected = { id: res[0].id, type: ClientConnectionType.LOCATION }; - } - set({ instances: res, selectedInstance: selected }); - }, - updateAppConfig: async (data) => { - // don't emit event bcs this updates store anyway - const newConfig = await setAppConfig(data, false); - set({ appConfig: newConfig }); - return newConfig; - }, - }), - { - name: 'client-store', - storage: createJSONStorage(() => localStorage), - partialize: (store) => pickBy(store, persistedKeys), - version: 1, - }, - ), - Object.is, -); - -type Store = StoreValues & StoreMethods; - -type StoreValues = { - instances: DefguardInstance[]; - tunnels: CommonWireguardFields[]; - statsFilter: number; - selectedInstance?: SelectedInstance; - selectedLocation?: number; - // launch carousel page if there is no instances or/and tunnels for the first time after launching application - listChecked: boolean; - selectedView: ClientView; - appConfig: AppConfig; - platformInfo: PlatformInfo; -}; - -type StoreMethods = { - setState: (values: Partial) => void; - setInstances: (instances: DefguardInstance[]) => void; - setTunnels: (tunnels: CommonWireguardFields[]) => void; - setListChecked: (listChecked: boolean) => void; - updateInstances: () => Promise; - updateAppConfig: (data: Partial) => Promise; -}; diff --git a/src/pages/client/pages/CarouselPage/CarouselPage.tsx b/src/pages/client/pages/CarouselPage/CarouselPage.tsx deleted file mode 100644 index c98a6ecca..000000000 --- a/src/pages/client/pages/CarouselPage/CarouselPage.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import './style.scss'; - -import { useEffect } from 'react'; - -import { useClientFlags } from '../../hooks/useClientFlags'; -import { - InstancesSlide, - SecuritySlide, - SupportSlide, - TwoFaSlide, - WelcomeCardSlide, -} from './cards/CarouselCards'; -import { CardCarousel } from './components/CardCarousel/CardCarousel'; -import type { CarouselItem } from './components/CardCarousel/types'; - -const slides: CarouselItem[] = [ - { - key: 'welcome', - element: , - }, - { - key: 'twofa', - element: , - }, - { - element: , - key: 'security', - }, - { - key: 'instances', - element: , - }, - { - key: 'support', - element: , - }, -]; - -export const CarouselPage = () => { - const setClientFlags = useClientFlags((state) => state.setValues); - - useEffect(() => { - setClientFlags({ firstStart: false }); - // eslint-next-line-ignore - }, [setClientFlags]); - - return ( - - ); -}; diff --git a/src/pages/client/pages/CarouselPage/cards/CarouselCards.tsx b/src/pages/client/pages/CarouselPage/cards/CarouselCards.tsx deleted file mode 100644 index e3f6f97bb..000000000 --- a/src/pages/client/pages/CarouselPage/cards/CarouselCards.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import './style.scss'; - -import Markdown from 'react-markdown'; -import { useNavigate } from 'react-router-dom'; - -import { useI18nContext } from '../../../../../i18n/i18n-react'; -import { IconDefguard } from '../../../../../shared/components/icons/IconDefguard/IconDeguard'; -import SvgDefguardLogoText from '../../../../../shared/components/svg/DefguardLogoText'; -import { GitHubIcon } from '../../../../../shared/components/svg/GithubIcon'; -import { discussionsUrl, githubUrl, mastodonUrl } from '../../../../../shared/constants'; -import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../shared/defguard-ui/components/Layout/Button/types'; -import { Card } from '../../../../../shared/defguard-ui/components/Layout/Card/Card'; -import { defguardGithubLink } from '../../../../../shared/links'; -import { routes } from '../../../../../shared/routes'; -import { clientApi } from '../../../clientAPI/clientApi'; -import twoFactorImage from './assets/slide_2fa.png'; -import instancesImage from './assets/slide_instances.png'; -import securityImage from './assets/slide_security.png'; - -const { openLink } = clientApi; - -export const WelcomeCardSlide = () => { - const { LL } = useI18nContext(); - const localLL = LL.pages.client.pages.carouselPage.slides.welcome; - const navigate = useNavigate(); - - return ( - -

- {localLL.title()} -

-
-
navigate(routes.client.addInstance, { replace: true })} - > -

{localLL.instance.title()}

-

{localLL.instance.subtitle()}

-
- - -
-
-
navigate(routes.client.addTunnel, { replace: true })} - > -

{localLL.tunnel.title()}

-

{localLL.tunnel.subtitle()}

- -
-
-
- ); -}; - -export const TwoFaSlide = () => { - const { LL } = useI18nContext(); - const localLL = LL.pages.client.pages.carouselPage.slides.twoFa; - return ( - -

- {localLL.title()} -

-
- -
- {localLL.sideText()} -
-
- -
- ); -}; - -const GithubButton = () => { - const { LL } = useI18nContext(); - const localLL = LL.pages.client.pages.carouselPage.slides.shared; - return ( - - ))} - - ); -}; diff --git a/src/pages/client/pages/CarouselPage/components/CardCarousel/components/CarouselControl/style.scss b/src/pages/client/pages/CarouselPage/components/CardCarousel/components/CarouselControl/style.scss deleted file mode 100644 index 70b1163f2..000000000 --- a/src/pages/client/pages/CarouselPage/components/CardCarousel/components/CarouselControl/style.scss +++ /dev/null @@ -1,45 +0,0 @@ -.carousel-control { - display: flex; - flex-flow: row; - align-items: center; - justify-content: center; - width: 100%; - height: 40px; - gap: 0; - - & > button { - display: flex; - flex-flow: row nowrap; - align-items: center; - justify-content: center; - width: 40px; - height: 100%; - position: relative; - overflow: hidden; - cursor: pointer; - border: 0px solid transparent; - background: none; - - .dot { - display: block; - content: ' '; - height: 14px; - width: 14px; - background-color: var(--surface-scroll-inactive); - transition-property: background-color; - transition-timing-function: ease-in-out; - transition-duration: 50ms; - border-radius: 50%; - - &.active { - background-color: var(--surface-main-primary); - } - } - - &:hover { - .dot { - background-color: var(--surface-main-primary); - } - } - } -} diff --git a/src/pages/client/pages/CarouselPage/components/CardCarousel/style.scss b/src/pages/client/pages/CarouselPage/components/CardCarousel/style.scss deleted file mode 100644 index fe4c11292..000000000 --- a/src/pages/client/pages/CarouselPage/components/CardCarousel/style.scss +++ /dev/null @@ -1,28 +0,0 @@ -.card-carousel { - display: flex; - flex-flow: column; - align-items: center; - justify-content: center; - row-gap: 30px; - min-width: 900px; - overflow-x: auto; - - & > .card-wrapper { - display: block; - width: 100%; - max-width: 1200px; - - & > .card { - width: 100%; - max-width: inherit; - min-height: 720px; - overflow: hidden; - box-sizing: border-box; - padding: 60px; - display: flex; - flex-flow: column; - align-items: center; - justify-content: flex-start; - } - } -} diff --git a/src/pages/client/pages/CarouselPage/components/CardCarousel/types.ts b/src/pages/client/pages/CarouselPage/components/CardCarousel/types.ts deleted file mode 100644 index bde2adf38..000000000 --- a/src/pages/client/pages/CarouselPage/components/CardCarousel/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react'; - -export type CarouselItem = { - key: string; - element: ReactNode; -}; diff --git a/src/pages/client/pages/CarouselPage/style.scss b/src/pages/client/pages/CarouselPage/style.scss deleted file mode 100644 index 5c38f6f49..000000000 --- a/src/pages/client/pages/CarouselPage/style.scss +++ /dev/null @@ -1,222 +0,0 @@ -#carousel-page { - .card { - box-sizing: border-box; - padding: 60px; - - .github { - height: 60px; - width: 270px; - - p, - span { - @include typography(app-button-l); - text-decoration: none; - } - } - - a { - text-decoration: underline; - cursor: pointer; - } - - .more { - @include typography(app-body-1); - } - - ul { - margin: 0; - } - - h2 { - font-family: 'Poppins'; - font-size: 48px; - font-style: normal; - color: var(--text-body-primary); - line-height: normal; - text-align: center; - width: 100%; - font-weight: 400; - min-height: 72px; - } - - strong, - b { - font-weight: 700; - } - - .row { - width: 100%; - display: grid; - grid-template-rows: auto auto; - grid-template-columns: 1fr; - align-items: center; - justify-items: center; - row-gap: 20px; - column-gap: 10px; - - @include media-breakpoint-up(xxl) { - grid-template-rows: auto; - grid-template-columns: 1fr 1fr; - column-gap: 40px; - row-gap: 0; - } - - & > .image-box { - width: 100%; - height: 301px; - border: none; - border-radius: 15px; - box-shadow: var(--box-shadow); - background-size: cover; - } - - &.between { - @include media-breakpoint-up(xl) { - grid-template-columns: auto auto; - grid-template-rows: 1fr; - justify-items: space-between; - } - } - } - - .text { - display: flex; - flex-flow: column; - align-items: flex-start; - justify-content: flex-start; - row-gap: 20px; - max-width: 650px; - - @include typography(app-welcome-2); - text-align: center; - - @include media-breakpoint-up(xl) { - text-align: left; - } - - strong, - b { - font-weight: 700; - } - - &.centered { - justify-content: center; - } - } - } - - #welcome-slide { - h2 { - padding-bottom: 40px; - display: block; - } - - & > .row { - padding: 0 60px; - } - - .wireguard-logo { - path { - fill: var(--text-body-primary); - } - } - - .logo-container { - width: 100%; - height: 85px; - display: flex; - align-items: center; - justify-content: center; - flex-flow: row nowrap; - column-gap: 13px; - - :nth-child(1) { - width: 40px; - height: 100%; - } - - :nth-child(2) { - width: 159px; - height: 46px; - - path { - fill: var(--text-body-primary); - } - } - } - - .inner-card { - display: flex; - flex-flow: column; - align-items: center; - justify-content: center; - background-color: var(--surface-frame-bg); - border-radius: 15px; - box-shadow: var(--box-shadow); - min-height: 415px; - box-sizing: border-box; - padding: 64px; - width: 420px; - overflow: hidden; - user-select: none; - - h3 { - @include typography(app-body-1); - margin-bottom: 20px; - } - - p { - @include typography(welcome-h2); - text-align: center; - color: var(--text-body-tertiary); - margin-bottom: 45px; - max-width: 100%; - } - } - } - - #factor-slide, - #security-slide, - #instances-slide, - #support-slide, - #welcome-slide { - min-height: 750px; - } - - #factor-slide, - #security-slide, - #instances-slide, - #support-slide { - justify-content: space-between; - row-gap: 25px; - } - - #support-slide { - .text { - max-width: 600px; - user-select: text; - - p { - margin-bottom: 20px; - } - } - - .logo-container { - height: 118px; - width: 100%; - display: flex; - flex-flow: row nowrap; - column-gap: 18px; - - :nth-child(1) { - height: 100%; - } - - :nth-child(2) { - path { - fill: var(--text-body-primary); - } - } - } - } -} diff --git a/src/pages/client/pages/ClientAddInstancePage/ClientAddInstnacePage.tsx b/src/pages/client/pages/ClientAddInstancePage/ClientAddInstnacePage.tsx deleted file mode 100644 index e42d6f0f4..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/ClientAddInstnacePage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import './style.scss'; - -import { useI18nContext } from '../../../../i18n/i18n-react'; -import { AddInstanceFormCard } from './components/AddInstanceFormCard/AddInstanceFormCard'; -import { AddInstanceGuide } from './components/AddInstanceGuide/AddInstanceGuide'; - -export const ClientAddInstancePage = () => { - const { LL } = useI18nContext(); - return ( -
-
-

{LL.pages.client.pages.addInstancePage.title()}

-
-
- - -
-
- ); -}; diff --git a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/AddInstanceFormCard.tsx b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/AddInstanceFormCard.tsx deleted file mode 100644 index 48e4f19f9..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/AddInstanceFormCard.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Card } from '../../../../../../shared/defguard-ui/components/Layout/Card/Card'; -import { AddInstanceFormStep } from '../../hooks/types'; -import { useAddInstanceStore } from '../../hooks/useAddInstanceStore'; -import { AddInstanceDeviceForm } from './components/AddInstanceDeviceForm/AddInstanceDeviceForm'; -import { AddInstanceInitForm } from './components/AddInstanceInitForm/AddInstanceInitForm'; - -export const AddInstanceFormCard = () => { - const currentStep = useAddInstanceStore((s) => s.step); - return ( - - {currentStep === AddInstanceFormStep.INIT && } - {currentStep === AddInstanceFormStep.DEVICE && } - - ); -}; diff --git a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceDeviceForm/AddInstanceDeviceForm.tsx b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceDeviceForm/AddInstanceDeviceForm.tsx deleted file mode 100644 index e91f4c35e..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceDeviceForm/AddInstanceDeviceForm.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import './style.scss'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useQuery } from '@tanstack/react-query'; -import { fetch } from '@tauri-apps/plugin-http'; -import { error } from '@tauri-apps/plugin-log'; -import { hostname } from '@tauri-apps/plugin-os'; -import { useCallback, useMemo, useState } from 'react'; -import { type SubmitHandler, useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom'; -import { z } from 'zod'; -import { useI18nContext } from '../../../../../../../../i18n/i18n-react'; -import { FormInput } from '../../../../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; -import { Button } from '../../../../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../../../../shared/defguard-ui/components/Layout/Button/types'; -import { useToaster } from '../../../../../../../../shared/defguard-ui/hooks/toasts/useToaster'; -import { isPresent } from '../../../../../../../../shared/defguard-ui/utils/isPresent'; -import type { - CreateDeviceRequest, - CreateDeviceResponse, -} from '../../../../../../../../shared/hooks/api/types'; -import { routes } from '../../../../../../../../shared/routes'; -import { errorDetail } from '../../../../../../../../shared/utils/errorDetail'; -import { generateWGKeys } from '../../../../../../../../shared/utils/generateWGKeys'; -import { clientApi } from '../../../../../../clientAPI/clientApi'; -import { useClientStore } from '../../../../../../hooks/useClientStore'; -import { ClientConnectionType, type SelectedInstance } from '../../../../../../types'; -import { useAddInstanceStore } from '../../../../hooks/useAddInstanceStore'; -import type { AddInstanceInitResponse } from '../../types'; - -const { getInstances, saveConfig } = clientApi; - -type ErrorData = { - error: string; -}; - -export const AddInstanceDeviceForm = () => { - const { LL } = useI18nContext(); - const localLL = LL.pages.client.pages.addInstancePage.forms.device; - const toaster = useToaster(); - const setClientStore = useClientStore((state) => state.setState); - const platformInfo = useClientStore((state) => state.platformInfo); - const navigate = useNavigate(); - const [isLoading, setIsLoading] = useState(false); - const response = useAddInstanceStore((s) => s.response as AddInstanceInitResponse); - const resetAddInstanceStore = useAddInstanceStore((s) => s.reset); - - const { url: proxyUrl, cookie, device_names } = response; - - const { data: instancesCount } = useQuery({ - queryFn: getInstances, - queryKey: ['instance', 'count'], - select: (data) => data.length, - }); - - const schema = useMemo( - () => - z.object({ - name: z - .string() - .trim() - .min(1, LL.form.errors.required()) - .refine((val) => !device_names.includes(val), { - message: LL.form.errors.duplicatedName(), - }), - }), - [LL.form.errors, device_names], - ); - - type FormFields = z.infer; - - const defaultValues = useCallback(async (): Promise => { - const name = await hostname(); - return { - name: name ?? '', - }; - }, []); - - const { control, handleSubmit } = useForm({ - defaultValues: defaultValues, - resolver: zodResolver(schema), - mode: 'all', - }); - - const handleValidSubmit: SubmitHandler = async (values) => { - if (!proxyUrl) return; - setIsLoading(true); - const { publicKey, privateKey } = generateWGKeys(); - const data: CreateDeviceRequest = { - name: values.name, - pubkey: publicKey, - }; - - const headers = { - 'Content-Type': 'application/json', - Cookie: cookie, - CLIENT_VERSION_HEADER: platformInfo.client_version, - CLIENT_PLATFORM_HEADER: platformInfo.platform_info, - }; - try { - await fetch(`${proxyUrl}/enrollment/create_device`, { - headers, - body: JSON.stringify(data), - method: 'POST', - }).then(async (r) => { - if (!r.ok) { - setIsLoading(false); - const data = (await r.json()) as ErrorData; - const details = `${data?.error ? `${data.error}, ` : ''}`; - error( - `Failed to create device. Check enrollment and Defguard logs, details: ${details}. Error status code: ${r.status}`, - ); - throw Error(`Failed to create device, details: ${details} `); - } - const deviceResp = (await r.json()) as CreateDeviceResponse; - saveConfig({ - privateKey: privateKey, - response: deviceResp, - }) - .then(async (res) => { - setIsLoading(false); - toaster.success(localLL.messages.addSuccess()); - const instances = await getInstances(); - const selectedInstance: SelectedInstance = { - id: res.instance.id, - type: ClientConnectionType.LOCATION, - }; - setClientStore({ selectedInstance, instances }); - setTimeout(() => { - resetAddInstanceStore(); - }, 250); - navigate(routes.client.instancePage, { replace: true }); - }) - .catch((e) => { - const detail = errorDetail(e); - error(`Failed to save device config: ${detail}`); - toaster.error( - LL.common.messages.errorWithMessage({ - message: String(e), - }), - ); - setIsLoading(false); - }); - }); - } catch (e) { - setIsLoading(false); - const detail = errorDetail(e); - error(`Device form submit failed for proxy ${proxyUrl}: ${detail}`); - - if (typeof e === 'string') { - if (e.includes('Network Error')) { - toaster.error(LL.common.messages.networkError()); - return; - } - toaster.error( - LL.common.messages.errorWithMessage({ - message: String(e), - }), - ); - } else { - toaster.error( - LL.common.messages.errorWithMessage({ - message: (e as Error).message, - }), - ); - } - } - }; - - return ( - <> -

{localLL.title()}

-
- -
-
- - - ); -}; diff --git a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceDeviceForm/style.scss b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceDeviceForm/style.scss deleted file mode 100644 index fe83d8a73..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceDeviceForm/style.scss +++ /dev/null @@ -1,4 +0,0 @@ -.controls { - display: flex; - gap: 10px; -} diff --git a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceInitForm/AddInstanceInitForm.tsx b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceInitForm/AddInstanceInitForm.tsx deleted file mode 100644 index 98ebf7917..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceInitForm/AddInstanceInitForm.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import './style.scss'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { invoke } from '@tauri-apps/api/core'; -import { fetch } from '@tauri-apps/plugin-http'; -import { debug, error, info } from '@tauri-apps/plugin-log'; -import dayjs from 'dayjs'; -import { useMemo, useState } from 'react'; -import { type SubmitHandler, useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom'; -import { z } from 'zod'; -import { useI18nContext } from '../../../../../../../../i18n/i18n-react'; -import { FormInput } from '../../../../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; -import { Button } from '../../../../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../../../../shared/defguard-ui/components/Layout/Button/types'; -import { useToaster } from '../../../../../../../../shared/defguard-ui/hooks/toasts/useToaster'; -import type { - CreateDeviceResponse, - EnrollmentError, - EnrollmentStartResponse, -} from '../../../../../../../../shared/hooks/api/types'; -import { routes } from '../../../../../../../../shared/routes'; -import { errorDetail } from '../../../../../../../../shared/utils/errorDetail'; -import { useEnrollmentStore } from '../../../../../../../enrollment/hooks/store/useEnrollmentStore'; -import { clientApi } from '../../../../../../clientAPI/clientApi'; -import { useClientStore } from '../../../../../../hooks/useClientStore'; -import { ClientConnectionType, type SelectedInstance } from '../../../../../../types'; -import { AddInstanceFormStep } from '../../../../hooks/types'; -import { useAddInstanceStore } from '../../../../hooks/useAddInstanceStore'; - -export const AddInstanceInitForm = () => { - const setPageState = useAddInstanceStore((s) => s.setState); - const toaster = useToaster(); - const navigate = useNavigate(); - const { LL } = useI18nContext(); - const localLL = LL.pages.client.pages.addInstancePage.forms.initInstance; - const [isLoading, setIsLoading] = useState(false); - const initEnrollment = useEnrollmentStore((state) => state.init); - const setClientState = useClientStore((state) => state.setState); - - const schema = useMemo( - () => - z.object({ - url: z - .string() - .trim() - .min(1, LL.form.errors.required()) - .url(LL.form.errors.invalid()), - token: z.string().trim().min(1, LL.form.errors.required()), - }), - [LL.form.errors], - ); - - type FormFields = z.infer; - - const { handleSubmit, control } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - url: '', - token: '', - }, - mode: 'all', - }); - - const handleValidSubmit: SubmitHandler = async (values) => { - const url = () => { - const endpoint = '/api/v1/enrollment/start'; - let base: string; - if (values.url[values.url.length - 1] === '/') { - base = values.url.slice(0, -1); - } else { - base = values.url; - } - return base + endpoint; - }; - - const endpointUrl = url(); - - const headers: Record = { - 'Content-Type': 'application/json', - }; - - const data = { - token: values.token, - }; - - setIsLoading(true); - fetch(endpointUrl, { - method: 'POST', - headers, - body: JSON.stringify(data), - }) - .then(async (res: Response) => { - if (!res.ok) { - setIsLoading(false); - error( - `Enrollment start returned non-OK status ${res.status} for URL: ${endpointUrl}`, - ); - const errorMessage = ((await res.json()) as EnrollmentError).error; - - switch (errorMessage) { - case 'token expired': { - throw Error(LL.common.messages.tokenExpired()); - } - default: { - throw Error( - LL.common.messages.errorWithMessage({ - message: errorMessage, - }), - ); - } - } - } - // There may be other set-cookies, set by e.g. a proxy - // Get only the defguard_proxy cookie - const authCookie = res.headers - .getSetCookie() - .find((cookie) => cookie.startsWith('defguard_proxy=')); - if (!authCookie) { - setIsLoading(false); - error( - `Enrollment start response for ${endpointUrl} is missing defguard_proxy set-cookie header`, - ); - throw Error( - LL.common.messages.errorWithMessage({ - message: LL.common.messages.noCookie(), - }), - ); - } - debug('Response received with status OK'); - const startResponse = (await res.json()) as EnrollmentStartResponse; - // get client registered instances - const clientInstances = await clientApi.getInstances(); - const instance = clientInstances.find( - (i) => i.uuid === startResponse.instance.id, - ); - let proxy_api_url = values.url; - if (proxy_api_url[proxy_api_url.length - 1] === '/') { - proxy_api_url = proxy_api_url.slice(0, -1); - } - proxy_api_url = `${proxy_api_url}/api/v1`; - setIsLoading(false); - - if (instance) { - debug('Instance already exists, fetching update'); - // update already registered instance instead - headers.Cookie = authCookie; - fetch(`${proxy_api_url}/enrollment/network_info`, { - method: 'POST', - headers, - body: JSON.stringify({ - pubkey: instance.pubkey, - }), - }) - .then(async (res) => { - if (!res.ok) { - const detail = `network_info returned status ${res.status} for instance ${instance.uuid}`; - error(`Failed to fetch network info: ${detail}`); - toaster.error( - LL.common.messages.errorWithMessage({ - message: detail, - }), - ); - return; - } - invoke('update_instance', { - instanceId: instance.id, - response: (await res.json()) as CreateDeviceResponse, - }) - .then(() => { - info('Configured device'); - toaster.success( - LL.pages.enrollment.steps.deviceSetup.desktopSetup.messages.deviceConfigured(), - ); - const _selectedInstance: SelectedInstance = { - id: instance.id, - type: ClientConnectionType.LOCATION, - }; - setClientState({ - selectedInstance: _selectedInstance, - }); - navigate(routes.client.base, { replace: true }); - }) - .catch((e) => { - const detail = errorDetail(e); - error(`Failed to save config during instance add: ${detail}`); - toaster.error( - LL.common.messages.errorWithMessage({ - message: String(e), - }), - ); - }); - }) - .catch((e) => { - const detail = errorDetail(e); - error( - `Failed to reach network_info endpoint for instance ${instance.uuid}: ${detail}`, - ); - toaster.error( - LL.common.messages.errorWithMessage({ - message: String(e), - }), - ); - }); - } - // register new instance - // is user in need of full enrollment ? - if (startResponse.user.enrolled) { - //no, only create new device for desktop client - debug('User already active, adding device only.'); - setPageState({ - step: AddInstanceFormStep.DEVICE, - response: { - url: proxy_api_url, - cookie: authCookie, - device_names: startResponse.user.device_names, - }, - }); - } else { - // yes, enroll the user - debug('User is not active. Starting enrollment.'); - const sessionEnd = dayjs - .unix(startResponse.deadline_timestamp) - .utc() - .local() - .format(); - const sessionStart = dayjs().local().format(); - initEnrollment({ - userInfo: startResponse.user, - adminInfo: startResponse.admin, - endContent: startResponse.final_page_content, - proxy_url: proxy_api_url, - enrollmentSettings: startResponse.settings, - sessionEnd, - sessionStart, - cookie: authCookie, - }); - navigate(routes.enrollment, { replace: true }); - } - }) - .catch((e) => { - setIsLoading(false); - const detail = errorDetail(e); - error(`Failed to initialize instance: ${detail}`); - if (typeof e === 'string') { - if (e.includes('Network Error')) { - toaster.error(LL.common.messages.networkError()); - return; - } - toaster.error( - LL.common.messages.errorWithMessage({ - message: String(e), - }), - ); - } else { - toaster.error( - LL.common.messages.errorWithMessage({ - message: (e as Error).message, - }), - ); - } - }); - }; - - return ( - <> -

{localLL.title()}

-
- - -
-
- - - ); -}; diff --git a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceInitForm/style.scss b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceInitForm/style.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/types.ts b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/types.ts deleted file mode 100644 index d078e3168..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type AddInstanceInitResponse = { - url: string; - cookie: string; - device_names: string[]; -}; diff --git a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceGuide/AddInstanceGuide.tsx b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceGuide/AddInstanceGuide.tsx deleted file mode 100644 index d029378b3..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceGuide/AddInstanceGuide.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import './style.scss'; - -import parse from 'html-react-parser'; - -import { useI18nContext } from '../../../../../../i18n/i18n-react'; -import SvgVpnLocation from '../../../../../../shared/components/svg/VpnLocation'; -import { Card } from '../../../../../../shared/defguard-ui/components/Layout/Card/Card'; - -export const AddInstanceGuide = () => { - const { LL } = useI18nContext(); - const localLL = LL.pages.client.pages.addInstancePage.guide; - return ( -
-
-

{localLL.title()}

-

{localLL.subTitle()}

-
- - -

{localLL.card.title()}

- {parse(localLL.card.content())} -
-
- ); -}; diff --git a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceGuide/style.scss b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceGuide/style.scss deleted file mode 100644 index 294fffd03..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceGuide/style.scss +++ /dev/null @@ -1,43 +0,0 @@ -#add-instance-guide { - display: flex; - flex-flow: column; - align-items: center; - justify-content: flex-start; - row-gap: 50px; - - p { - @include typography(app-body-2); - - color: var(--text-body-primary); - } - - #instance-guide { - * { - text-align: center; - width: 100%; - } - - h2 { - padding-bottom: 20px; - } - - p { - max-width: 500px; - } - } - - #token-guide { - display: flex; - flex-flow: column; - align-items: flex-start; - justify-content: flex-start; - row-gap: 20px; - box-sizing: border-box; - padding: 20px 25px; - - & > div { - box-sizing: border-box; - padding-left: 20px; - } - } -} diff --git a/src/pages/client/pages/ClientAddInstancePage/hooks/types.ts b/src/pages/client/pages/ClientAddInstancePage/hooks/types.ts deleted file mode 100644 index 29bd4099d..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/hooks/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum AddInstanceFormStep { - INIT, - DEVICE, -} diff --git a/src/pages/client/pages/ClientAddInstancePage/hooks/useAddInstanceStore.tsx b/src/pages/client/pages/ClientAddInstancePage/hooks/useAddInstanceStore.tsx deleted file mode 100644 index 52e7a68ff..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/hooks/useAddInstanceStore.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { createWithEqualityFn } from 'zustand/traditional'; -import type { AddInstanceInitResponse } from '../components/AddInstanceFormCard/types'; -import { AddInstanceFormStep } from './types'; - -const defaults: StoreValues = { - step: AddInstanceFormStep.INIT, - response: undefined, -}; - -export const useAddInstanceStore = createWithEqualityFn((set) => ({ - ...defaults, - setState: (values) => set((old) => ({ ...old, ...values })), - reset: () => set(defaults), -})); - -type Store = StoreMethods & StoreValues; - -type StoreMethods = { - setState: (values: Partial) => void; - reset: () => void; -}; - -type StoreValues = { - step: AddInstanceFormStep; - response?: AddInstanceInitResponse; -}; diff --git a/src/pages/client/pages/ClientAddInstancePage/style.scss b/src/pages/client/pages/ClientAddInstancePage/style.scss deleted file mode 100644 index 37cc5ecbc..000000000 --- a/src/pages/client/pages/ClientAddInstancePage/style.scss +++ /dev/null @@ -1,80 +0,0 @@ -#client-add-instance-page { - h1 { - @include typography(app-title); - color: var(--text-body-primary); - } - - h2 { - @include typography(app-body-1); - color: var(--text-body-primary); - } - - form { - & > * { - width: 100%; - } - - .controls { - display: flex; - flex-flow: row; - align-items: center; - justify-content: center; - - .btn { - width: 100%; - max-width: 200px; - } - } - } - - & > header { - width: 100%; - display: flex; - flex-flow: row; - align-items: center; - justify-content: flex-start; - padding-bottom: 15px; - - & > h1 { - text-align: left; - } - } - - & > .content { - width: 100%; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); - align-items: start; - justify-items: center; - column-gap: 25px; - row-gap: 25px; - - @include media-breakpoint-up(xl) { - column-gap: 50px; - } - - & > * { - width: 100%; - flex-grow: 1; - } - - & > .card { - box-sizing: border-box; - padding: 32px 64px; - - & > h2 { - width: 100%; - text-align: center; - padding-bottom: 42px; - } - - form > .controls { - padding-top: 42px; - - .btn { - height: 47px; - } - } - } - } -} diff --git a/src/pages/client/pages/ClientAddTunnelPage/AddTunnelGuide/AddTunnelGuide.tsx b/src/pages/client/pages/ClientAddTunnelPage/AddTunnelGuide/AddTunnelGuide.tsx deleted file mode 100644 index ff8ccd80d..000000000 --- a/src/pages/client/pages/ClientAddTunnelPage/AddTunnelGuide/AddTunnelGuide.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import './style.scss'; - -import parse from 'html-react-parser'; - -import { useI18nContext } from '../../../../../i18n/i18n-react'; -import SvgVpnLocation from '../../../../../shared/components/svg/VpnLocation'; -import { Card } from '../../../../../shared/defguard-ui/components/Layout/Card/Card'; - -export const AddTunnelGuide = () => { - const { LL } = useI18nContext(); - const localLL = LL.pages.client.pages.addTunnelPage.guide; - return ( -
-
-

{localLL.title()}

- {parse(localLL.subTitle())} -
- - -

{localLL.card.title()}:

- {parse(localLL.card.content())} -
-
- ); -}; diff --git a/src/pages/client/pages/ClientAddTunnelPage/AddTunnelGuide/style.scss b/src/pages/client/pages/ClientAddTunnelPage/AddTunnelGuide/style.scss deleted file mode 100644 index 0d352ac92..000000000 --- a/src/pages/client/pages/ClientAddTunnelPage/AddTunnelGuide/style.scss +++ /dev/null @@ -1,55 +0,0 @@ -#add-tunnel-guide { - display: flex; - flex-flow: column; - align-items: center; - justify-content: flex-start; - row-gap: 50px; - - p { - @include typography(app-body-2); - - color: var(--text-body-primary); - } - - a { - @include typography(app-body-2); - - color: var(--text-body-primary); - } - - li { - @include typography(app-body-2); - - color: var(--text-body-primary); - } - - #tunnel-guide { - * { - text-align: center; - width: 100%; - } - - h2 { - padding-bottom: 20px; - } - - p { - max-width: 500px; - } - } - - #setup-guide { - display: flex; - flex-flow: column; - align-items: flex-start; - justify-content: flex-start; - row-gap: 20px; - box-sizing: border-box; - padding: 20px 25px; - - & > div { - box-sizing: border-box; - padding-left: 20px; - } - } -} diff --git a/src/pages/client/pages/ClientAddTunnelPage/ClientAddTunnelPage.tsx b/src/pages/client/pages/ClientAddTunnelPage/ClientAddTunnelPage.tsx deleted file mode 100644 index 2d173a1a2..000000000 --- a/src/pages/client/pages/ClientAddTunnelPage/ClientAddTunnelPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import './style.scss'; - -import { useI18nContext } from '../../../../i18n/i18n-react'; -import { AddTunnelGuide } from './AddTunnelGuide/AddTunnelGuide'; -import { AddTunnelFormCard } from './components/AddTunnelFormCard/AddTunnelFormCard'; - -export const ClientAddTunnelPage = () => { - const { LL } = useI18nContext(); - return ( -
-
-

{LL.pages.client.pages.addTunnelPage.title()}

-
-
- - -
-
- ); -}; diff --git a/src/pages/client/pages/ClientAddTunnelPage/components/AddTunnelFormCard/AddTunnelFormCard.tsx b/src/pages/client/pages/ClientAddTunnelPage/components/AddTunnelFormCard/AddTunnelFormCard.tsx deleted file mode 100644 index 64bfd814d..000000000 --- a/src/pages/client/pages/ClientAddTunnelPage/components/AddTunnelFormCard/AddTunnelFormCard.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import './style.scss'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { pickBy } from 'lodash-es'; -import { useEffect, useMemo, useState } from 'react'; -import { type SubmitHandler, useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom'; -import { z } from 'zod'; - -import { useI18nContext } from '../../../../../../i18n/i18n-react'; -import { FormInput } from '../../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; -import { ArrowSingle } from '../../../../../../shared/defguard-ui/components/icons/ArrowSingle/ArrowSingle'; -import { - ArrowSingleDirection, - ArrowSingleSize, -} from '../../../../../../shared/defguard-ui/components/icons/ArrowSingle/types'; -import { Button } from '../../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../../shared/defguard-ui/components/Layout/Button/types'; -import { Card } from '../../../../../../shared/defguard-ui/components/Layout/Card/Card'; -import { Helper } from '../../../../../../shared/defguard-ui/components/Layout/Helper/Helper'; -import { useToaster } from '../../../../../../shared/defguard-ui/hooks/toasts/useToaster'; -import { - cidrRegex, - patternValidEndpoint, - patternValidIp, - patternValidIpV6, - patternValidIpV6WithPort, - patternValidWireguardKey, -} from '../../../../../../shared/patterns'; -import { routes } from '../../../../../../shared/routes'; -import { generateWGKeys } from '../../../../../../shared/utils/generateWGKeys'; -import { validateIpOrDomainList } from '../../../../../../shared/validators/tunnel'; -import { clientApi } from '../../../../clientAPI/clientApi'; - -type FormFields = { - id: null; - name: string; - pubkey: string; - prvkey: string; - address: string; - server_pubkey: string; - preshared_key: string; - allowed_ips?: string; - endpoint: string; - dns?: string; - persistent_keep_alive: number; - route_all_traffic: boolean; - pre_up?: string; - post_up?: string; - pre_down?: string; - post_down?: string; -}; -const defaultValues: FormFields = { - id: null, - name: '', - pubkey: '', - prvkey: '', - address: '', - server_pubkey: '', - preshared_key: '', - allowed_ips: '', - endpoint: '', - dns: '', - persistent_keep_alive: 25, // Adjust as needed - route_all_traffic: false, - pre_up: '', - post_up: '', - pre_down: '', - post_down: '', -}; - -export const AddTunnelFormCard = () => { - const { LL } = useI18nContext(); - const { parseTunnelConfig, saveTunnel } = clientApi; - const toaster = useToaster(); - const navigate = useNavigate(); - - const localLL = LL.pages.client.pages.addTunnelPage.forms.initTunnel; - /* eslint-disable no-useless-escape */ - const schema = useMemo( - () => - z.object({ - id: z.null(), - name: z.string().trim().min(1, LL.form.errors.required()), - pubkey: z - .string() - .trim() - .min(1, LL.form.errors.required()) - .refine((value) => { - return patternValidWireguardKey.test(value); - }, LL.form.errors.invalid()), - prvkey: z - .string() - .trim() - .min(1, LL.form.errors.required()) - .refine((value) => { - return patternValidWireguardKey.test(value); - }, LL.form.errors.invalid()), - server_pubkey: z - .string() - .trim() - .min(1, LL.form.errors.required()) - .refine((value) => { - return patternValidWireguardKey.test(value); - }, LL.form.errors.invalid()), - preshared_key: z - .string() - .trim() - .refine((value) => { - return value === '' || patternValidWireguardKey.test(value); - }, LL.form.errors.invalid()), - address: z.string().refine((value) => { - if (value) { - const ips = value.split(',').map((ip) => ip.trim()); - return ips.every( - (ip) => patternValidIp.test(ip) || patternValidIpV6.test(ip), - ); - } - return false; - }, LL.form.errors.invalid()), - endpoint: z - .string() - .min(1, LL.form.errors.required()) - .refine((value) => { - return ( - patternValidEndpoint.test(value) || patternValidIpV6WithPort.test(value) - ); - }, LL.form.errors.invalid()), - dns: z - .string() - .refine((value) => { - if (value && value.length !== 0) { - return validateIpOrDomainList(value, ',', true); - } - return true; - }, LL.form.errors.invalid()) - .optional(), - allowed_ips: z.string().refine((value) => { - if (value) { - const ips = value.split(',').map((ip) => ip.trim()); - return ips.every((ip) => cidrRegex.test(ip)); - } - return true; - }, LL.form.errors.invalid()), - persistent_keep_alive: z.coerce.number(), - route_all_traffic: z.boolean(), - pre_up: z.string().nullable(), - post_up: z.string().nullable(), - pre_down: z.string().nullable(), - post_down: z.string().nullable(), - }), - [LL.form.errors], - ); - const handleValidSubmit: SubmitHandler = (values) => { - saveTunnel(values) - .then(() => { - navigate(routes.client.tunnelCreated, { replace: true }); - toaster.success(localLL.messages.addSuccess()); - }) - .catch(() => toaster.error(localLL.messages.addError())); - }; - const { handleSubmit, control, reset, setValue } = useForm({ - resolver: zodResolver(schema), - defaultValues, - mode: 'all', - }); - - const [generatedKeys, setGeneratedKeys] = useState(false); - - const handleConfigUpload = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = false; - input.style.display = 'none'; - input.onchange = () => { - if (input.files && input.files.length === 1) { - const reader = new FileReader(); - reader.onload = () => { - if (reader.result && input.files) { - const res = reader.result; - const filename = input.files[0].name; - parseTunnelConfig(filename as string, res as string) - .then((data) => { - const fileData = data as Partial; - const trimed = pickBy( - fileData, - (value) => value !== undefined && value !== null, - ); - const parsedConfig = { ...defaultValues, ...trimed }; - reset(parsedConfig); - }) - .catch(() => toaster.error(localLL.messages.configError())); - } - }; - reader.onerror = () => { - toaster.error(localLL.messages.configError()); - }; - reader.readAsText(input.files[0]); - } - }; - input.click(); - }; - - const generateKeyPair = () => { - const { privateKey, publicKey } = generateWGKeys(); - setValue('prvkey', privateKey); - setValue('pubkey', publicKey); - setGeneratedKeys(true); - }; - - useEffect(() => { - const onPrvKeyChange = (e: Event) => { - const input = e.target as HTMLInputElement; - if (generatedKeys && input.value !== defaultValues.prvkey) { - setGeneratedKeys(false); - } - }; - - const prvKeyInput = document.getElementsByName('prvkey')[0]; - if (prvKeyInput) { - prvKeyInput.addEventListener('input', onPrvKeyChange); - - return () => { - prvKeyInput.removeEventListener('input', onPrvKeyChange); - }; - } - }, [generatedKeys]); - - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - - const handleToggleAdvancedOptions = () => { - setShowAdvancedOptions(!showAdvancedOptions); - }; - - return ( - -
-

Tunnel Configuration

-
-
-
-
-
- {localLL.helpers.name()}} - /> - {localLL.helpers.prvkey()}} - /> - {localLL.helpers.pubkey()}} - /> - {localLL.helpers.address()}} - /> -
-

{localLL.sections.vpnServer()}

- {localLL.helpers.serverPubkey()}} - /> - {localLL.helpers.presharedKey()}} - /> - {localLL.helpers.endpoint()}} - /> - {localLL.helpers.dns()}} - /> - {localLL.helpers.allowedIps()}} - /> - - {localLL.helpers.persistentKeepAlive()}} - /> -
-

{localLL.sections.advancedOptions()}

- {localLL.helpers.advancedOptions()} -
- -
-
- {localLL.helpers.preUp()}} - /> - {localLL.helpers.postUp()}} - /> - {localLL.helpers.preDown()}} - /> - {localLL.helpers.postDown()}} - /> -
-
-
- -
- ); -}; diff --git a/src/pages/client/pages/ClientAddTunnelPage/components/AddTunnelFormCard/style.scss b/src/pages/client/pages/ClientAddTunnelPage/components/AddTunnelFormCard/style.scss deleted file mode 100644 index a05b47159..000000000 --- a/src/pages/client/pages/ClientAddTunnelPage/components/AddTunnelFormCard/style.scss +++ /dev/null @@ -1,32 +0,0 @@ -#add-tunnel-form-card { - & > header { - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: flex-start; - padding-bottom: 10px; - gap: 10px; - - h2 { - text-wrap: nowrap; - } - - .controls { - margin-left: auto; - display: flex; - flex-flow: row nowrap; - gap: 10px; - align-items: center; - justify-content: flex-start; - - & > .btn { - min-width: 135px; - - span { - display: block; - padding: 0 1px; - } - } - } - } -} diff --git a/src/pages/client/pages/ClientAddTunnelPage/style.scss b/src/pages/client/pages/ClientAddTunnelPage/style.scss deleted file mode 100644 index 81ee24b10..000000000 --- a/src/pages/client/pages/ClientAddTunnelPage/style.scss +++ /dev/null @@ -1,125 +0,0 @@ -#client-add-tunnel-page { - h1 { - @include typography(app-title); - color: var(--text-body-primary); - } - - h2 { - @include typography(app-body-1); - color: var(--text-body-primary); - } - - h3 { - @include typography(app-side-bar); - color: var(--text-body-primary); - } - - form { - & > * { - width: 100%; - } - - .controls { - display: flex; - flex-flow: row; - align-items: center; - justify-content: center; - - .btn { - width: 100%; - max-width: 200px; - } - } - } - - & > header { - width: 100%; - display: flex; - flex-flow: row; - align-items: center; - justify-content: flex-start; - padding-bottom: 15px; - - & > h1 { - text-align: left; - } - } - - & > .content { - width: 100%; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); - align-items: start; - justify-items: center; - column-gap: 25px; - row-gap: 25px; - - @include media-breakpoint-up(xl) { - column-gap: 50px; - } - - & > * { - width: 100%; - flex-grow: 1; - } - - & > .card { - box-sizing: border-box; - padding: 32px 64px; - - form { - & > .client { - border-bottom: 1px solid var(--border-primary); - margin-bottom: 10px; - } - - & > h3 { - margin-bottom: 10px; - } - - .advanced-options-header { - display: flex; - align-items: center; - gap: 5px; - margin-bottom: 10px; - - & > button { - background: none; - border: none; - padding: 0; - margin: 0; - } - - .arrow-single { - width: 22px; - height: 22px; - margin-left: auto; - } - - .underscore { - flex-grow: 1; - border-bottom: 1px solid var(--border-primary); - margin-right: 10px; - } - } - - .advanced-options { - display: none; - transition: opacity 0.5s ease; - } - - .advanced-options.open { - display: block; - } - - > .controls { - padding-top: 42px; - - .btn { - height: 47px; - } - } - } - } - } -} diff --git a/src/pages/client/pages/ClientAddedPage/ClientAddedPage.tsx b/src/pages/client/pages/ClientAddedPage/ClientAddedPage.tsx deleted file mode 100644 index 65ef7ab37..000000000 --- a/src/pages/client/pages/ClientAddedPage/ClientAddedPage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import './style.scss'; - -import { useNavigate } from 'react-router-dom'; - -import { useI18nContext } from '../../../../i18n/i18n-react'; -import SvgVpnLocation from '../../../../shared/components/svg/VpnLocation'; -import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../shared/defguard-ui/components/Layout/Button/types'; -import { Card } from '../../../../shared/defguard-ui/components/Layout/Card/Card'; -import { routes } from '../../../../shared/routes'; -import { ClientConnectionType } from '../../types'; - -type Props = { - pageType: ClientConnectionType; -}; - -export const ClientAddedPage = ({ pageType }: Props) => { - const { LL } = useI18nContext(); - const navigate = useNavigate(); - const [localLL, navigateRoute] = - pageType === ClientConnectionType.TUNNEL - ? [LL.pages.client.pages.createdPage.tunnel, routes.client.addTunnel] - : [LL.pages.client.pages.createdPage.instance, routes.client.addInstance]; - - return ( -
-
- -
-

{localLL.title()}

- -

{localLL.content()}

-
-
-
-
- ); -}; diff --git a/src/pages/client/pages/ClientAddedPage/style.scss b/src/pages/client/pages/ClientAddedPage/style.scss deleted file mode 100644 index e50bc5b93..000000000 --- a/src/pages/client/pages/ClientAddedPage/style.scss +++ /dev/null @@ -1,47 +0,0 @@ -#created-page { - h2 { - @include typography(app-body-1); - } - - display: flex; - justify-content: center; - align-items: center; - overflow-x: auto; - - & > .content { - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: center; - - @include media-breakpoint-up(xxl) { - justify-content: flex-start; - } - - & > .card { - box-sizing: border-box; - padding: 32px 64px; - max-width: 700px; - min-width: 300px; - display: flex; - - & > .card-content { - display: flex; - justify-content: flex-start; - align-items: center; - flex-direction: column; - gap: 50px; - - & > p { - @include typography(app-body-2); - text-align: center; - } - - & > button { - width: 260px; - height: 50px; - } - } - } - } -} diff --git a/src/pages/client/pages/ClientEditTunnelPage/ClientEditTunnelPage.tsx b/src/pages/client/pages/ClientEditTunnelPage/ClientEditTunnelPage.tsx deleted file mode 100644 index ab36b147d..000000000 --- a/src/pages/client/pages/ClientEditTunnelPage/ClientEditTunnelPage.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import './style.scss'; - -import { useQuery } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { useI18nContext } from '../../../../i18n/i18n-react'; -import SvgIconCheckmarkSmall from '../../../../shared/components/svg/IconCheckmarkSmall'; -import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../shared/defguard-ui/components/Layout/Button/types'; -import { routes } from '../../../../shared/routes'; -import { clientApi } from '../../clientAPI/clientApi'; -import { useClientStore } from '../../hooks/useClientStore'; -import { clientQueryKeys } from '../../query'; -import { ClientConnectionType } from '../../types'; -import { EditTunnelFormCard } from './components/EditTunnelFormCard'; -import { DeleteTunnelModal } from './modals/DeleteTunnelModal/DeleteTunnelModal'; -import { useDeleteTunnelModal } from './modals/DeleteTunnelModal/useDeleteTunnelModal'; - -const { getTunnelDetails } = clientApi; - -export const ClientEditTunnelPage = () => { - const { LL } = useI18nContext(); - const navigate = useNavigate(); - const submitRef = useRef(null); - const selectedInstance = useClientStore((state) => state.selectedInstance); - const openDeleteTunnel = useDeleteTunnelModal((state) => state.open); - useEffect(() => { - if ( - selectedInstance?.id === undefined || - selectedInstance.type !== ClientConnectionType.TUNNEL - ) { - navigate(routes.client.base, { replace: true }); - } - }, [selectedInstance, navigate]); - - const { data: tunnel } = useQuery({ - queryKey: [clientQueryKeys.getTunnels, selectedInstance?.id as number], - queryFn: () => getTunnelDetails(selectedInstance?.id as number), - enabled: !!selectedInstance?.id, - }); - return ( - <> -
-
-

{LL.pages.client.pages.editTunnelPage.title()}

-
-
-
-
- {tunnel && } -
-
- - - ); -}; diff --git a/src/pages/client/pages/ClientEditTunnelPage/components/EditTunnelFormCard.tsx b/src/pages/client/pages/ClientEditTunnelPage/components/EditTunnelFormCard.tsx deleted file mode 100644 index 437a6245f..000000000 --- a/src/pages/client/pages/ClientEditTunnelPage/components/EditTunnelFormCard.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { error } from '@tauri-apps/plugin-log'; -import { useMemo, useState } from 'react'; -import { type SubmitHandler, useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom'; -import { z } from 'zod'; -import { useI18nContext } from '../../../../../i18n/i18n-react'; -import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; -import { ArrowSingle } from '../../../../../shared/defguard-ui/components/icons/ArrowSingle/ArrowSingle'; -import { - ArrowSingleDirection, - ArrowSingleSize, -} from '../../../../../shared/defguard-ui/components/icons/ArrowSingle/types'; -import { Card } from '../../../../../shared/defguard-ui/components/Layout/Card/Card'; -import { Helper } from '../../../../../shared/defguard-ui/components/Layout/Helper/Helper'; -import { useToaster } from '../../../../../shared/defguard-ui/hooks/toasts/useToaster'; -import { - cidrRegex, - patternValidEndpoint, - patternValidIp, - patternValidIpV6, - patternValidIpV6WithPort, - patternValidWireguardKey, -} from '../../../../../shared/patterns'; -import { routes } from '../../../../../shared/routes'; -import { errorDetail } from '../../../../../shared/utils/errorDetail'; -import { validateIpOrDomainList } from '../../../../../shared/validators/tunnel'; -import { clientApi } from '../../../clientAPI/clientApi'; -import type { Tunnel } from '../../../types'; - -type Props = { - tunnel: Tunnel; - submitRef: React.MutableRefObject; // Add submitRef prop -}; - -type FormFields = { - id?: number; - name: string; - pubkey: string; - prvkey: string; - address: string; - server_pubkey: string; - preshared_key?: string; - allowed_ips?: string; - endpoint: string; - dns?: string; - persistent_keep_alive: number; - route_all_traffic: boolean; - pre_up?: string; - post_up?: string; - pre_down?: string; - post_down?: string; -}; -const defaultValues: FormFields = { - name: '', - pubkey: '', - prvkey: '', - address: '', - server_pubkey: '', - preshared_key: '', - allowed_ips: '', - endpoint: '', - dns: '', - persistent_keep_alive: 25, // Adjust as needed - route_all_traffic: false, - pre_up: '', - post_up: '', - pre_down: '', - post_down: '', -}; -const { updateTunnel } = clientApi; - -const tunnelToForm = (tunnel: Tunnel): FormFields => { - const { - id, - pubkey, - prvkey, - server_pubkey, - preshared_key, - allowed_ips, - dns, - persistent_keep_alive, - pre_up, - post_up, - pre_down, - post_down, - ...commonFields - } = tunnel; - - return { - id: id, - pubkey, - prvkey, - server_pubkey, - preshared_key: preshared_key || '', - allowed_ips: allowed_ips || '', - dns: dns || '', - persistent_keep_alive, - pre_up: pre_up || '', - post_up: post_up || '', - pre_down: pre_down || '', - post_down: post_down || '', - ...commonFields, - }; -}; - -export const EditTunnelFormCard = ({ tunnel, submitRef }: Props) => { - const { LL } = useI18nContext(); - const localLL = LL.pages.client.pages.addTunnelPage.forms.initTunnel; - const navigate = useNavigate(); - const toaster = useToaster(); - - const defaultFormValues: FormFields = useMemo(() => { - if (tunnel) { - return tunnelToForm(tunnel); - } - return defaultValues; - }, [tunnel]); - - const schema = useMemo( - () => - z.object({ - id: z.number(), - name: z.string().trim().min(1, LL.form.errors.required()), - pubkey: z - .string() - .trim() - .min(1, LL.form.errors.required()) - .refine((value) => { - return patternValidWireguardKey.test(value); - }, LL.form.errors.invalid()), - prvkey: z - .string() - .trim() - .min(1, LL.form.errors.required()) - .refine((value) => { - return patternValidWireguardKey.test(value); - }, LL.form.errors.invalid()), - server_pubkey: z - .string() - .trim() - .min(1, LL.form.errors.required()) - .refine((value) => { - return patternValidWireguardKey.test(value); - }, LL.form.errors.invalid()), - preshared_key: z - .string() - .trim() - .refine((value) => { - return value === '' || patternValidWireguardKey.test(value); - }, LL.form.errors.invalid()), - address: z.string().refine((value) => { - if (value) { - const ips = value.split(',').map((ip) => ip.trim()); - return ips.every( - (ip) => patternValidIp.test(ip) || patternValidIpV6.test(ip), - ); - } - return false; - }, LL.form.errors.invalid()), - endpoint: z - .string() - .min(1, LL.form.errors.required()) - .refine((value) => { - return ( - patternValidEndpoint.test(value) || patternValidIpV6WithPort.test(value) - ); - }, LL.form.errors.invalid()), - dns: z - .string() - .refine((value) => { - if (value && value.length !== 0) { - return validateIpOrDomainList(value, ',', true); - } - return true; - }, LL.form.errors.invalid()) - .optional(), - allowed_ips: z.string().refine((value) => { - if (value) { - const ips = value.split(',').map((ip) => ip.trim()); - return ips.every((ip) => cidrRegex.test(ip)); - } - return true; - }, LL.form.errors.invalid()), - persistent_keep_alive: z.coerce.number(), - route_all_traffic: z.boolean(), - pre_up: z.string().nullable(), - post_up: z.string().nullable(), - pre_down: z.string().nullable(), - post_down: z.string().nullable(), - }), - [LL.form.errors], - ); - - const handleValidSubmit: SubmitHandler = (values) => { - updateTunnel(values) - .then(() => { - navigate(routes.client.base, { replace: true }); - toaster.success(LL.pages.client.pages.editTunnelPage.messages.editSuccess()); - }) - .catch((e) => { - const detail = errorDetail(e); - error(`Failed to update tunnel: ${detail}`); - toaster.error(LL.pages.client.pages.editTunnelPage.messages.editError()); - }); - }; - - const { handleSubmit, control } = useForm({ - resolver: zodResolver(schema), - defaultValues: defaultFormValues, - mode: 'all', - }); - - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - - const handleToggleAdvancedOptions = () => { - setShowAdvancedOptions(!showAdvancedOptions); - }; - - return ( -
- -
-

Tunnel Configuration

-
-
-
- {localLL.helpers.name()}} - /> - {localLL.helpers.prvkey()}} - /> - {localLL.helpers.pubkey()}} - /> - {localLL.helpers.address()}} - /> -
-
- -

{localLL.sections.vpnServer()}

- {localLL.helpers.serverPubkey()}} - /> - {localLL.helpers.presharedKey()}} - /> - {localLL.helpers.endpoint()}} - /> - {localLL.helpers.dns()}} - /> - {localLL.helpers.allowedIps()}} - /> - - {localLL.helpers.persistentKeepAlive()}} - /> -
-

{localLL.sections.advancedOptions()}

- {localLL.helpers.advancedOptions()} -
- -
-
- {localLL.helpers.preUp()}} - /> - {localLL.helpers.postUp()}} - /> - {localLL.helpers.preDown()}} - /> - {localLL.helpers.postDown()}} - /> -
-
- -
- ); -}; diff --git a/src/pages/client/pages/ClientEditTunnelPage/modals/DeleteTunnelModal/DeleteTunnelModal.tsx b/src/pages/client/pages/ClientEditTunnelPage/modals/DeleteTunnelModal/DeleteTunnelModal.tsx deleted file mode 100644 index cfb0274e6..000000000 --- a/src/pages/client/pages/ClientEditTunnelPage/modals/DeleteTunnelModal/DeleteTunnelModal.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { error } from '@tauri-apps/plugin-log'; -import { isUndefined } from 'lodash-es'; -import { useNavigate } from 'react-router-dom'; -import { shallow } from 'zustand/shallow'; -import { useI18nContext } from '../../../../../../i18n/i18n-react'; -import { ConfirmModal } from '../../../../../../shared/defguard-ui/components/Layout/modals/ConfirmModal/ConfirmModal'; -import { ConfirmModalType } from '../../../../../../shared/defguard-ui/components/Layout/modals/ConfirmModal/types'; -import { useToaster } from '../../../../../../shared/defguard-ui/hooks/toasts/useToaster'; -import { routes } from '../../../../../../shared/routes'; -import { errorDetail } from '../../../../../../shared/utils/errorDetail'; -import { clientApi } from '../../../../clientAPI/clientApi'; -import { useClientStore } from '../../../../hooks/useClientStore'; -import { clientQueryKeys } from '../../../../query'; -import { ClientConnectionType } from '../../../../types'; -import { useDeleteTunnelModal } from './useDeleteTunnelModal'; - -const { deleteTunnel } = clientApi; - -const invalidateOnSuccess = [clientQueryKeys.getTunnels, clientQueryKeys.getConnections]; - -export const DeleteTunnelModal = () => { - const { LL } = useI18nContext(); - const navigate = useNavigate(); - const setClientStore = useClientStore((state) => state.setState); - const [isOpen, tunnel] = useDeleteTunnelModal( - (state) => [state.isOpen, state.tunnel], - shallow, - ); - const [close, reset] = useDeleteTunnelModal( - (state) => [state.close, state.reset], - shallow, - ); - const toaster = useToaster(); - const localLL = LL.modals.deleteTunnel; - const queryClient = useQueryClient(); - - const { mutate, isPending } = useMutation({ - mutationFn: deleteTunnel, - onSuccess: () => { - toaster.success(localLL.messages.success()); - invalidateOnSuccess.forEach((key) => { - queryClient.invalidateQueries({ - queryKey: [key], - refetchType: 'active', - }); - }); - reset(); - setClientStore({ - selectedInstance: { - id: undefined, - type: ClientConnectionType.TUNNEL, - }, - }); - navigate(routes.client.base, { replace: true }); - }, - onError: (e) => { - toaster.error( - LL.common.messages.errorWithMessage({ - message: String(e), - }), - ); - const detail = errorDetail(e); - error(`Failed to delete tunnel "${tunnel?.name}" (id: ${tunnel?.id}): ${detail}`); - }, - }); - - return ( - close()} - afterClose={() => reset()} - loading={isPending} - submitText={localLL.controls.submit()} - cancelText={LL.common.controls.cancel()} - onSubmit={() => { - if (tunnel) { - mutate(tunnel.id); - } - }} - onCancel={() => close()} - /> - ); -}; diff --git a/src/pages/client/pages/ClientEditTunnelPage/modals/DeleteTunnelModal/useDeleteTunnelModal.ts b/src/pages/client/pages/ClientEditTunnelPage/modals/DeleteTunnelModal/useDeleteTunnelModal.ts deleted file mode 100644 index 60a0a05f1..000000000 --- a/src/pages/client/pages/ClientEditTunnelPage/modals/DeleteTunnelModal/useDeleteTunnelModal.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createWithEqualityFn } from 'zustand/traditional'; - -import type { Tunnel } from '../../../../types'; - -const defaultValues: StoreValues = { - isOpen: false, - tunnel: undefined, -}; - -export const useDeleteTunnelModal = createWithEqualityFn( - (set) => ({ - ...defaultValues, - open: (tunnel) => set({ tunnel, isOpen: true }), - close: () => set({ isOpen: false }), - reset: () => set(defaultValues), - }), - Object.is, -); - -type Store = StoreValues & StoreMethods; - -type StoreValues = { - isOpen: boolean; - tunnel?: Tunnel; -}; - -type StoreMethods = { - open: (tunnel: Tunnel) => void; - close: () => void; - reset: () => void; -}; diff --git a/src/pages/client/pages/ClientEditTunnelPage/style.scss b/src/pages/client/pages/ClientEditTunnelPage/style.scss deleted file mode 100644 index f1fb0c9e3..000000000 --- a/src/pages/client/pages/ClientEditTunnelPage/style.scss +++ /dev/null @@ -1,119 +0,0 @@ -#client-edit-tunnel-page { - h1 { - @include typography(app-title); - color: var(--text-body-primary); - } - - h2 { - @include typography(app-body-1); - color: var(--text-body-primary); - } - - h3 { - @include typography(app-side-bar); - color: var(--text-body-primary); - } - - & > header { - width: 100%; - display: flex; - flex-flow: row; - align-items: center; - justify-content: flex-start; - padding-bottom: 15px; - - & > h1 { - text-align: left; - } - - & > .controls { - margin-left: auto; - display: flex; - flex-flow: row; - align-items: center; - justify-content: center; - gap: 20px; - - .btn { - width: 100%; - min-width: 130px; - } - } - } - - & > .content { - form { - & > * { - width: 100%; - } - - display: flex; - flex-direction: row; - justify-content: space-between; - gap: 50px; - align-items: flex-start; - - @include media-breakpoint-down(xxl) { - justify-content: flex-start; - flex-direction: column; - } - - & > .card { - box-sizing: border-box; - padding: 32px 64px; - } - - & > .client { - border-bottom: 1px solid var(--border-primary); - margin-bottom: 10px; - } - - & > h3 { - margin-bottom: 10px; - } - - .advanced-options-header { - display: flex; - align-items: center; - gap: 5px; - margin-bottom: 10px; - - & > button { - background: none; - border: none; - padding: 0; - margin: 0; - } - - .arrow-single { - width: 22px; - height: 22px; - margin-left: auto; - } - - .underscore { - flex-grow: 1; - border-bottom: 1px solid var(--border-primary); - margin-right: 10px; - } - } - - .advanced-options { - display: none; - transition: opacity 0.5s ease; - } - - .advanced-options.open { - display: block; - } - - > .controls { - padding-top: 42px; - - .btn { - height: 47px; - } - } - } - } -} diff --git a/src/pages/client/pages/ClientInstancePage/ClientInstancePage.tsx b/src/pages/client/pages/ClientInstancePage/ClientInstancePage.tsx deleted file mode 100644 index b6d40cf33..000000000 --- a/src/pages/client/pages/ClientInstancePage/ClientInstancePage.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import './style.scss'; - -import { useQuery } from '@tanstack/react-query'; -import { isUndefined } from 'lodash-es'; -import { useCallback, useEffect, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { useI18nContext } from '../../../../i18n/i18n-react'; -import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { ButtonStyleVariant } from '../../../../shared/defguard-ui/components/Layout/Button/types'; -import { routes } from '../../../../shared/routes'; -import { clientApi } from '../../clientAPI/clientApi'; -import { useClientStore } from '../../hooks/useClientStore'; -import { clientQueryKeys } from '../../query'; -import { ClientConnectionType, type DefguardInstance } from '../../types'; -import { LocationsList } from './components/LocationsList/LocationsList'; -import { StatsFilterSelect } from './components/StatsFilterSelect/StatsFilterSelect'; -import { StatsLayoutSelect } from './components/StatsLayoutSelect/StatsLayoutSelect'; -import { DeleteInstanceModal } from './modals/DeleteInstanceModal/DeleteInstanceModal'; -import { UpdateInstanceModal } from './modals/UpdateInstanceModal/UpdateInstanceModal'; -import { useUpdateInstanceModal } from './modals/UpdateInstanceModal/useUpdateInstanceModal'; - -const { getLocations, getTunnels } = clientApi; - -export const ClientInstancePage = () => { - const { LL } = useI18nContext(); - const instanceLL = LL.pages.client.pages.instancePage; - const tunnelLL = LL.pages.client.pages.tunnelPage; - const instances = useClientStore((state) => state.instances); - const tunnels = useClientStore((state) => state.tunnels); - const [selectedInstanceId, selectedInstanceType] = useClientStore((state) => [ - state.selectedInstance?.id, - state.selectedInstance?.type, - ]); - - const selectedInstance = useMemo((): DefguardInstance | undefined => { - if ( - !isUndefined(selectedInstanceId) && - selectedInstanceType && - selectedInstanceType === ClientConnectionType.LOCATION - ) { - return instances.find((i) => i.id === selectedInstanceId); - } - }, [selectedInstanceId, selectedInstanceType, instances]); - - const navigate = useNavigate(); - - const isLocationPage = selectedInstanceType === ClientConnectionType.LOCATION; - - const openUpdateInstanceModal = useUpdateInstanceModal((state) => state.open); - - const queryKey = useMemo(() => { - if (selectedInstanceType === ClientConnectionType.LOCATION) { - return [clientQueryKeys.getLocations, selectedInstanceId as number]; - } else { - return [clientQueryKeys.getTunnels]; - } - }, [selectedInstanceId, selectedInstanceType]); - - const queryFn = useCallback(() => { - if (selectedInstanceType === ClientConnectionType.LOCATION) { - return getLocations({ instanceId: selectedInstanceId as number }); - } else { - return getTunnels(); - } - }, [selectedInstanceType, selectedInstanceId]); - - const { data: locations, isError } = useQuery({ - queryKey, - queryFn, - enabled: !!selectedInstance, - }); - - useEffect(() => { - const isDefguardInstance = selectedInstanceType === ClientConnectionType.LOCATION; - const isTunnelInstance = selectedInstanceType === ClientConnectionType.TUNNEL; - - if (isDefguardInstance && !selectedInstance) { - navigate(routes.client.addInstance, { replace: true }); - } else if (isTunnelInstance && tunnels.length === 0) { - navigate(routes.client.addTunnel, { replace: true }); - } - }, [selectedInstance, selectedInstanceType, tunnels.length, navigate]); - - return ( -
-
-

{isLocationPage ? instanceLL.title() : tunnelLL.title()}

-
- - {isLocationPage && ( - <> - - {selectedInstance && ( -
-
- - - -
- ); -}; diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationUsageChart/LocationUsageChart.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationUsageChart/LocationUsageChart.tsx deleted file mode 100644 index a21d79e20..000000000 --- a/src/pages/client/pages/ClientInstancePage/components/LocationUsageChart/LocationUsageChart.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import './style.scss'; - -import dayjs from 'dayjs'; -import { sortBy } from 'lodash-es'; -import { useMemo } from 'react'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { Bar, BarChart, Line, LineChart, XAxis, YAxis } from 'recharts'; - -import { NetworkSpeed } from '../../../../../../shared/defguard-ui/components/Layout/NetworkSpeed/NetworkSpeed'; -import { NetworkDirection } from '../../../../../../shared/defguard-ui/components/Layout/NetworkSpeed/types'; -import { useTheme } from '../../../../../../shared/defguard-ui/hooks/theme/useTheme'; -import type { LocationStats } from '../../../../types'; -import { LocationUsageChartType } from './types'; - -type ChartBoxSpacing = { - top?: number; - bottom?: number; - left?: number; - right?: number; -}; - -interface LocationUsageProps { - data: LocationStats[]; - type: LocationUsageChartType; - hideX?: boolean; - barSize?: number; - barGap?: number; - heightX?: number; - margin?: ChartBoxSpacing; - padding?: ChartBoxSpacing; -} - -const parseStatsForCharts = (data: LocationStats[]): LocationStats[] => { - const filtered = data.filter((stats) => stats.download > 0 || stats.upload > 0); - const formatted = filtered.map((stats) => ({ - ...stats, - collected_at: dayjs.utc(stats.collected_at).toDate().getTime(), - })); - return sortBy(formatted, ['collected_at']); -}; - -const totalUploadDownload = (data: LocationStats[]): number[] => { - let totalDownload = 0; - let totalUpload = 0; - for (const locationStat of data) { - totalDownload += locationStat.download; - totalUpload += locationStat.upload; - } - return [totalUpload, totalDownload]; -}; - -export const LocationUsageChart = ({ - data, - hideX = false, - barSize = 2, - barGap = 2, - heightX = 50, - type, - margin, - padding, -}: LocationUsageProps) => { - const [totalUpload, totalDownload] = useMemo(() => totalUploadDownload(data), [data]); - const getFormattedData = useMemo(() => parseStatsForCharts(data), [data]); - const { colors } = useTheme(); - - const getMargin = useMemo((): ChartBoxSpacing => { - const defaultMargin: ChartBoxSpacing = { - top: 0, - left: 0, - right: 0, - bottom: 0, - }; - return margin ?? defaultMargin; - }, [margin]); - - const getPadding = useMemo((): ChartBoxSpacing => { - const defaultPadding: ChartBoxSpacing = { - bottom: 0, - right: 0, - left: 0, - top: 0, - }; - return padding ?? defaultPadding; - }, [padding]); - - if (!data.length) return null; - return ( -
-
- - -
- {type === LocationUsageChartType.BAR && ( - - {(size) => ( - - - - - - - )} - - )} - - {type === LocationUsageChartType.LINE && ( - - {(size) => ( - - - - - - - )} - - )} -
- ); -}; - -// FIXME: hack with spaces to avoid tick overlapping -const formatXTick = (tickData: number) => - dayjs.utc(tickData).local().format('HH:mm:ss '); diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationUsageChart/style.scss b/src/pages/client/pages/ClientInstancePage/components/LocationUsageChart/style.scss deleted file mode 100644 index bceb6df2a..000000000 --- a/src/pages/client/pages/ClientInstancePage/components/LocationUsageChart/style.scss +++ /dev/null @@ -1,23 +0,0 @@ -.location-usage { - display: grid; - grid-template-rows: auto 1fr; - grid-template-columns: 1fr; - width: 100%; - row-gap: 8px; - - & > .summary { - grid-row: 1; - grid-column: 1; - width: 100%; - display: flex; - flex-flow: row; - align-items: center; - justify-content: space-between; - } - - & > .chart-wrapper { - grid-row: 2; - grid-column: 1; - width: 100%; - } -} diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationUsageChart/types.ts b/src/pages/client/pages/ClientInstancePage/components/LocationUsageChart/types.ts deleted file mode 100644 index 4dd9e8dc8..000000000 --- a/src/pages/client/pages/ClientInstancePage/components/LocationUsageChart/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum LocationUsageChartType { - BAR, - LINE, -} diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/LocationsList.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/LocationsList.tsx deleted file mode 100644 index 3c1546abb..000000000 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/LocationsList.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import './style.scss'; - -import { useEffect } from 'react'; -import Markdown from 'react-markdown'; -import { useNavigate } from 'react-router-dom'; - -import { useI18nContext } from '../../../../../../i18n/i18n-react'; -import { useToaster } from '../../../../../../shared/defguard-ui/hooks/toasts/useToaster'; -import { routes } from '../../../../../../shared/routes'; -import { useClientStore } from '../../../../hooks/useClientStore'; -import { - ClientConnectionType, - type CommonWireguardFields, - type DefguardInstance, -} from '../../../../types'; -import { LocationsDetailView } from './components/LocationsDetailView/LocationsDetailView'; -import { LocationsGridView } from './components/LocationsGridView/LocationsGridView'; - -interface LocationsListProps { - locations: CommonWireguardFields[] | undefined; - isError: boolean; - selectedDefguardInstance: DefguardInstance | undefined; -} - -export const LocationsList = ({ - locations, - isError, - selectedDefguardInstance, -}: LocationsListProps) => { - const { LL } = useI18nContext(); - - const selectedView = useClientStore((state) => state.selectedView); - const selectedInstance = useClientStore((state) => state.selectedInstance); - const toaster = useToaster(); - const navigate = useNavigate(); - - const isTunnelType = selectedInstance?.type === ClientConnectionType.TUNNEL; - - useEffect(() => { - if (isError) { - toaster.error(LL.common.messages.error()); - } - }, [isError, toaster, LL.common.messages]); - - useEffect(() => { - if ( - locations?.length === 0 && - selectedInstance?.type === ClientConnectionType.TUNNEL - ) { - navigate(routes.client.addTunnel, { replace: true }); - } - }, [locations, navigate, selectedInstance]); - - // Listen for rust requesting MFA for connection - - // TODO: add loader or another placeholder view pointing to opening enter token modal if no instances are found / present - if (!selectedInstance || !locations) return null; - - return ( - <> - {locations && locations.length === 0 && ( -
- {LL.pages.client.pages.instancePage.noData().trim()} -
- )} - {locations.length === 1 && selectedView === null && !isTunnelType && ( - - )} - {(selectedView === 'grid' || selectedView === null || isTunnelType) && ( - - )} - - {selectedView === 'detail' && !isTunnelType && ( - - )} - - ); -}; diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationCardConnectButton/LocationCardConnectButton.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationCardConnectButton/LocationCardConnectButton.tsx deleted file mode 100644 index 1b4168766..000000000 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationCardConnectButton/LocationCardConnectButton.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import './style.scss'; - -import { error } from '@tauri-apps/plugin-log'; -import classNames from 'classnames'; -import { useState } from 'react'; -import { useI18nContext } from '../../../../../../../../i18n/i18n-react'; -import SvgIconCheckmarkSmall from '../../../../../../../../shared/components/svg/IconCheckmarkSmall'; -import { Button } from '../../../../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../../../../shared/defguard-ui/components/Layout/Button/types'; -import SvgIconX from '../../../../../../../../shared/defguard-ui/components/svg/IconX'; -import { useToaster } from '../../../../../../../../shared/defguard-ui/hooks/toasts/useToaster'; -import { errorDetail } from '../../../../../../../../shared/utils/errorDetail'; -import { clientApi } from '../../../../../../clientAPI/clientApi'; -import { type CommonWireguardFields, LocationMfaType } from '../../../../../../types'; -import { useMFAModal } from '../../modals/MFAModal/useMFAModal'; - -const { connect, disconnect } = clientApi; - -type Props = { - location?: CommonWireguardFields; -}; - -export const LocationCardConnectButton = ({ location }: Props) => { - const openMFAModal = useMFAModal((state) => state.open); - const toaster = useToaster(); - const [isLoading, setIsLoading] = useState(false); - const { LL } = useI18nContext(); - - const cn = classNames('location-card-connect-button', { - connected: location?.active, - }); - - const mfaEnabled = - location?.location_mfa_mode && - (location.location_mfa_mode === LocationMfaType.INTERNAL || - location.location_mfa_mode === LocationMfaType.EXTERNAL); - - const handleClick = async () => { - setIsLoading(true); - try { - if (location) { - if (location?.active) { - await disconnect({ - locationId: location.id, - connectionType: location.connection_type, - }); - } else { - if (mfaEnabled) { - openMFAModal(location); - } else { - await connect({ - locationId: location?.id, - connectionType: location.connection_type, - }); - } - } - setIsLoading(false); - } - } catch (e) { - setIsLoading(false); - toaster.error( - LL.common.messages.errorWithMessage({ - message: String(e), - }), - ); - const detail = errorDetail(e); - error( - `Error handling interface for location ${location?.id} (${location?.active ? 'disconnect' : 'connect'}): ${detail}`, - ); - } - }; - - return ( -