17 Commits

Author SHA1 Message Date
90b3b4fc9d chore(release): bump version to 1.6.2
All checks were successful
Desktop CI / tests (push) Successful in 12s
Desktop Release / release (push) Successful in 1m57s
2026-02-15 18:46:21 +03:00
190e67c931 feat(update): stage 2 sha256 verification for auto-update
All checks were successful
Desktop CI / tests (push) Successful in 13s
Desktop Release / release (push) Successful in 16s
2026-02-15 18:42:37 +03:00
2eb4c52b81 ci(release): set release title to Anabasis Manager <version>
All checks were successful
Desktop CI / tests (push) Successful in 13s
Desktop Release / release (push) Successful in 2m16s
2026-02-15 17:36:02 +03:00
3d73a504d2 ci(release): use v-prefixed semantic tags 2026-02-15 17:35:37 +03:00
1524271be7 ci(release): write outputs/env as utf8 no bom for powershell
All checks were successful
Desktop CI / tests (push) Successful in 13s
Desktop Release / release (push) Successful in 2m5s
2026-02-15 17:31:37 +03:00
561cf43e09 ci(release): handle missing tag exit code in powershell step
All checks were successful
Desktop CI / tests (push) Successful in 14s
Desktop Release / release (push) Successful in 24s
2026-02-15 17:30:22 +03:00
e8930f7550 ci(release): switch windows release steps from bash to powershell
Some checks failed
Desktop CI / tests (push) Successful in 13s
Desktop Release / release (push) Failing after 1m14s
2026-02-15 17:28:29 +03:00
c8da0f9191 ci(release): use preinstalled Python on self-hosted windows runner
Some checks failed
Desktop CI / tests (push) Successful in 13s
Desktop Release / release (push) Failing after 14s
2026-02-15 17:20:42 +03:00
37ce500fd2 ci(release): remove node bootstrap step, require system node
Some checks failed
Desktop CI / tests (push) Successful in 12s
Desktop Release / release (push) Failing after 25s
2026-02-15 17:19:24 +03:00
098a84e5bd ci(release): restore powershell node bootstrap
Some checks failed
Desktop CI / tests (push) Successful in 12s
Desktop Release / release (push) Failing after 2m42s
2026-02-15 16:42:08 +03:00
5aa17c1a84 ci(release): avoid powershell policy by using cmd node bootstrap
All checks were successful
Desktop CI / tests (push) Successful in 13s
2026-02-15 16:38:40 +03:00
dde14f3714 ci(release): bootstrap node before js-based actions
Some checks failed
Desktop CI / tests (push) Successful in 12s
Desktop Release / release (push) Failing after 5s
2026-02-15 16:37:42 +03:00
fa5d4c6993 ci(release): use windows runner label
Some checks failed
Desktop CI / tests (push) Successful in 20s
Desktop Release / release (push) Failing after 1m21s
2026-02-15 16:35:21 +03:00
f9e0225243 ci: add Gitea CI and desktop release workflow
Some checks failed
Desktop CI / tests (push) Successful in 1m29s
Desktop Release / release (push) Has been cancelled
2026-02-15 15:27:21 +03:00
c42b23bea5 chore(release): bump version to 1.6.1 2026-02-15 15:25:21 +03:00
b52cdea425 feat(update): stage 1 auto-update (one-click) 2026-02-15 15:24:45 +03:00
b7fad78a71 refactor(release): bump to 1.6.0 and unify version source 2026-02-15 15:17:30 +03:00
7 changed files with 362 additions and 4 deletions

35
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,35 @@
name: Desktop CI
on:
push:
branches:
- master
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.daemonlord.ru/actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: https://git.daemonlord.ru/actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Validate syntax
run: |
python -m py_compile app_version.py main.py build.py tests/test_auth_relogin_smoke.py
- name: Run tests
run: |
python -m unittest tests/test_auth_relogin_smoke.py

View File

@@ -0,0 +1,133 @@
name: Desktop Release
on:
push:
branches:
- master
jobs:
release:
runs-on: windows
steps:
- name: Checkout
uses: https://git.daemonlord.ru/actions/checkout@v4
with:
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: Install dependencies
shell: powershell
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt pyinstaller
- name: Extract app version
id: extract_version
shell: powershell
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"
- name: Stop if version already released
id: stop
shell: powershell
run: |
$version = "${{ steps.extract_version.outputs.version }}"
$tag = "v$version"
git show-ref --tags --quiet --verify "refs/tags/$tag"
$tagExists = ($LASTEXITCODE -eq 0)
$global:LASTEXITCODE = 0
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
if ($tagExists) {
Write-Host "Version $tag already released, stopping job."
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "CONTINUE=false`n", $utf8NoBom)
} else {
Write-Host "Version $tag not released yet, continuing workflow..."
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "CONTINUE=true`n", $utf8NoBom)
}
exit 0
- name: Run tests
if: env.CONTINUE == 'true'
shell: powershell
run: |
python -m py_compile app_version.py main.py build.py tests/test_auth_relogin_smoke.py
python -m unittest tests/test_auth_relogin_smoke.py
- name: Build release zip
if: env.CONTINUE == 'true'
shell: powershell
run: |
python build.py
- name: Ensure archive exists
if: env.CONTINUE == 'true'
shell: powershell
run: |
$version = "${{ steps.extract_version.outputs.version }}"
$archivePath = "dist/AnabasisManager-$version.zip"
if (-not (Test-Path $archivePath)) {
throw "Archive not found: $archivePath"
}
- name: Generate SHA256 checksum
if: env.CONTINUE == 'true'
shell: powershell
run: |
$version = "${{ steps.extract_version.outputs.version }}"
$archiveName = "AnabasisManager-$version.zip"
$archivePath = "dist/$archiveName"
$checksumPath = "dist/$archiveName.sha256"
$hash = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower()
"$hash $archiveName" | Set-Content -Path $checksumPath -Encoding UTF8
Write-Host "Checksum created: $checksumPath"
- 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"
- name: Create git tag
if: env.CONTINUE == 'true'
shell: powershell
run: |
$version = "${{ steps.extract_version.outputs.version }}"
$tag = "v$version"
git tag "$tag"
git push origin "$tag"
- name: Create Gitea Release
if: env.CONTINUE == '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 }}
name: Anabasis Manager ${{ steps.extract_version.outputs.version }}
body: |
Desktop release v${{ steps.extract_version.outputs.version }}
files: |
dist/AnabasisManager-${{ steps.extract_version.outputs.version }}.zip
dist/AnabasisManager-${{ steps.extract_version.outputs.version }}.zip.sha256

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ __pycache__/
tests/__pycache__/ tests/__pycache__/
build/ build/
dist/ dist/
AnabasisManager.spec

1
app_version.py Normal file
View File

@@ -0,0 +1 @@
APP_VERSION = "1.6.2"

View File

@@ -2,10 +2,11 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
from app_version import APP_VERSION
# --- Конфигурация --- # --- Конфигурация ---
APP_NAME = "AnabasisManager" APP_NAME = "AnabasisManager"
VERSION = "1.5.1" # Ваша версия VERSION = APP_VERSION # Единая версия приложения
MAIN_SCRIPT = "main.py" MAIN_SCRIPT = "main.py"
ICON_PATH = "icon.ico" ICON_PATH = "icon.ico"
DIST_DIR = os.path.join("dist", APP_NAME) DIST_DIR = os.path.join("dist", APP_NAME)

188
main.py
View File

@@ -8,9 +8,14 @@ import time
import auth_webview import auth_webview
import os import os
import re import re
import hashlib
import subprocess
import threading import threading
import tempfile
import urllib.error import urllib.error
import urllib.request import urllib.request
import zipfile
from app_version import APP_VERSION
from PySide6.QtWidgets import (QApplication, QMainWindow, QLabel, QLineEdit, from PySide6.QtWidgets import (QApplication, QMainWindow, QLabel, QLineEdit,
QPushButton, QVBoxLayout, QWidget, QMessageBox, QPushButton, QVBoxLayout, QWidget, QMessageBox,
QTextBrowser, QScrollArea, QCheckBox, QHBoxLayout, QTextBrowser, QScrollArea, QCheckBox, QHBoxLayout,
@@ -33,7 +38,6 @@ LOG_FILE = os.path.join(APP_DATA_DIR, "app.log")
LOG_MAX_BYTES = 1024 * 1024 # 1 MB LOG_MAX_BYTES = 1024 * 1024 # 1 MB
LOG_BACKUP_FILE = os.path.join(APP_DATA_DIR, "app.log.1") LOG_BACKUP_FILE = os.path.join(APP_DATA_DIR, "app.log.1")
AUTH_RELOGIN_BACKOFF_SECONDS = 5.0 AUTH_RELOGIN_BACKOFF_SECONDS = 5.0
APP_VERSION = "1.5.1"
# Legacy owner/repo format for GitHub-only fallback. # Legacy owner/repo format for GitHub-only fallback.
UPDATE_REPOSITORY = "" UPDATE_REPOSITORY = ""
# Full repository URL is preferred (supports GitHub/Gitea). # Full repository URL is preferred (supports GitHub/Gitea).
@@ -354,13 +358,30 @@ class UpdateChecker(QObject):
html_url = release_data.get("html_url") or releases_url html_url = release_data.get("html_url") or releases_url
assets = release_data.get("assets") or [] assets = release_data.get("assets") or []
download_url = "" download_url = ""
download_name = ""
checksum_url = ""
for asset in assets: for asset in assets:
url = asset.get("browser_download_url", "") url = asset.get("browser_download_url", "")
if url.lower().endswith(".zip"): if url.lower().endswith(".zip"):
download_url = url download_url = url
download_name = asset.get("name", "")
break break
if not download_url and assets: if not download_url and assets:
download_url = assets[0].get("browser_download_url", "") download_url = assets[0].get("browser_download_url", "")
download_name = assets[0].get("name", "")
for asset in assets:
name = asset.get("name", "").lower()
if not name:
continue
is_checksum_asset = name.endswith(".sha256") or name.endswith(".sha256.txt") or name in ("checksums.txt", "sha256sums.txt")
if not is_checksum_asset:
continue
if download_name and (download_name.lower() in name or name in (f"{download_name.lower()}.sha256", f"{download_name.lower()}.sha256.txt")):
checksum_url = asset.get("browser_download_url", "")
break
if not checksum_url:
checksum_url = asset.get("browser_download_url", "")
self.check_finished.emit( self.check_finished.emit(
{ {
@@ -370,6 +391,8 @@ class UpdateChecker(QObject):
"latest_tag": latest_tag, "latest_tag": latest_tag,
"release_url": html_url, "release_url": html_url,
"download_url": download_url, "download_url": download_url,
"download_name": download_name,
"checksum_url": checksum_url,
"has_update": _is_newer_version(latest_version, self.current_version), "has_update": _is_newer_version(latest_version, self.current_version),
} }
) )
@@ -695,15 +718,23 @@ class VkChatManager(QMainWindow):
f"Доступная версия: {latest_version}\n\n" f"Доступная версия: {latest_version}\n\n"
"Открыть страницу загрузки?" "Открыть страницу загрузки?"
) )
update_now_button = message_box.addButton("Обновить сейчас", QMessageBox.AcceptRole)
download_button = message_box.addButton("Скачать", QMessageBox.AcceptRole) download_button = message_box.addButton("Скачать", QMessageBox.AcceptRole)
releases_button = message_box.addButton("Релизы", QMessageBox.ActionRole) releases_button = message_box.addButton("Релизы", QMessageBox.ActionRole)
cancel_button = message_box.addButton("Позже", QMessageBox.RejectRole) cancel_button = message_box.addButton("Позже", QMessageBox.RejectRole)
message_box.setDefaultButton(download_button) message_box.setDefaultButton(update_now_button)
message_box.exec() message_box.exec()
clicked = message_box.clickedButton() clicked = message_box.clickedButton()
download_url = result.get("download_url") download_url = result.get("download_url")
checksum_url = result.get("checksum_url")
download_name = result.get("download_name")
release_url = result.get("release_url") release_url = result.get("release_url")
if clicked is update_now_button and download_url:
if not self._start_auto_update(download_url, latest_version, checksum_url, download_name):
if release_url:
QDesktopServices.openUrl(QUrl(release_url))
return
if clicked is download_button and download_url: if clicked is download_button and download_url:
QDesktopServices.openUrl(QUrl(download_url)) QDesktopServices.openUrl(QUrl(download_url))
elif clicked in (download_button, releases_button) and release_url: elif clicked in (download_button, releases_button) and release_url:
@@ -736,6 +767,159 @@ class VkChatManager(QMainWindow):
if not self._update_check_silent: if not self._update_check_silent:
QMessageBox.warning(self, "Проверка обновлений", error_text) QMessageBox.warning(self, "Проверка обновлений", error_text)
def _download_update_archive(self, download_url, destination_path):
request = urllib.request.Request(
download_url,
headers={"User-Agent": "AnabasisManager-Updater"},
)
with urllib.request.urlopen(request, timeout=60) as response:
with open(destination_path, "wb") as f:
shutil.copyfileobj(response, f)
def _download_update_text(self, url):
request = urllib.request.Request(
url,
headers={"User-Agent": "AnabasisManager-Updater"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return response.read().decode("utf-8", errors="replace")
@staticmethod
def _sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest().lower()
@staticmethod
def _extract_sha256_from_text(checksum_text, target_file_name):
target = (target_file_name or "").strip().lower()
for raw_line in checksum_text.splitlines():
line = raw_line.strip()
if not line:
continue
match = re.search(r"\b([A-Fa-f0-9]{64})\b", line)
if not match:
continue
checksum = match.group(1).lower()
if not target:
return checksum
line_lower = line.lower()
if target in line_lower:
return checksum
if os.path.basename(target) in line_lower:
return checksum
return ""
def _verify_update_checksum(self, zip_path, checksum_url, download_name):
if not checksum_url:
raise RuntimeError("В релизе нет файла SHA256. Автообновление остановлено.")
checksum_text = self._download_update_text(checksum_url)
expected_hash = self._extract_sha256_from_text(checksum_text, download_name or os.path.basename(zip_path))
if not expected_hash:
raise RuntimeError("Не удалось найти SHA256 для архива обновления.")
actual_hash = self._sha256_file(zip_path)
if actual_hash != expected_hash:
raise RuntimeError("SHA256 не совпадает, обновление отменено.")
def _locate_extracted_root(self, extracted_dir):
entries = []
for name in os.listdir(extracted_dir):
full_path = os.path.join(extracted_dir, name)
if os.path.isdir(full_path):
entries.append(full_path)
if len(entries) == 1:
candidate = entries[0]
if os.path.exists(os.path.join(candidate, "AnabasisManager.exe")):
return candidate
return extracted_dir
def _build_update_script(self, app_dir, source_dir, exe_name):
script_path = os.path.join(tempfile.gettempdir(), "anabasis_apply_update.cmd")
script_lines = [
"@echo off",
"setlocal",
f"set APP_DIR={app_dir}",
f"set SRC_DIR={source_dir}",
f"set EXE_NAME={exe_name}",
"timeout /t 2 /nobreak >nul",
"robocopy \"%SRC_DIR%\" \"%APP_DIR%\" /E /NFL /NDL /NJH /NJS /NP /R:3 /W:1 >nul",
"set RC=%ERRORLEVEL%",
"if %RC% GEQ 8 goto :copy_error",
"start \"\" \"%APP_DIR%\\%EXE_NAME%\"",
"exit /b 0",
":copy_error",
"echo Auto-update failed with code %RC% > \"%APP_DIR%\\update_error.log\"",
"exit /b %RC%",
]
with open(script_path, "w", encoding="utf-8", newline="\r\n") as f:
f.write("\r\n".join(script_lines) + "\r\n")
return script_path
def _start_auto_update(self, download_url, latest_version, checksum_url="", download_name=""):
if os.name != "nt":
QMessageBox.information(
self,
"Автообновление",
"Автообновление пока поддерживается только в Windows-сборке.",
)
return False
if not getattr(sys, "frozen", False):
QMessageBox.information(
self,
"Автообновление",
"Автообновление доступно в собранной версии приложения (.exe).",
)
return False
if not download_url:
QMessageBox.warning(self, "Автообновление", "В релизе нет ссылки на файл для обновления.")
return False
self.status_label.setText(f"Статус: загрузка обновления {latest_version}...")
self._set_busy(True)
work_dir = tempfile.mkdtemp(prefix="anabasis_update_")
zip_path = os.path.join(work_dir, "update.zip")
unpack_dir = os.path.join(work_dir, "extracted")
try:
self._download_update_archive(download_url, zip_path)
self._verify_update_checksum(zip_path, checksum_url, download_name)
os.makedirs(unpack_dir, exist_ok=True)
with zipfile.ZipFile(zip_path, "r") as archive:
archive.extractall(unpack_dir)
source_dir = self._locate_extracted_root(unpack_dir)
app_exe = sys.executable
app_dir = os.path.dirname(app_exe)
exe_name = os.path.basename(app_exe)
script_path = self._build_update_script(app_dir, source_dir, exe_name)
creation_flags = 0
if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
creation_flags |= subprocess.CREATE_NEW_PROCESS_GROUP
if hasattr(subprocess, "DETACHED_PROCESS"):
creation_flags |= subprocess.DETACHED_PROCESS
subprocess.Popen(
["cmd.exe", "/c", script_path],
cwd=work_dir,
creationflags=creation_flags,
)
self._log_event("auto_update", f"Update {latest_version} started from {download_url}")
QMessageBox.information(
self,
"Обновление запущено",
"Обновление скачано. Приложение будет перезапущено.",
)
QTimer.singleShot(150, QApplication.instance().quit)
return True
except Exception as e:
self._log_event("auto_update_failed", str(e), level="ERROR")
QMessageBox.warning(self, "Автообновление", f"Не удалось выполнить автообновление: {e}")
return False
finally:
self._set_busy(False)
def setup_token_timer(self): def setup_token_timer(self):
self.token_countdown_timer = QTimer(self) self.token_countdown_timer = QTimer(self)
self.token_countdown_timer.timeout.connect(self.update_token_timer_display) self.token_countdown_timer.timeout.connect(self.update_token_timer_display)

View File

@@ -38,11 +38,14 @@ class AuthReloginSmokeTests(unittest.TestCase):
self.assertNotIn("self.retail_coffee_checkboxes", self.source) self.assertNotIn("self.retail_coffee_checkboxes", self.source)
def test_update_check_actions_exist(self): def test_update_check_actions_exist(self):
self.assertIn("APP_VERSION = ", self.source) self.assertIn("from app_version import APP_VERSION", self.source)
self.assertIn("UPDATE_REPOSITORY = ", self.source) self.assertIn("UPDATE_REPOSITORY = ", self.source)
self.assertIn('QAction("Проверить обновления", self)', self.source) self.assertIn('QAction("Проверить обновления", self)', self.source)
self.assertIn("def check_for_updates(self, silent_no_updates=False):", self.source) self.assertIn("def check_for_updates(self, silent_no_updates=False):", self.source)
self.assertIn("class UpdateChecker(QObject):", self.source) self.assertIn("class UpdateChecker(QObject):", self.source)
self.assertIn('message_box.addButton("Обновить сейчас", QMessageBox.AcceptRole)', self.source)
self.assertIn("def _start_auto_update(self, download_url, latest_version, checksum_url=\"\", download_name=\"\"):", self.source)
self.assertIn("def _verify_update_checksum(self, zip_path, checksum_url, download_name):", self.source)
if __name__ == "__main__": if __name__ == "__main__":