4 Commits

Author SHA1 Message Date
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 257 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,99 @@
name: Desktop Release
on:
push:
branches:
- master
workflow_dispatch:
jobs:
release:
runs-on: windows-latest
steps:
- name: Checkout
uses: https://git.daemonlord.ru/actions/checkout@v4
with:
fetch-depth: 0
tags: true
- name: Set up Python
uses: https://git.daemonlord.ru/actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
shell: bash
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt pyinstaller
- name: Extract app version
id: extract_version
shell: bash
run: |
VERSION=$(python -c "from app_version import APP_VERSION; print(APP_VERSION)")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Detected version: $VERSION"
- name: Stop if version already released
id: stop
shell: bash
run: |
VERSION="${{ steps.extract_version.outputs.version }}"
if git show-ref --tags --quiet --verify "refs/tags/$VERSION"; then
echo "Version $VERSION already released, stopping job."
echo "CONTINUE=false" >> "$GITHUB_ENV"
else
echo "Version $VERSION not released yet, continuing workflow..."
echo "CONTINUE=true" >> "$GITHUB_ENV"
fi
- name: Run tests
if: env.CONTINUE == 'true'
shell: bash
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: bash
run: |
python build.py
- name: Ensure archive exists
if: env.CONTINUE == 'true'
shell: bash
run: |
VERSION="${{ steps.extract_version.outputs.version }}"
test -f "dist/AnabasisManager-$VERSION.zip"
- name: Configure git identity
if: env.CONTINUE == 'true'
shell: bash
run: |
git config user.name "gitea-actions"
git config user.email "gitea-actions@daemonlord.ru"
- name: Create git tag
if: env.CONTINUE == 'true'
shell: bash
run: |
VERSION="${{ steps.extract_version.outputs.version }}"
git tag "$VERSION"
git push origin "$VERSION"
- 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: ${{ steps.extract_version.outputs.version }}
name: Release ${{ steps.extract_version.outputs.version }}
body: |
Desktop release ${{ steps.extract_version.outputs.version }}
files: |
dist/AnabasisManager-${{ steps.extract_version.outputs.version }}.zip

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.1"

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)

118
main.py
View File

@@ -8,9 +8,13 @@ import time
import auth_webview import auth_webview
import os import os
import re import re
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 +37,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).
@@ -695,15 +698,21 @@ 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")
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):
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 +745,111 @@ 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 _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):
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)
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,13 @@ 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):", self.source)
if __name__ == "__main__": if __name__ == "__main__":