From bb6de9b7371b6873770f4c7f63857b5ffac77cdc Mon Sep 17 00:00:00 2001 From: benya Date: Fri, 25 Sep 2026 14:02:32 +0300 Subject: [PATCH] Prepare 2.3.0 release and Linux Docker workflows --- .gitea/workflows/release-dev.yml | 203 +++++++++---------- .gitea/workflows/release.yml | 288 ++++++++++++--------------- README.md | 2 + app_version.py | 2 +- auth_webview.py | 135 +++++++++++-- build.py | 16 +- docs/release-linux.md | 24 +++ main.py | 127 ++++++++++-- requirements.txt | 2 +- scripts/docker-build-installer.sh | 22 ++ scripts/docker-build-windows.sh | 19 ++ tests/test_auth_fallback.py | 212 ++++++++++++++++++++ tests/test_main_contracts.py | 14 +- tests/test_update_reentry_runtime.py | 6 +- ui/main_window.py | 2 +- 15 files changed, 759 insertions(+), 315 deletions(-) create mode 100644 docs/release-linux.md create mode 100644 scripts/docker-build-installer.sh create mode 100644 scripts/docker-build-windows.sh create mode 100644 tests/test_auth_fallback.py diff --git a/.gitea/workflows/release-dev.yml b/.gitea/workflows/release-dev.yml index 8b983f6..c2c3efa 100644 --- a/.gitea/workflows/release-dev.yml +++ b/.gitea/workflows/release-dev.yml @@ -8,8 +8,7 @@ on: jobs: prerelease: - runs-on: windows - + runs-on: ubuntu-latest steps: - name: Checkout uses: https://git.daemonlord.ru/actions/checkout@v4 @@ -17,127 +16,131 @@ jobs: fetch-depth: 0 tags: true - - name: Ensure Python 3.13 - shell: powershell - run: | - if (Get-Command python -ErrorAction SilentlyContinue) { - python --version - } elseif (Get-Command py -ErrorAction SilentlyContinue) { - $pyExe = py -3.13 -c "import sys; print(sys.executable)" - if (-not $pyExe) { - throw "Python 3.13 launcher is available, but interpreter was not found." - } - Split-Path $pyExe | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - python --version - } else { - throw "Python is not installed on runner. Install Python 3.13 and restart runner service." - } + - name: Set up Python for tests + uses: https://git.daemonlord.ru/actions/setup-python@v5 + with: + python-version: "3.13" - - name: Install dependencies - shell: powershell - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt pyinstaller - - - name: Extract prerelease metadata + - name: Read prerelease metadata id: meta - shell: powershell + shell: bash run: | - $version = (python -c "from app_version import APP_VERSION; print(APP_VERSION)").Trim() - $commit = (git rev-parse --short HEAD).Trim() - $tag = "v$version-$commit" - $archive = "AnabasisManager-$version-$commit" - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "version=$version`n", $utf8NoBom) - [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "commit=$commit`n", $utf8NoBom) - [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "tag=$tag`n", $utf8NoBom) - [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "archive=$archive`n", $utf8NoBom) - [System.IO.File]::AppendAllText($env:GITHUB_ENV, "CONTINUE=true`n", $utf8NoBom) - Write-Host "Detected tag: $tag" + set -euo pipefail + test "$GITHUB_REF" = refs/heads/dev + version="$(python -c 'from app_version import APP_VERSION; print(APP_VERSION)')" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + commit="$(git rev-parse --short HEAD)" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "commit=$commit" >> "$GITHUB_OUTPUT" + echo "tag=v$version-$commit" >> "$GITHUB_OUTPUT" + echo "archive=AnabasisManager-$version-$commit" >> "$GITHUB_OUTPUT" - - name: Stop if prerelease already exists - if: env.CONTINUE == 'true' - shell: powershell + - name: Check prerelease does not exist + id: check_release + shell: bash + env: + API_TOKEN: ${{ secrets.API_TOKEN }} + RELEASE_TAG: ${{ steps.meta.outputs.tag }} run: | - $tag = "${{ steps.meta.outputs.tag }}" - $apiUrl = "https://git.daemonlord.ru/api/v1/repos/${{ gitea.repository }}/releases?page=1&limit=100" - $headers = @{ Authorization = "token ${{ secrets.API_TOKEN }}" } - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - try { - $response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Get - $found = $false - foreach ($release in $response) { - if ($release.tag_name -eq $tag) { - $found = $true - break - } - } - if ($found) { - Write-Host "Pre-release $tag already exists, stopping job." - [System.IO.File]::AppendAllText($env:GITHUB_ENV, "CONTINUE=false`n", $utf8NoBom) - } else { - Write-Host "Pre-release $tag not found, continuing workflow..." - } - } catch { - Write-Host "Failed to query releases list, continuing workflow..." - } + set -euo pipefail + test -n "$API_TOKEN" || { echo 'API_TOKEN secret is missing'; exit 1; } + status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --header "Authorization: token $API_TOKEN" \ + "https://git.daemonlord.ru/api/v1/repos/${{ gitea.repository }}/releases/tags/$RELEASE_TAG")" + case "$status" in + 200) echo "Prerelease $RELEASE_TAG already exists; skipping."; echo 'publish=false' >> "$GITHUB_OUTPUT" ;; + 404) echo 'publish=true' >> "$GITHUB_OUTPUT" ;; + *) echo "Cannot check prerelease: HTTP $status"; exit 1 ;; + esac + + - name: Check Linux Docker runner + if: steps.check_release.outputs.publish == 'true' + shell: bash + run: | + set -euo pipefail + test "$(uname -m)" = x86_64 + if ! command -v docker > /dev/null; then + if [ "$(id -u)" -eq 0 ]; then + apt-get update && apt-get install -y docker.io + else + sudo apt-get update && sudo apt-get install -y docker.io + fi + fi + docker info --format '{{.OSType}}' | grep -qx linux + + - name: Install test dependencies + if: steps.check_release.outputs.publish == 'true' + shell: bash + run: | + set -euo pipefail + python -m pip install -r requirements.txt - name: Run tests - if: env.CONTINUE == 'true' - shell: powershell + if: steps.check_release.outputs.publish == 'true' + shell: bash + env: + QT_QPA_PLATFORM: offscreen run: | - python -m py_compile app_version.py main.py build.py updater_gui.py tests/test_auth_relogin_smoke.py tests/test_auto_update_service.py tests/test_chat_actions.py tests/test_token_store.py - python -m unittest discover -s tests -p "test_*.py" -v + set -euo pipefail + python -m compileall -q app_version.py main.py build.py updater_gui.py services ui tests + python -m unittest discover -s tests -p 'test_*.py' -v - - name: Build release zip - if: env.CONTINUE == 'true' - shell: powershell - run: | - python build.py + - name: Build Windows application with PyInstaller + if: steps.check_release.outputs.publish == 'true' + shell: bash + run: bash scripts/docker-build-windows.sh - - name: Prepare prerelease artifacts - if: env.CONTINUE == 'true' - shell: powershell + - name: Prepare prerelease archive + if: steps.check_release.outputs.publish == 'true' + shell: bash + env: + APP_VERSION: ${{ steps.meta.outputs.version }} + ARCHIVE_BASE: ${{ steps.meta.outputs.archive }} run: | - $version = "${{ steps.meta.outputs.version }}" - $archiveBase = "${{ steps.meta.outputs.archive }}" - $srcZip = "dist/AnabasisManager-$version.zip" - $dstZip = "dist/$archiveBase.zip" - if (-not (Test-Path $srcZip)) { - throw "Archive not found: $srcZip" - } - Copy-Item -Path $srcZip -Destination $dstZip -Force - $hash = (Get-FileHash -Path $dstZip -Algorithm SHA256).Hash.ToLower() - "$hash $archiveBase.zip" | Set-Content -Path "dist/$archiveBase.zip.sha256" -Encoding UTF8 + set -euo pipefail + test -s "dist/AnabasisManager-$APP_VERSION.zip" + cp "dist/AnabasisManager-$APP_VERSION.zip" "dist/$ARCHIVE_BASE.zip" + python - "$ARCHIVE_BASE" "$APP_VERSION" <<'PY' + import sys + from pathlib import Path + from zipfile import ZipFile - - name: Configure git identity - if: env.CONTINUE == 'true' - shell: powershell - run: | - git config user.name "gitea-actions" - git config user.email "gitea-actions@daemonlord.ru" + archive = Path('dist') / f'{sys.argv[1]}.zip' + with ZipFile(archive) as package: + required = {'AnabasisManager.exe', 'AnabasisUpdater.exe', 'version.txt'} + missing = required.difference(package.namelist()) + if missing: + raise SystemExit(f'Archive is missing: {sorted(missing)}') + if package.read('version.txt').decode('utf-8').strip() != sys.argv[2]: + raise SystemExit('Archive version does not match APP_VERSION') + PY + (cd dist && sha256sum "$ARCHIVE_BASE.zip" > "$ARCHIVE_BASE.zip.sha256") + (cd dist && sha256sum --check "$ARCHIVE_BASE.zip.sha256") - name: Create git tag - if: env.CONTINUE == 'true' - shell: powershell + if: steps.check_release.outputs.publish == 'true' + shell: bash + env: + RELEASE_TAG: ${{ steps.meta.outputs.tag }} run: | - $tag = "${{ steps.meta.outputs.tag }}" - $tagLine = (git ls-remote --tags origin "refs/tags/$tag" | Select-Object -First 1) - if ([string]::IsNullOrWhiteSpace($tagLine)) { - git tag "$tag" - git push origin "$tag" - } else { - Write-Host "Tag $tag already exists on origin, skipping tag push." - } + set -euo pipefail + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" > /dev/null; then + git fetch origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$GITHUB_SHA" + else + git tag "$RELEASE_TAG" "$GITHUB_SHA" + git push origin "$RELEASE_TAG" + fi - name: Create Gitea Pre-release - if: env.CONTINUE == 'true' + if: steps.check_release.outputs.publish == 'true' uses: https://git.daemonlord.ru/actions/gitea-release-action@v1 with: server_url: https://git.daemonlord.ru repository: ${{ gitea.repository }} token: ${{ secrets.API_TOKEN }} tag_name: ${{ steps.meta.outputs.tag }} + target_commitish: ${{ gitea.sha }} name: Anabasis Manager ${{ steps.meta.outputs.version }} (dev ${{ steps.meta.outputs.commit }}) prerelease: true body: | diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9ba3537..e45daeb 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,14 +1,11 @@ name: Desktop Release on: - push: - branches: - - master + workflow_dispatch: jobs: release: - runs-on: windows - + runs-on: ubuntu-latest steps: - name: Checkout uses: https://git.daemonlord.ru/actions/checkout@v4 @@ -16,193 +13,156 @@ jobs: fetch-depth: 0 tags: true - - name: Ensure Python 3.13 - shell: powershell - run: | - if (Get-Command python -ErrorAction SilentlyContinue) { - python --version - } elseif (Get-Command py -ErrorAction SilentlyContinue) { - $pyExe = py -3.13 -c "import sys; print(sys.executable)" - if (-not $pyExe) { - throw "Python 3.13 launcher is available, but interpreter was not found." - } - Split-Path $pyExe | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - python --version - } else { - throw "Python is not installed on runner. Install Python 3.13 and restart runner service." - } + - name: Require master branch + shell: bash + run: test "$GITHUB_REF" = refs/heads/master - - name: Install dependencies - shell: powershell - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt pyinstaller + - name: Set up Python for tests + uses: https://git.daemonlord.ru/actions/setup-python@v5 + with: + python-version: "3.13" - - name: Ensure Inno Setup 6 - shell: powershell + - name: Read release version + id: version + shell: bash run: | - $isccPath = "" - $inPath = Get-Command iscc.exe -ErrorAction SilentlyContinue - if ($inPath) { - $isccPath = $inPath.Source - Write-Host "Inno Setup compiler found in PATH." - } elseif (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe") { - "C:\Program Files (x86)\Inno Setup 6" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - $isccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" - Write-Host "Inno Setup compiler found in Program Files (x86)." - } elseif (Test-Path "C:\Program Files\Inno Setup 6\ISCC.exe") { - "C:\Program Files\Inno Setup 6" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - $isccPath = "C:\Program Files\Inno Setup 6\ISCC.exe" - Write-Host "Inno Setup compiler found in Program Files." - } else { - throw "Inno Setup 6 is not installed on runner. Install Inno Setup and restart runner service." - } - Write-Host "Using ISCC: $isccPath" - exit 0 + set -euo pipefail + version="$(python -c 'from app_version import APP_VERSION; print(APP_VERSION)')" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid APP_VERSION: $version"; exit 1; } + echo "value=$version" >> "$GITHUB_OUTPUT" + echo "Release version: $version" - - name: Extract app version - id: extract_version - shell: powershell + - name: Check release does not exist + id: check_release + shell: bash + env: + API_TOKEN: ${{ secrets.API_TOKEN }} + RELEASE_TAG: v${{ steps.version.outputs.value }} run: | - $version = (python -c "from app_version import APP_VERSION; print(APP_VERSION)").Trim() - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "version=$version`n", $utf8NoBom) - Write-Host "Detected version: $version" + set -euo pipefail + test -n "$API_TOKEN" || { echo 'API_TOKEN secret is missing'; exit 1; } + status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --header "Authorization: token $API_TOKEN" \ + "https://git.daemonlord.ru/api/v1/repos/${{ gitea.repository }}/releases/tags/$RELEASE_TAG")" + case "$status" in + 200) echo "Release $RELEASE_TAG already exists; skipping."; echo 'publish=false' >> "$GITHUB_OUTPUT" ;; + 404) echo 'publish=true' >> "$GITHUB_OUTPUT" ;; + *) echo "Cannot check release: HTTP $status"; exit 1 ;; + esac - - name: Initialize release flow - id: flow_init - shell: powershell + - name: Check Linux Docker runner + if: steps.check_release.outputs.publish == 'true' + shell: bash run: | - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::AppendAllText($env:GITHUB_ENV, "CONTINUE=true`n", $utf8NoBom) - exit 0 + set -euo pipefail + test "$(uname -m)" = x86_64 + if ! command -v docker > /dev/null; then + if [ "$(id -u)" -eq 0 ]; then + apt-get update && apt-get install -y docker.io + else + sudo apt-get update && sudo apt-get install -y docker.io + fi + fi + docker info --format '{{.OSType}}' | grep -qx linux - - name: Stop if release already exists - shell: powershell + - name: Install test dependencies + if: steps.check_release.outputs.publish == 'true' + shell: bash run: | - $version = "${{ steps.extract_version.outputs.version }}" - $tag = "v$version" - $apiUrl = "https://git.daemonlord.ru/api/v1/repos/${{ gitea.repository }}/releases?page=1&limit=100" - $headers = @{ Authorization = "token ${{ secrets.API_TOKEN }}" } - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - try { - $response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Get - $found = $false - foreach ($release in $response) { - if ($release.tag_name -eq $tag) { - $found = $true - break - } - } - if ($found) { - Write-Host "Release $tag already exists, stopping job." - [System.IO.File]::AppendAllText($env:GITHUB_ENV, "CONTINUE=false`n", $utf8NoBom) - } else { - Write-Host "Release $tag not found, continuing workflow..." - } - } catch { - Write-Host "Failed to query releases list, continuing workflow..." - } + set -euo pipefail + python -m pip install -r requirements.txt - name: Run tests - if: env.CONTINUE == 'true' - shell: powershell - run: | - python -m py_compile app_version.py main.py build.py updater_gui.py tests/test_auth_relogin_smoke.py tests/test_auto_update_service.py tests/test_chat_actions.py tests/test_token_store.py - python -m unittest discover -s tests -p "test_*.py" -v - - - name: Build release zip - if: env.CONTINUE == 'true' - shell: powershell + if: steps.check_release.outputs.publish == 'true' + shell: bash env: - PYTHONUTF8: "1" - PYTHONIOENCODING: "utf-8" + QT_QPA_PLATFORM: offscreen run: | - $ErrorActionPreference = "Continue" - $repoRoot = (git rev-parse --show-toplevel).Trim() - if (-not [string]::IsNullOrWhiteSpace($repoRoot)) { - Set-Location $repoRoot - } - $logDir = Join-Path $env:RUNNER_TEMP "anabasis-build" - New-Item -ItemType Directory -Force -Path $logDir | Out-Null - $buildLog = Join-Path $logDir "build.log" - python build.py *>&1 | Tee-Object -FilePath $buildLog - $code = $LASTEXITCODE - if ($code -ne 0) { - Write-Host "Build failed with exit code $code. Dumping build log:" - if (Test-Path $buildLog) { - Get-Content -Path $buildLog -Raw - } else { - Write-Host "Build log was not created: $buildLog" - } - exit $code - } + set -euo pipefail + python -m compileall -q app_version.py main.py build.py updater_gui.py services ui tests + python -m unittest discover -s tests -p 'test_*.py' -v - - name: Ensure artifacts exist - if: env.CONTINUE == 'true' - shell: powershell - run: | - $version = "${{ steps.extract_version.outputs.version }}" - $archivePath = "dist/AnabasisManager-$version.zip" - $installerPath = "dist/AnabasisManager-setup-$version.exe" - if (-not (Test-Path $archivePath)) { - throw "Archive not found: $archivePath" - } - if (-not (Test-Path $installerPath)) { - throw "Installer not found: $installerPath" - } + - name: Build Windows application with PyInstaller + if: steps.check_release.outputs.publish == 'true' + shell: bash + run: bash scripts/docker-build-windows.sh - - name: Generate SHA256 checksum - if: env.CONTINUE == 'true' - shell: powershell + - name: Build Windows installer with Inno Setup + if: steps.check_release.outputs.publish == 'true' + shell: bash + env: + APP_VERSION: ${{ steps.version.outputs.value }} run: | - $version = "${{ steps.extract_version.outputs.version }}" - $archiveName = "AnabasisManager-$version.zip" - $installerName = "AnabasisManager-setup-$version.exe" - foreach ($name in @($archiveName, $installerName)) { - $path = "dist/$name" - $checksumPath = "dist/$name.sha256" - $hash = (Get-FileHash -Path $path -Algorithm SHA256).Hash.ToLower() - "$hash $name" | Set-Content -Path $checksumPath -Encoding UTF8 - Write-Host "Checksum created: $checksumPath" - } + set -euo pipefail + test -f 'dist/AnabasisManager/AnabasisManager.exe' + test -f 'dist/AnabasisManager/AnabasisUpdater.exe' + bash scripts/docker-build-installer.sh "$APP_VERSION" - - name: Configure git identity - if: env.CONTINUE == 'true' - shell: powershell + - name: Verify release assets + if: steps.check_release.outputs.publish == 'true' + shell: bash + env: + APP_VERSION: ${{ steps.version.outputs.value }} run: | - git config user.name "gitea-actions" - git config user.email "gitea-actions@daemonlord.ru" + set -euo pipefail + archive="AnabasisManager-$APP_VERSION.zip" + installer="AnabasisManager-setup-$APP_VERSION.exe" + test -s "dist/$archive" + test -s "dist/$installer" + python - "$APP_VERSION" <<'PY' + import sys + from pathlib import Path + from zipfile import ZipFile + + version = sys.argv[1] + archive = Path('dist') / f'AnabasisManager-{version}.zip' + with ZipFile(archive) as package: + required = {'AnabasisManager.exe', 'AnabasisUpdater.exe', 'version.txt'} + missing = required.difference(package.namelist()) + if missing: + raise SystemExit(f'Archive is missing: {sorted(missing)}') + actual_version = package.read('version.txt').decode('utf-8').strip() + if actual_version != version: + raise SystemExit(f'Archive version {actual_version} != {version}') + PY + for name in "$archive" "$installer"; do + (cd dist && sha256sum "$name" > "$name.sha256") + (cd dist && sha256sum --check "$name.sha256") + done - name: Create git tag - if: env.CONTINUE == 'true' - shell: powershell + if: steps.check_release.outputs.publish == 'true' + shell: bash + env: + RELEASE_TAG: v${{ steps.version.outputs.value }} run: | - $version = "${{ steps.extract_version.outputs.version }}" - $tag = "v$version" - $sha = "${{ gitea.sha }}" - $tagLine = (git ls-remote --tags origin "refs/tags/$tag" | Select-Object -First 1) - if ([string]::IsNullOrWhiteSpace($tagLine)) { - git tag -a "$tag" -m "Release $tag" "$sha" - git push origin "$tag" - } else { - Write-Host "Tag $tag already exists on origin, skipping tag push." - } + set -euo pipefail + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" > /dev/null; then + git fetch origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$GITHUB_SHA" || { + echo "Tag $RELEASE_TAG points to another commit"; exit 1; + } + else + git config user.name gitea-actions + git config user.email gitea-actions@daemonlord.ru + git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" "$GITHUB_SHA" + git push origin "$RELEASE_TAG" + fi - name: Create Gitea Release - if: env.CONTINUE == 'true' + if: steps.check_release.outputs.publish == 'true' uses: https://git.daemonlord.ru/actions/gitea-release-action@v1 with: server_url: https://git.daemonlord.ru repository: ${{ gitea.repository }} token: ${{ secrets.API_TOKEN }} - tag_name: v${{ steps.extract_version.outputs.version }} + tag_name: v${{ steps.version.outputs.value }} target_commitish: ${{ gitea.sha }} - name: Anabasis Manager ${{ steps.extract_version.outputs.version }} + name: Anabasis Manager ${{ steps.version.outputs.value }} body: | - Desktop release v${{ steps.extract_version.outputs.version }} + Desktop release v${{ steps.version.outputs.value }} files: | - dist/AnabasisManager-${{ steps.extract_version.outputs.version }}.zip - dist/AnabasisManager-${{ steps.extract_version.outputs.version }}.zip.sha256 - dist/AnabasisManager-setup-${{ steps.extract_version.outputs.version }}.exe - dist/AnabasisManager-setup-${{ steps.extract_version.outputs.version }}.exe.sha256 + dist/AnabasisManager-${{ steps.version.outputs.value }}.zip + dist/AnabasisManager-${{ steps.version.outputs.value }}.zip.sha256 + dist/AnabasisManager-setup-${{ steps.version.outputs.value }}.exe + dist/AnabasisManager-setup-${{ steps.version.outputs.value }}.exe.sha256 diff --git a/README.md b/README.md index 56a3a75..7908277 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ - В Windows сохранение токена требует успешного шифрования через DPAPI; при ошибке шифрования сессия продолжается, но токен не сохраняется на диск. ### Сборка проекта (для разработчиков) +Инструкция по выпуску Windows-релиза на Linux runner: [docs/release-linux.md](docs/release-linux.md). + Проект использует кастомный скрипт автоматизации `build.py`, который оптимизирует зависимости `PySide6` и корректно упаковывает `QtWebEngineCore`. **Команда для сборки:** diff --git a/app_version.py b/app_version.py index fb2c0ac..de4d328 100644 --- a/app_version.py +++ b/app_version.py @@ -1 +1 @@ -APP_VERSION = "2.2.5" +APP_VERSION = "2.3.0" diff --git a/auth_webview.py b/auth_webview.py index a68214f..f9b22e9 100644 --- a/auth_webview.py +++ b/auth_webview.py @@ -7,13 +7,6 @@ from urllib.parse import urlparse, parse_qs, unquote import webview -AUTH_USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0" -) - - def extract_token(url_string): token = None expires_in = 3600 @@ -50,30 +43,125 @@ def extract_token(url_string): raw_token = remaining_url[:amp_index] else: raw_token = remaining_url + token = unquote(raw_token) return token, expires_in -def main_auth(auth_url, output_path): +def extract_auth_error(url_string): + parsed = urlparse(url_string) + params = parse_qs(parsed.fragment or parsed.query) + return (params.get("error") or [None])[0], (params.get("error_description") or [None])[0] + +def extract_auth_error_from_page(page_text): + try: + data = json.loads(page_text.strip()) + except (AttributeError, TypeError, ValueError): + return None, None + if not isinstance(data, dict): + return None, None + return data.get("error"), data.get("error_description") + + +def extract_redirect_result(url): + """Read an OAuth result from VK's redirect URL before the page loads.""" + parsed = urlparse(url) + if parsed.scheme != "https" or parsed.hostname not in ("oauth.vk.com", "oauth.vk.ru"): + return None + if parsed.path != "/blank.html": + return None + token, expires_in = extract_token(url) + if token: + return {"token": token, "expires_in": expires_in} + error, description = extract_auth_error(url) + if error: + return {"error": error, "error_description": description} + return None + + +def login_continue_script(auth_url): + """Add an in-window control for starting OAuth after VK sign-in.""" + return """(function() { + if (document.getElementById('anabasis-vk-continue')) return; + const button = document.createElement('button'); + button.id = 'anabasis-vk-continue'; + button.textContent = 'Я вошёл в VK — получить токен'; + button.style.cssText = 'position:fixed;top:12px;right:12px;z-index:2147483647;' + + 'padding:12px 16px;border:0;border-radius:8px;background:#0077ff;' + + 'color:white;font:600 14px sans-serif;cursor:pointer;' + + 'box-shadow:0 2px 12px #0006'; + button.addEventListener('click', function() { + window.location.assign(AUTH_URL); + }); + document.body.appendChild(button); + })();""".replace("AUTH_URL", json.dumps(auth_url)) + + +def main_auth(auth_url, output_path, login_first=False): stop_polling = threading.Event() polling_started = threading.Event() + redirect_result = [] + redirect_lock = threading.Lock() + + def on_native_navigation_start(_sender, args): + # WebView2 exposes the target URL even if blank.html later returns 405. + result = extract_redirect_result(str(args.Uri)) + if result: + with redirect_lock: + redirect_result.append(result) + args.Cancel = True + + def attach_navigation_hook(): + native = getattr(window, "native", None) + native_webview = getattr(native, "webview", None) + if native_webview is not None and hasattr(native_webview, "NavigationStarting"): + native_webview.NavigationStarting += on_native_navigation_start def poll_url(): while not stop_polling.wait(0.75): + with redirect_lock: + result = redirect_result[0] if redirect_result else None + if result: + stop_polling.set() + with open(output_path, "w", encoding="utf-8") as f: + json.dump(result, f) + try: + window.destroy() + except Exception: + pass + return try: - url = window.get_current_url() + url = str(window.get_current_url() or "") except Exception: continue if not url: continue token, expires_in = extract_token(url) - if not token: + error, error_description = extract_auth_error(url) + if not token and not error and urlparse(url).hostname in ("oauth.vk.com", "oauth.vk.ru"): + try: + # A 405 response can still leave the token in location.href. + page_url = window.evaluate_js("window.location.href") + if page_url: + token, expires_in = extract_token(str(page_url)) + error, error_description = extract_auth_error(str(page_url)) + except Exception: + pass + if not token and not error and urlparse(url).hostname in ("oauth.vk.com", "oauth.vk.ru"): + try: + page_text = window.evaluate_js("document.body.innerText") + error, error_description = extract_auth_error_from_page(page_text) + except Exception: + pass + if not token and not error: continue stop_polling.set() - data = {"token": token, "expires_in": expires_in} + data = {"token": token, "expires_in": expires_in} if token else { + "error": error, "error_description": error_description, + } with open(output_path, "w", encoding="utf-8") as f: json.dump(data, f) try: @@ -91,23 +179,28 @@ def main_auth(auth_url, output_path): def on_loaded(): start_polling() + if login_first: + try: + current_url = urlparse(str(window.get_current_url() or "")) + if current_url.scheme == "https" and current_url.hostname in ( + "vk.ru", "www.vk.ru", "vk.com", "www.vk.com" + ): + window.evaluate_js(login_continue_script(auth_url)) + except Exception: + pass def on_closed(): stop_polling.set() - window = webview.create_window("VK Авторизация", auth_url) + window = webview.create_window("VK Авторизация", "https://vk.ru/" if login_first else auth_url) + window.events.before_show += attach_navigation_hook window.events.loaded += on_loaded try: window.events.closed += on_closed except Exception: pass - webview.start( - start_polling, - gui="edgechromium" if os.name == "nt" else None, - private_mode=True, - storage_path=None, - user_agent=AUTH_USER_AGENT, - ) + storage_path = os.path.join(os.path.dirname(output_path), "webview_profile") + webview.start(start_polling, private_mode=False, storage_path=storage_path) stop_polling.set() @@ -115,6 +208,8 @@ def main(): # Supports both: `python auth_webview.py ` # and: `python auth_webview.py --auth ` args = sys.argv[1:] + login_first = "--login-first" in args + args = [arg for arg in args if arg != "--login-first"] if len(args) == 3 and args[0] == "--auth": auth_url, output_path = args[1], args[2] elif len(args) == 2: @@ -123,7 +218,7 @@ def main(): print("Usage: auth_webview.py [--auth] ") return 1 - main_auth(auth_url, output_path) + main_auth(auth_url, output_path, login_first=login_first) return 0 diff --git a/build.py b/build.py index ab606c2..66ee85d 100644 --- a/build.py +++ b/build.py @@ -87,7 +87,7 @@ def run_build(): has_icon = os.path.exists(icon_abs_path) command = [ - "pyinstaller", + sys.executable, "-m", "PyInstaller", "--noconfirm", "--onedir", "--windowed", @@ -120,7 +120,7 @@ def run_updater_build(): if os.path.exists(updater_spec_path): os.remove(updater_spec_path) command = [ - "pyinstaller", + sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onefile", @@ -272,6 +272,10 @@ def build_installer(): if __name__ == "__main__": + if any(arg != "--skip-installer" for arg in sys.argv[1:]): + print("Usage: python build.py [--skip-installer]") + sys.exit(2) + skip_installer = "--skip-installer" in sys.argv[1:] ensure_project_root() # Предварительная очистка for folder in ["build", "dist"]: @@ -284,12 +288,14 @@ if __name__ == "__main__": copy_icon_to_dist() write_version_marker() create_archive() - build_installer() + if not skip_installer: + build_installer() print("\n" + "=" * 30) print("BUILD COMPLETED") print(f"Release archive: dist/{ARCHIVE_NAME}.zip") print(f"Release archive SHA256: dist/{ARCHIVE_NAME}.zip.sha256") - print(f"Installer: dist/{INSTALLER_NAME}") - print(f"Installer SHA256: dist/{INSTALLER_NAME}.sha256") + if not skip_installer: + print(f"Installer: dist/{INSTALLER_NAME}") + print(f"Installer SHA256: dist/{INSTALLER_NAME}.sha256") print("=" * 30) diff --git a/docs/release-linux.md b/docs/release-linux.md new file mode 100644 index 0000000..49410f9 --- /dev/null +++ b/docs/release-linux.md @@ -0,0 +1,24 @@ +# Сборка Windows-релиза на Linux runner + +Workflow `.gitea/workflows/release.yml` запускается вручную из ветки `master` через Gitea Actions → Desktop Release → Run workflow. Версия берётся из `app_version.py`; для ближайшего релиза это `2.3.0`. + +## Настройка раннера + +- Ваши `runner.labels` в `/config.yaml` уже верно привязывают `ubuntu-latest` к `docker://docker.gitea.com/runner-images:ubuntu-latest`. Менять их не требуется. +- В Docker Compose сейчас есть `GITEA_RUNNER_LABELS: "ubuntu-latest,docker,self-hosted"`. Удалите эту переменную или задайте в ней ту же явную схему `ubuntu-latest:docker://...`: при запуске daemon значение переменной может переопределить `runner.labels`, а голая метка `ubuntu-latest` означает выполнение шагов внутри контейнера `gitea-runner`. +- Ваше монтирование `/var/run/docker.sock:/var/run/docker.sock` в контейнер раннера оставьте. Job-контейнеру также нужен доступ к этому сокету через настройки `container.docker_host` (значение по умолчанию — автоопределение). Workflow при необходимости установит Docker CLI в job-контейнер. +- Docker host должен быть x86_64 и иметь достаточно места. `actions/setup-python` устанавливает Python 3.13 в job-контейнер. +- В секретах репозитория нужен `API_TOKEN` с правами записи тегов и релизов. Checkout также должен иметь право отправить git tag в `origin`. +- Первый запуск скачает образы `batonogov/pyinstaller-windows:latest` и `amake/innosetup:innosetup6` и установит зависимости Python внутри контейнера Wine. Для образов и артефактов потребуется несколько гигабайт свободного места. Сборка передаёт файлы через `docker cp`, поэтому пути внутри job-контейнера и Docker host могут различаться. + +## Что делает workflow + +1. Проверяет, что релиза `v2.3.0` ещё нет, затем запускает тесты. +2. Собирает Windows-приложение и ZIP через PyInstaller под Wine. +3. Собирает установщик через Inno Setup под Wine. +4. Проверяет наличие обоих EXE, `version.txt` в ZIP и SHA256 обоих файлов. +5. Создаёт тег и релиз Gitea с четырьмя вложениями: ZIP, его `.sha256`, установщик EXE и его `.sha256`. + +Имена ZIP и файлов SHA256 сохранены в формате, который читает автообновление. Workflow для ветки `dev` также переведён на `ubuntu-latest` и выпускает ZIP-предрелизы; установщик для них не собирается. + +Сборка под Wine требует отдельной проверки запуска приложения и установки на реальной Windows. Локально можно собрать и проверить артефакты теми же скриптами `scripts/docker-build-windows.sh` и `scripts/docker-build-installer.sh`, если доступен Linux с Docker. diff --git a/main.py b/main.py index 18326ce..9f0313e 100644 --- a/main.py +++ b/main.py @@ -45,10 +45,20 @@ LOG_FILE = os.path.join(APP_DATA_DIR, "app.log") LOG_MAX_BYTES = 1024 * 1024 # 1 MB LOG_BACKUP_FILE = os.path.join(APP_DATA_DIR, "app.log.1") AUTH_RELOGIN_BACKOFF_SECONDS = 5.0 -VK_APP_ID = "54454043" +VK_CLIENT_IDS = ("6287487", "2274003", "3140623", "3682744", "3697615", "6464690", "7497650", "4083558") +VK_CLIENT_NAMES = { + "6287487": "VK (рабочий ID)", + "2274003": "VK для Android", + "3140623": "VK для iPhone", + "3682744": "VK для iPad", + "3697615": "VK для Windows", + "6464690": "Маруся", + "7497650": "VK Connect", + "4083558": "VFeed", +} VK_AUTH_SCOPE = "1073737727" VK_API_VERSION = "5.131" -VK_AUTH_REDIRECT_URI = os.getenv("ANABASIS_VK_REDIRECT_URI", "https://vk.daemonlord.ru/vk/callback").strip() or "https://vk.daemonlord.ru/vk/callback" +VK_AUTH_REDIRECT_URI = "https://oauth.vk.com/blank.html" # Legacy owner/repo format for GitHub-only fallback. UPDATE_REPOSITORY = "" # Full repository URL is preferred (supports GitHub/Gitea). @@ -195,6 +205,7 @@ class VkChatManager(QMainWindow): self.suppress_resolve = False self.auth_process = None self.auth_output_path = None + self._auth_client_index = 0 self._auth_process_error_text = None self._auth_ui_busy = False self._auth_relogin_in_progress = False @@ -248,7 +259,7 @@ class VkChatManager(QMainWindow): self.token_timer_label.setAlignment(Qt.AlignmentFlag.AlignRight) layout.addWidget(self.token_timer_label) - self.auth_btn = QPushButton("Авторизоваться через VK") + self.auth_btn = QPushButton("Войти через VK") self.auth_btn.clicked.connect(self.start_auth) layout.addWidget(self.auth_btn) self.auth_progress = QProgressBar() @@ -949,13 +960,14 @@ class VkChatManager(QMainWindow): def _build_auth_command(self, auth_url, output_path): return self.vk_service.build_auth_command(auth_url, output_path, entry_script_path=os.path.abspath(__file__)) - def _build_vk_auth_url(self, display="mobile"): + def _build_vk_auth_url(self, display="page", client_id=None): if VK_AUTH_URL_OVERRIDE: return VK_AUTH_URL_OVERRIDE + selected_client_id = client_id or VK_CLIENT_IDS[0] params = { - "client_id": VK_APP_ID, + "client_id": selected_client_id, "display": display, - "redirect_uri": VK_AUTH_REDIRECT_URI, + "redirect_uri": "https://oauth.vk.ru/blank.html" if selected_client_id == "6287487" else VK_AUTH_REDIRECT_URI, "scope": VK_AUTH_SCOPE, "response_type": "token", "v": VK_API_VERSION, @@ -1004,12 +1016,16 @@ class VkChatManager(QMainWindow): token = None expires_in = 0 + auth_error = None + auth_error_description = None if output_path and os.path.exists(output_path): try: with open(output_path, "r", encoding="utf-8") as f: data = json.load(f) token = data.get("token") expires_in = data.get("expires_in", 0) + auth_error = data.get("error") + auth_error_description = data.get("error_description") except Exception as e: self._log_event("auth_result_parse", f"Ошибка чтения результата авторизации: {e}", level="ERROR") finally: @@ -1021,23 +1037,41 @@ class VkChatManager(QMainWindow): else: self._log_event("auth_result", "Файл результата авторизации не найден.", level="WARN") - self._auth_relogin_in_progress = False - self.handle_new_auth_token(token, expires_in) + if auth_error and auth_error != "access_denied" and self._auth_client_index + 1 < len(VK_CLIENT_IDS): + self._log_event("auth_client_fallback", f"client_id={VK_CLIENT_IDS[self._auth_client_index]}; error={auth_error}", level="WARN") + self.start_auth(client_index=self._auth_client_index + 1) + return - def start_auth(self, keep_status_text=False): + if auth_error: + detail = auth_error_description or auth_error + self.status_label.setText(f"Статус: ошибка авторизации VK: {detail}") + self._log_event("auth_oauth_error", f"client_id={VK_CLIENT_IDS[self._auth_client_index]}; error={auth_error}; description={detail}", level="WARN") + self._auth_relogin_in_progress = False + self.set_ui_state(False) + return + + self._auth_relogin_in_progress = False + self.handle_new_auth_token(token, expires_in, client_index=self._auth_client_index) + + def start_auth(self, keep_status_text=False, client_index=0): if self.auth_process and self.auth_process.state() != QProcess.ProcessState.NotRunning: self._log_event("auth_process", "Авторизация уже запущена, повторный запуск пропущен.") return - self._log_event("auth_process", "start") + self._auth_client_index = client_index + client_id = VK_CLIENT_IDS[client_index] + self._log_event("auth_process", f"start client_id={client_id}") if keep_status_text and hasattr(self, "_relogin_status_text"): status_text = self._relogin_status_text self._relogin_status_text = None else: - status_text = "Статус: ожидание авторизации..." + status_text = ( + f"Статус: вход через {VK_CLIENT_NAMES[client_id]} " + f"({client_id}, попытка {client_index + 1}/{len(VK_CLIENT_IDS)})..." + ) self.status_label.setText(status_text) - auth_url = self._build_vk_auth_url(display="mobile") + auth_url = self._build_vk_auth_url(client_id=client_id) output_path = os.path.join(APP_DATA_DIR, "auth_result.json") try: if os.path.exists(output_path): @@ -1046,6 +1080,8 @@ class VkChatManager(QMainWindow): self._log_event("auth_result_cleanup", f"Не удалось удалить старый файл результата авторизации: {exc}", level="WARN") program, args = self._build_auth_command(auth_url, output_path) + if client_index == 0: + args = [*args, "--login-first"] self.auth_output_path = output_path self._auth_process_error_text = None @@ -1056,13 +1092,51 @@ class VkChatManager(QMainWindow): self._set_auth_ui_state(True) process.start(program, args) - def handle_new_auth_token(self, token, expires_in): + def handle_new_auth_token(self, token, expires_in, client_index=None): if not token: self.status_label.setText("Статус: Авторизация не удалась") self.set_ui_state(False) self._auth_relogin_in_progress = False return + # A token may be valid for VK but unable to call messages.getConversations. + # Check that permission before replacing the saved, working session. + candidate_service = VkService() + rate_limited = False + try: + self.status_label.setText("Статус: проверка доступа к чатам VK...") + candidate_service.set_token(token) + candidate_service.api.messages.getConversations(count=1) + except VkApiError as exc: + code = candidate_service.vk_error_code(exc) + client_id = VK_CLIENT_IDS[client_index] if client_index is not None else None + self._log_event("auth_token_check", f"client_id={client_id}; code={code}", level="WARN") + if code == 9: + rate_limited = True + elif code == 15 and client_index is not None and client_index + 1 < len(VK_CLIENT_IDS): + self.status_label.setText(f"Статус: клиент {VK_CLIENT_NAMES[client_id]} не имеет доступа к чатам; пробуем следующий...") + self.start_auth(client_index=client_index + 1) + return + else: + if code == 15: + if client_index is not None: + message = "Ни один из перечисленных ID клиентов не дал доступ к чатам VK (ошибка 15)." + else: + message = "Клиент VK не имеет доступа к чатам (ошибка 15). Выберите другой ID клиента." + else: + message = f"Не удалось проверить доступ к чатам: {self._format_vk_error(exc)}" + self.status_label.setText(f"Статус: {message}") + QMessageBox.warning(self, "Вход в VK", message) + self.set_ui_state(self.token is not None) + self._auth_relogin_in_progress = False + return + except Exception as exc: + self._log_event("auth_token_check", f"Ошибка проверки доступа к чатам: {exc}", level="WARN") + QMessageBox.warning(self, "Вход в VK", f"Не удалось проверить доступ к чатам: {exc}") + self.set_ui_state(self.token is not None) + self._auth_relogin_in_progress = False + return + self._log_event("auth_process", f"success expires_in={expires_in}") self.token = token # Сохраняем и получаем корректный expiration_time (0 или будущее время) @@ -1089,12 +1163,18 @@ class VkChatManager(QMainWindow): self.token_input.setText(self.token[:50] + "...") self.status_label.setText("Статус: авторизован") + if not self.token_countdown_timer.isActive(): + self.token_countdown_timer.start(1000) self.vk_service.set_token(self.token) self.vk_session = self.vk_service.session self.vk = self.vk_service.api self.set_ui_state(True) self._auth_relogin_in_progress = False - self.load_chats() + if rate_limited: + self.status_label.setText("Статус: токен сохранён. VK ограничил запросы; загрузка чатов через минуту.") + QTimer.singleShot(60_000, lambda current_token=token: self.load_chats() if self.token == current_token else None) + else: + self.load_chats() def handle_auth_token_on_load(self, token, expiration_time): self.token = token @@ -1175,7 +1255,7 @@ class VkChatManager(QMainWindow): "Требуется повторная авторизация", f"Во время {action_name} получена ошибка авторизации:\n" f"{self._format_vk_error(exc)}\n\n" - "Сейчас откроется окно авторизации VK." + "Сейчас откроется встроенное окно для повторного входа VK." ) self.start_auth(keep_status_text=True) @@ -1504,6 +1584,21 @@ class VkChatManager(QMainWindow): if non_chat_samples: self._log_event("load_chats", f"non_chat_samples={non_chat_samples}") except VkApiError as e: + if self._vk_error_code(e) == 9: + self._log_event("load_chats", "VK временно ограничил частоту запросов (код 9).", level="WARN") + self.status_label.setText("Статус: VK временно ограничил запросы. Повторите загрузку чатов позже.") + QMessageBox.warning(self, "VK Flood control", "VK временно ограничил запросы. Токен сохранён; повторите загрузку чатов позже.") + return + if self._vk_error_code(e) == 15: + self._log_event("load_chats", "Сохранённый токен не имеет доступа к чатам (код 15).", level="WARN") + self._clear_auth_state(stop_timer=True) + QMessageBox.warning( + self, + "Вход в VK", + "Сохранённый токен не имеет доступа к чатам (ошибка 15). " + "Войдите снова через другой ID клиента.", + ) + return if self._handle_vk_api_error("load_chats", e, action_name="загрузки чатов"): return QMessageBox.critical(self, "Ошибка", f"Не удалось загрузить чаты: {self._format_vk_error(e)}") @@ -1568,7 +1663,7 @@ if __name__ == "__main__": except Exception as exc: sys.stderr.write(f"[ERROR] auth_cli_args_invalid: {exc}\n") sys.exit(1) - auth_webview.main_auth(auth_url, output_path) + auth_webview.main_auth(auth_url, output_path, login_first="--login-first" in sys.argv) sys.exit(0) app = QApplication(sys.argv) app.setStyle("Fusion") diff --git a/requirements.txt b/requirements.txt index 00c2c52..460ffeb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ PySide6~=6.10.2 -vk-api~=11.9.9 +vk-api~=11.10.1 pywebview diff --git a/scripts/docker-build-installer.sh b/scripts/docker-build-installer.sh new file mode 100644 index 0000000..2936d3b --- /dev/null +++ b/scripts/docker-build-installer.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="${1:?usage: docker-build-installer.sh VERSION}" +container_id="$(docker create --platform linux/amd64 \ + amake/innosetup:innosetup6 \ + "/DMyAppVersion=$version" installer/AnabasisManager.iss)" +trap 'docker rm -f "$container_id" > /dev/null 2>&1 || true' EXIT + +docker cp installer "$container_id:/work/" +# Inno Setup runs as xclient; docker cp otherwise creates root-owned output directories. +chmod -R a+rwX dist +docker cp dist "$container_id:/work/" +docker cp icon.ico "$container_id:/work/" +docker start -a "$container_id" || true +status="$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" +if [ "$status" -ne 0 ]; then + echo "Inno Setup container failed with exit code $status" >&2 + exit "$status" +fi + +docker cp "$container_id:/work/dist/AnabasisManager-setup-$version.exe" dist/ diff --git a/scripts/docker-build-windows.sh b/scripts/docker-build-windows.sh new file mode 100644 index 0000000..219fadf --- /dev/null +++ b/scripts/docker-build-windows.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +image='batonogov/pyinstaller-windows:latest' +container_id="$(docker create --platform linux/amd64 \ + --env PYTHONUTF8=1 --env PYTHONIOENCODING=utf-8 \ + "$image" 'python build.py --skip-installer')" +trap 'docker rm -f "$container_id" > /dev/null 2>&1 || true' EXIT + +docker cp . "$container_id:/src" +docker start -a "$container_id" || true +status="$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" +if [ "$status" -ne 0 ]; then + echo "PyInstaller container failed with exit code $status" >&2 + exit "$status" +fi + +mkdir -p dist +docker cp "$container_id:/src/dist/." dist/ diff --git a/tests/test_auth_fallback.py b/tests/test_auth_fallback.py new file mode 100644 index 0000000..e9dce36 --- /dev/null +++ b/tests/test_auth_fallback.py @@ -0,0 +1,212 @@ +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse +from unittest.mock import Mock, patch + +import auth_webview +import main +from vk_api.exceptions import VkApiError + + +class AuthFallbackTests(unittest.TestCase): + def test_working_client_is_tried_first_with_vk_ru_redirect(self): + manager = main.VkChatManager.__new__(main.VkChatManager) + url = manager._build_vk_auth_url() + params = parse_qs(urlparse(url).query) + self.assertEqual(params["client_id"], ["6287487"]) + self.assertEqual(params["redirect_uri"], ["https://oauth.vk.ru/blank.html"]) + + def test_expired_session_reopens_embedded_auth(self): + manager = main.VkChatManager.__new__(main.VkChatManager) + manager._auth_relogin_in_progress = False + manager._last_auth_relogin_ts = 0 + manager._vk_error_code = Mock(return_value=5) + manager._format_vk_error = Mock(return_value="Session expired") + manager._log_event = Mock() + manager._clear_auth_state = Mock() + manager.start_auth = Mock() + with patch.object(main.QMessageBox, "warning"): + manager._force_relogin(Exception("Session expired"), "загрузки чатов") + manager.start_auth.assert_called_once_with(keep_status_text=True) + + def test_parses_token_from_full_vk_response_link(self): + token, expires_in = auth_webview.extract_token( + "https://oauth.vk.ru/blank.html#access_token=test-token&expires_in=0&user_id=123&email=private@example.test" + ) + self.assertEqual((token, expires_in), ("test-token", 0)) + + def test_reads_redirect_before_failed_page_load(self): + result = auth_webview.extract_redirect_result( + "https://oauth.vk.ru/blank.html#access_token=test-token&expires_in=0" + ) + self.assertEqual(result, {"token": "test-token", "expires_in": 0}) + self.assertIsNone( + auth_webview.extract_redirect_result( + "https://example.test/blank.html#access_token=test-token" + ) + ) + + def test_embedded_window_captures_redirect_on_navigation_start(self): + class Event: + def __init__(self): + self.handler = None + + def __iadd__(self, handler): + self.handler = handler + return self + + navigation = Event() + finished = auth_webview.threading.Event() + window = SimpleNamespace( + native=SimpleNamespace(webview=SimpleNamespace(NavigationStarting=navigation)), + events=SimpleNamespace(before_show=Event(), loaded=Event(), closed=Event()), + get_current_url=lambda: "https://vk.ru/feed", + evaluate_js=Mock(), + destroy=Mock(side_effect=finished.set), + ) + + def start(_callback, **_kwargs): + window.events.before_show.handler() + window.events.loaded.handler() + args = SimpleNamespace( + Uri="https://oauth.vk.ru/blank.html#access_token=test-token&expires_in=0", + Cancel=False, + ) + navigation.handler(None, args) + self.assertTrue(args.Cancel) + _callback() + self.assertTrue(finished.wait(2)) + + with tempfile.TemporaryDirectory() as temp_dir, patch.object( + auth_webview.webview, "create_window", return_value=window + ) as create_window, patch.object(auth_webview.webview, "start", side_effect=start): + result_path = Path(temp_dir) / "auth.json" + auth_webview.main_auth("https://oauth.vk.com/authorize", str(result_path), login_first=True) + create_window.assert_called_once_with("VK Авторизация", "https://vk.ru/") + self.assertIn("https://oauth.vk.com/authorize", window.evaluate_js.call_args.args[0]) + self.assertEqual( + json.loads(result_path.read_text(encoding="utf-8")), + {"token": "test-token", "expires_in": 0}, + ) + window.destroy.assert_called_once() + + def test_parses_vk_oauth_error_from_redirect_url(self): + error, description = auth_webview.extract_auth_error( + "https://oauth.vk.com/blank.html#error=invalid_client&error_description=Bad+client" + ) + self.assertEqual((error, description), ("invalid_client", "Bad client")) + + def test_parses_vk_oauth_error_from_json_page(self): + error, description = auth_webview.extract_auth_error_from_page( + '{"error":"invalid_request","error_description":"incorrect app. Unavailable for apps with direct auth."}' + ) + self.assertEqual(error, "invalid_request") + self.assertIn("Unavailable for apps with direct auth", description) + + def test_next_client_is_tried_after_oauth_error(self): + manager = main.VkChatManager.__new__(main.VkChatManager) + manager._auth_client_index = 0 + manager._auth_process_error_text = None + manager._auth_relogin_in_progress = False + manager._set_auth_ui_state = Mock() + manager._log_event = Mock() + manager.start_auth = Mock() + manager.status_label = Mock() + manager.set_ui_state = Mock() + + with tempfile.TemporaryDirectory() as directory: + result_path = Path(directory) / "auth_result.json" + result_path.write_text(json.dumps({"error": "invalid_client"}), encoding="utf-8") + manager.auth_output_path = str(result_path) + with patch.object(main, "VK_CLIENT_IDS", ("2274003", "3140623")): + manager._on_auth_process_finished(0, None) + + manager.start_auth.assert_called_once_with(client_index=1) + self.assertFalse(result_path.exists()) + + def test_user_denial_does_not_open_another_client(self): + manager = main.VkChatManager.__new__(main.VkChatManager) + manager._auth_client_index = 0 + manager._auth_process_error_text = None + manager._auth_relogin_in_progress = False + manager._set_auth_ui_state = Mock() + manager._log_event = Mock() + manager.start_auth = Mock() + manager.status_label = Mock() + manager.set_ui_state = Mock() + + with tempfile.TemporaryDirectory() as directory: + result_path = Path(directory) / "auth_result.json" + result_path.write_text(json.dumps({"error": "access_denied"}), encoding="utf-8") + manager.auth_output_path = str(result_path) + manager._on_auth_process_finished(0, None) + + manager.start_auth.assert_not_called() + manager.set_ui_state.assert_called_once_with(False) + + def test_api_access_denied_tries_next_client_without_saving_token(self): + manager = main.VkChatManager.__new__(main.VkChatManager) + manager._log_event = Mock() + manager._auth_relogin_in_progress = False + manager.status_label = Mock() + manager.start_auth = Mock() + manager.set_ui_state = Mock() + manager.token = None + + class DeniedService: + def __init__(self): + self.api = SimpleNamespace(messages=SimpleNamespace(getConversations=Mock(side_effect=VkApiError("Access denied")))) + + def set_token(self, token): + pass + + def vk_error_code(self, exc): + return 15 + + with patch.object(main, "VkService", DeniedService), patch.object(main, "token_store_save_token") as save: + manager.handle_new_auth_token("candidate-token", 0, client_index=0) + + manager.start_auth.assert_called_once_with(client_index=1) + save.assert_not_called() + self.assertIsNone(manager.token) + + def test_flood_control_keeps_token_and_delays_chat_loading(self): + manager = main.VkChatManager.__new__(main.VkChatManager) + manager._log_event = Mock() + manager._auth_relogin_in_progress = False + manager.status_label = Mock() + manager.token_input = Mock() + manager.token_countdown_timer = Mock() + manager.token_countdown_timer.isActive.return_value = False + manager.vk_service = Mock() + manager.set_ui_state = Mock() + manager.load_chats = Mock() + manager.token = None + + class LimitedService: + def __init__(self): + self.api = SimpleNamespace(messages=SimpleNamespace(getConversations=Mock(side_effect=VkApiError("Flood control")))) + + def set_token(self, token): + pass + + def vk_error_code(self, exc): + return 9 + + with patch.object(main, "VkService", LimitedService), patch.object(main, "token_store_save_token", return_value=0) as save, patch.object(main.QTimer, "singleShot") as delay: + manager.handle_new_auth_token("candidate-token", 0) + + save.assert_called_once() + delay.assert_called_once() + self.assertEqual(delay.call_args.args[0], 60_000) + manager.load_chats.assert_not_called() + self.assertEqual(manager.token, "candidate-token") + delay.call_args.args[1]() + manager.load_chats.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main_contracts.py b/tests/test_main_contracts.py index 533a44e..4c5ccd0 100644 --- a/tests/test_main_contracts.py +++ b/tests/test_main_contracts.py @@ -40,7 +40,7 @@ class MainContractsTests(unittest.TestCase): def test_unset_user_admin_method_exists(self): self._find_method("unset_user_admin") - def test_uses_custom_standalone_vk_app(self): + def test_uses_vk_blank_page_auth(self): constants = {} for node in self.module.body: if not isinstance(node, ast.Assign) or len(node.targets) != 1: @@ -52,9 +52,15 @@ class MainContractsTests(unittest.TestCase): except Exception: continue - self.assertEqual(constants.get("VK_APP_ID"), "54454043") - self.assertIn("ANABASIS_VK_REDIRECT_URI", self.main_source) - self.assertIn("https://vk.daemonlord.ru/vk/callback", self.main_source) + self.assertEqual( + constants.get("VK_CLIENT_IDS"), + ("6287487", "2274003", "3140623", "3682744", "3697615", "6464690", "7497650", "4083558"), + ) + self.assertEqual(constants.get("VK_CLIENT_NAMES", {}).get("6287487"), "VK (рабочий ID)") + self.assertEqual(constants.get("VK_CLIENT_NAMES", {}).get("6464690"), "Маруся") + self.assertEqual(constants.get("VK_CLIENT_NAMES", {}).get("7497650"), "VK Connect") + self.assertEqual(constants.get("VK_CLIENT_NAMES", {}).get("4083558"), "VFeed") + self.assertEqual(constants.get("VK_AUTH_REDIRECT_URI"), "https://oauth.vk.com/blank.html") def test_remove_action_demotes_member_before_removing(self): bulk_worker = None diff --git a/tests/test_update_reentry_runtime.py b/tests/test_update_reentry_runtime.py index 0531662..22bf399 100644 --- a/tests/test_update_reentry_runtime.py +++ b/tests/test_update_reentry_runtime.py @@ -27,10 +27,10 @@ class _DummyThread: def start(self): self.started.emit() - def quit(self): + def quit(self, *_args, **_kwargs): self.finished.emit() - def deleteLater(self): + def deleteLater(self, *_args, **_kwargs): return None @@ -48,7 +48,7 @@ class _DummyChecker: def run(self): return None - def deleteLater(self): + def deleteLater(self, *_args, **_kwargs): return None diff --git a/ui/main_window.py b/ui/main_window.py index 557a5b3..114c7c9 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,7 +1,7 @@ def instructions_text(): return ( "Инструкция:\n" - "1. Авторизуйтесь через VK.\n" + "1. Нажмите «Войти через VK», войдите во встроенном окне и нажмите «Я вошёл в VK — получить токен».\n" "2. Выберите чаты.\n" "3. Вставьте ссылку на пользователя в поле ниже. ID определится автоматически.\n" "4. Для массовых операций нажмите кнопку 'Список' и вставьте ссылки в окне.\n"