574 lines
19 KiB
Python
574 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
SAIKYO OS License Activator - Modern GUI
|
||
Beautiful activation interface with dark theme
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
import subprocess
|
||
import re
|
||
from datetime import datetime
|
||
|
||
try:
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QLabel, QPushButton, QLineEdit, QTextEdit, QFrame, QMessageBox,
|
||
QFileDialog, QStackedWidget, QGroupBox, QSpacerItem, QSizePolicy
|
||
)
|
||
from PyQt6.QtCore import Qt, QTimer, QSize
|
||
from PyQt6.QtGui import QFont, QPixmap, QIcon, QPalette, QColor
|
||
PYQT_VERSION = 6
|
||
except ImportError:
|
||
from PyQt5.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QLabel, QPushButton, QLineEdit, QTextEdit, QFrame, QMessageBox,
|
||
QFileDialog, QStackedWidget, QGroupBox, QSpacerItem, QSizePolicy
|
||
)
|
||
from PyQt5.QtCore import Qt, QTimer, QSize
|
||
from PyQt5.QtGui import QFont, QPixmap, QIcon, QPalette, QColor
|
||
PYQT_VERSION = 5
|
||
|
||
# Saikyo OS color scheme
|
||
COLORS = {
|
||
'bg_dark': '#0a0a0a',
|
||
'bg_card': '#1a1a1a',
|
||
'bg_input': '#252525',
|
||
'accent': '#00ff66',
|
||
'accent_hover': '#00cc52',
|
||
'text': '#ffffff',
|
||
'text_secondary': '#888888',
|
||
'border': '#333333',
|
||
'success': '#00ff66',
|
||
'warning': '#ffaa00',
|
||
'error': '#ff4444',
|
||
}
|
||
|
||
STYLESHEET = f"""
|
||
QMainWindow, QWidget {{
|
||
background-color: {COLORS['bg_dark']};
|
||
color: {COLORS['text']};
|
||
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
|
||
}}
|
||
|
||
QLabel {{
|
||
color: {COLORS['text']};
|
||
background: transparent;
|
||
}}
|
||
|
||
QLabel#title {{
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
color: {COLORS['accent']};
|
||
}}
|
||
|
||
QLabel#subtitle {{
|
||
font-size: 14px;
|
||
color: {COLORS['text_secondary']};
|
||
}}
|
||
|
||
QLabel#status_label {{
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
padding: 10px;
|
||
border-radius: 8px;
|
||
}}
|
||
|
||
QLabel#status_active {{
|
||
background-color: rgba(0, 255, 102, 0.15);
|
||
color: {COLORS['success']};
|
||
border: 1px solid {COLORS['success']};
|
||
}}
|
||
|
||
QLabel#status_trial {{
|
||
background-color: rgba(255, 170, 0, 0.15);
|
||
color: {COLORS['warning']};
|
||
border: 1px solid {COLORS['warning']};
|
||
}}
|
||
|
||
QLabel#status_expired, QLabel#status_missing {{
|
||
background-color: rgba(255, 68, 68, 0.15);
|
||
color: {COLORS['error']};
|
||
border: 1px solid {COLORS['error']};
|
||
}}
|
||
|
||
QPushButton {{
|
||
background-color: {COLORS['bg_card']};
|
||
color: {COLORS['text']};
|
||
border: 1px solid {COLORS['border']};
|
||
border-radius: 8px;
|
||
padding: 12px 24px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
min-width: 120px;
|
||
}}
|
||
|
||
QPushButton:hover {{
|
||
background-color: {COLORS['bg_input']};
|
||
border-color: {COLORS['accent']};
|
||
}}
|
||
|
||
QPushButton:pressed {{
|
||
background-color: {COLORS['accent']};
|
||
color: {COLORS['bg_dark']};
|
||
}}
|
||
|
||
QPushButton#primary {{
|
||
background-color: {COLORS['accent']};
|
||
color: {COLORS['bg_dark']};
|
||
border: none;
|
||
font-weight: bold;
|
||
}}
|
||
|
||
QPushButton#primary:hover {{
|
||
background-color: {COLORS['accent_hover']};
|
||
}}
|
||
|
||
QLineEdit, QTextEdit {{
|
||
background-color: {COLORS['bg_input']};
|
||
color: {COLORS['text']};
|
||
border: 1px solid {COLORS['border']};
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
font-size: 14px;
|
||
}}
|
||
|
||
QLineEdit:focus, QTextEdit:focus {{
|
||
border-color: {COLORS['accent']};
|
||
}}
|
||
|
||
QGroupBox {{
|
||
background-color: {COLORS['bg_card']};
|
||
border: 1px solid {COLORS['border']};
|
||
border-radius: 12px;
|
||
margin-top: 16px;
|
||
padding: 20px;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
}}
|
||
|
||
QGroupBox::title {{
|
||
subcontrol-origin: margin;
|
||
left: 20px;
|
||
padding: 0 10px;
|
||
color: {COLORS['text_secondary']};
|
||
}}
|
||
|
||
QFrame#separator {{
|
||
background-color: {COLORS['border']};
|
||
max-height: 1px;
|
||
}}
|
||
|
||
QFrame#card {{
|
||
background-color: {COLORS['bg_card']};
|
||
border: 1px solid {COLORS['border']};
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
}}
|
||
"""
|
||
|
||
|
||
class LicenseManager:
|
||
"""Interface to saikyo-license CLI"""
|
||
|
||
LICENSE_CMD = '/usr/sbin/saikyo-license'
|
||
|
||
@classmethod
|
||
def run_cmd(cls, *args):
|
||
try:
|
||
result = subprocess.run(
|
||
[cls.LICENSE_CMD] + list(args),
|
||
capture_output=True, text=True, timeout=30
|
||
)
|
||
return result.returncode, result.stdout, result.stderr
|
||
except Exception as e:
|
||
return -1, '', str(e)
|
||
|
||
@classmethod
|
||
def run_privileged(cls, *args):
|
||
try:
|
||
result = subprocess.run(
|
||
['pkexec', cls.LICENSE_CMD] + list(args),
|
||
capture_output=True, text=True, timeout=60
|
||
)
|
||
return result.returncode, result.stdout, result.stderr
|
||
except Exception as e:
|
||
return -1, '', str(e)
|
||
|
||
@classmethod
|
||
def get_status(cls):
|
||
code, out, err = cls.run_cmd('status')
|
||
status = {
|
||
'status': 'unknown',
|
||
'trial_days': '',
|
||
'trial_remaining_seconds': '0',
|
||
'valid_until': '',
|
||
'expired': 'no',
|
||
'org_name': '',
|
||
'tier': '',
|
||
'machine_id': '',
|
||
}
|
||
for line in out.split('\n'):
|
||
if '=' in line:
|
||
k, v = line.split('=', 1)
|
||
k = k.strip().replace('-', '_')
|
||
status[k] = v.strip()
|
||
return status
|
||
|
||
@classmethod
|
||
def verify(cls):
|
||
code, _, _ = cls.run_cmd('verify')
|
||
return code == 0
|
||
|
||
@classmethod
|
||
def install_code(cls, code):
|
||
return cls.run_privileged('install-code', code)
|
||
|
||
@classmethod
|
||
def install_files(cls, json_path, sig_path):
|
||
return cls.run_privileged('install', json_path, sig_path)
|
||
|
||
@classmethod
|
||
def activate_online(cls, token, server='https://saikyo-os.ru/license'):
|
||
return cls.run_privileged('activate', '--token', token, '--server', server)
|
||
|
||
@classmethod
|
||
def get_machine_id(cls):
|
||
try:
|
||
with open('/etc/machine-id', 'r') as f:
|
||
return f.read().strip()
|
||
except:
|
||
return 'Не удалось прочитать'
|
||
|
||
|
||
def human_duration(seconds):
|
||
try:
|
||
s = int(seconds)
|
||
except:
|
||
return '—'
|
||
if s <= 0:
|
||
return '0м'
|
||
d = s // 86400
|
||
h = (s % 86400) // 3600
|
||
m = (s % 3600) // 60
|
||
if d > 0:
|
||
return f'{d}д {h}ч {m}м'
|
||
elif h > 0:
|
||
return f'{h}ч {m}м'
|
||
else:
|
||
return f'{m}м'
|
||
|
||
|
||
class StatusCard(QFrame):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setObjectName('card')
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(16)
|
||
|
||
# Status badge
|
||
self.status_label = QLabel('Загрузка...')
|
||
self.status_label.setObjectName('status_label')
|
||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter if PYQT_VERSION == 6 else Qt.AlignCenter)
|
||
layout.addWidget(self.status_label)
|
||
|
||
# Info grid
|
||
info_layout = QVBoxLayout()
|
||
info_layout.setSpacing(8)
|
||
|
||
self.org_label = self._create_info_row('Организация:', '—')
|
||
self.tier_label = self._create_info_row('Тариф:', '—')
|
||
self.remaining_label = self._create_info_row('Осталось:', '—')
|
||
self.valid_until_label = self._create_info_row('Действует до:', '—')
|
||
self.machine_id_label = self._create_info_row('Machine ID:', '—')
|
||
|
||
info_layout.addLayout(self.org_label[0])
|
||
info_layout.addLayout(self.tier_label[0])
|
||
info_layout.addLayout(self.remaining_label[0])
|
||
info_layout.addLayout(self.valid_until_label[0])
|
||
info_layout.addLayout(self.machine_id_label[0])
|
||
|
||
layout.addLayout(info_layout)
|
||
|
||
def _create_info_row(self, label_text, value_text):
|
||
layout = QHBoxLayout()
|
||
label = QLabel(label_text)
|
||
label.setStyleSheet(f'color: {COLORS["text_secondary"]}; font-size: 13px;')
|
||
value = QLabel(value_text)
|
||
value.setStyleSheet('font-size: 13px; font-weight: 500;')
|
||
value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse if PYQT_VERSION == 6 else Qt.TextSelectableByMouse)
|
||
layout.addWidget(label)
|
||
layout.addStretch()
|
||
layout.addWidget(value)
|
||
return (layout, value)
|
||
|
||
def update_status(self, status_data):
|
||
status = status_data.get('status', 'unknown')
|
||
|
||
# Update status badge
|
||
if status == 'trial':
|
||
self.status_label.setText('⏱ ПРОБНЫЙ ПЕРИОД')
|
||
self.status_label.setObjectName('status_trial')
|
||
elif status == 'missing':
|
||
self.status_label.setText('⚠ НЕ АКТИВИРОВАНА')
|
||
self.status_label.setObjectName('status_missing')
|
||
elif status_data.get('expired') == 'yes':
|
||
self.status_label.setText('✗ ИСТЕКЛА')
|
||
self.status_label.setObjectName('status_expired')
|
||
elif LicenseManager.verify():
|
||
self.status_label.setText('✓ АКТИВНА')
|
||
self.status_label.setObjectName('status_active')
|
||
else:
|
||
self.status_label.setText('? НЕИЗВЕСТНО')
|
||
self.status_label.setObjectName('status_missing')
|
||
|
||
# Force style refresh
|
||
self.status_label.setStyleSheet(self.status_label.styleSheet())
|
||
|
||
# Update info
|
||
self.org_label[1].setText(status_data.get('org_name') or '—')
|
||
self.tier_label[1].setText(status_data.get('tier') or '—')
|
||
|
||
# Calculate remaining time
|
||
if status == 'trial':
|
||
remaining = human_duration(status_data.get('trial_remaining_seconds', 0))
|
||
elif status_data.get('valid_until'):
|
||
try:
|
||
exp = datetime.fromisoformat(status_data['valid_until'].replace('Z', '+00:00'))
|
||
now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now()
|
||
delta = (exp - now).total_seconds()
|
||
remaining = human_duration(delta) if delta > 0 else 'Истекла'
|
||
except:
|
||
remaining = '—'
|
||
else:
|
||
remaining = '—'
|
||
|
||
self.remaining_label[1].setText(remaining)
|
||
self.valid_until_label[1].setText(status_data.get('valid_until') or '—')
|
||
self.machine_id_label[1].setText(LicenseManager.get_machine_id()[:16] + '...')
|
||
|
||
|
||
class ActivationWidget(QWidget):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(20)
|
||
|
||
# Online activation
|
||
online_group = QGroupBox('Онлайн активация')
|
||
online_layout = QVBoxLayout(online_group)
|
||
|
||
self.token_input = QLineEdit()
|
||
self.token_input.setPlaceholderText('Введите токен активации...')
|
||
online_layout.addWidget(self.token_input)
|
||
|
||
self.activate_btn = QPushButton('Активировать')
|
||
self.activate_btn.setObjectName('primary')
|
||
self.activate_btn.clicked.connect(self.activate_online)
|
||
online_layout.addWidget(self.activate_btn)
|
||
|
||
layout.addWidget(online_group)
|
||
|
||
# Offline activation
|
||
offline_group = QGroupBox('Оффлайн активация (response-код)')
|
||
offline_layout = QVBoxLayout(offline_group)
|
||
|
||
self.code_input = QTextEdit()
|
||
self.code_input.setPlaceholderText('Вставьте response-код (SAI-RC:... или SAI-RESP2:...)')
|
||
self.code_input.setMaximumHeight(100)
|
||
offline_layout.addWidget(self.code_input)
|
||
|
||
self.offline_btn = QPushButton('Активировать оффлайн')
|
||
self.offline_btn.clicked.connect(self.activate_offline)
|
||
offline_layout.addWidget(self.offline_btn)
|
||
|
||
layout.addWidget(offline_group)
|
||
|
||
# File installation
|
||
file_group = QGroupBox('Установка из файлов')
|
||
file_layout = QVBoxLayout(file_group)
|
||
|
||
file_hint = QLabel('Выберите license.json и license.sig')
|
||
file_hint.setStyleSheet(f'color: {COLORS["text_secondary"]}; font-size: 12px;')
|
||
file_layout.addWidget(file_hint)
|
||
|
||
self.file_btn = QPushButton('Выбрать файлы...')
|
||
self.file_btn.clicked.connect(self.install_from_files)
|
||
file_layout.addWidget(self.file_btn)
|
||
|
||
layout.addWidget(file_group)
|
||
|
||
layout.addStretch()
|
||
|
||
def activate_online(self):
|
||
token = self.token_input.text().strip()
|
||
if not token:
|
||
QMessageBox.warning(self, 'Ошибка', 'Введите токен активации')
|
||
return
|
||
|
||
code, out, err = LicenseManager.activate_online(token)
|
||
if code == 0:
|
||
QMessageBox.information(self, 'Успех', 'Лицензия успешно активирована!')
|
||
self.token_input.clear()
|
||
self.parent().parent().refresh_status()
|
||
else:
|
||
QMessageBox.critical(self, 'Ошибка', f'Ошибка активации:\n{err or out}')
|
||
|
||
def activate_offline(self):
|
||
code = self.code_input.toPlainText().strip()
|
||
if not code:
|
||
QMessageBox.warning(self, 'Ошибка', 'Вставьте response-код')
|
||
return
|
||
|
||
ret, out, err = LicenseManager.install_code(code)
|
||
if ret == 0:
|
||
QMessageBox.information(self, 'Успех', 'Лицензия успешно установлена!')
|
||
self.code_input.clear()
|
||
self.parent().parent().refresh_status()
|
||
else:
|
||
QMessageBox.critical(self, 'Ошибка', f'Ошибка установки:\n{err or out}')
|
||
|
||
def install_from_files(self):
|
||
json_path, _ = QFileDialog.getOpenFileName(
|
||
self, 'Выберите license.json', os.path.expanduser('~'),
|
||
'JSON files (*.json);;All files (*)'
|
||
)
|
||
if not json_path:
|
||
return
|
||
|
||
sig_path, _ = QFileDialog.getOpenFileName(
|
||
self, 'Выберите license.sig', os.path.dirname(json_path),
|
||
'Signature files (*.sig);;All files (*)'
|
||
)
|
||
if not sig_path:
|
||
return
|
||
|
||
ret, out, err = LicenseManager.install_files(json_path, sig_path)
|
||
if ret == 0:
|
||
QMessageBox.information(self, 'Успех', 'Лицензия успешно установлена!')
|
||
self.parent().parent().refresh_status()
|
||
else:
|
||
QMessageBox.critical(self, 'Ошибка', f'Ошибка установки:\n{err or out}')
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle('SAIKYO OS — Активация лицензии')
|
||
self.setMinimumSize(500, 650)
|
||
self.setMaximumSize(600, 800)
|
||
|
||
# Set window icon
|
||
icon_path = '/usr/share/icons/hicolor/512x512/apps/saikyo-activation-logo.png'
|
||
if os.path.exists(icon_path):
|
||
self.setWindowIcon(QIcon(icon_path))
|
||
|
||
self.setup_ui()
|
||
self.refresh_status()
|
||
|
||
def setup_ui(self):
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
|
||
layout = QVBoxLayout(central)
|
||
layout.setContentsMargins(30, 30, 30, 30)
|
||
layout.setSpacing(20)
|
||
|
||
# Header
|
||
header_layout = QVBoxLayout()
|
||
header_layout.setSpacing(8)
|
||
|
||
# Logo
|
||
logo_path = '/usr/share/icons/hicolor/512x512/apps/saikyo-activation-logo.png'
|
||
if os.path.exists(logo_path):
|
||
logo_label = QLabel()
|
||
pixmap = QPixmap(logo_path).scaled(
|
||
80, 80,
|
||
Qt.AspectRatioMode.KeepAspectRatio if PYQT_VERSION == 6 else Qt.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation if PYQT_VERSION == 6 else Qt.SmoothTransformation
|
||
)
|
||
logo_label.setPixmap(pixmap)
|
||
logo_label.setAlignment(Qt.AlignmentFlag.AlignCenter if PYQT_VERSION == 6 else Qt.AlignCenter)
|
||
header_layout.addWidget(logo_label)
|
||
|
||
title = QLabel('SAIKYO OS')
|
||
title.setObjectName('title')
|
||
title.setAlignment(Qt.AlignmentFlag.AlignCenter if PYQT_VERSION == 6 else Qt.AlignCenter)
|
||
header_layout.addWidget(title)
|
||
|
||
subtitle = QLabel('Активация лицензии')
|
||
subtitle.setObjectName('subtitle')
|
||
subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter if PYQT_VERSION == 6 else Qt.AlignCenter)
|
||
header_layout.addWidget(subtitle)
|
||
|
||
layout.addLayout(header_layout)
|
||
|
||
# Status card
|
||
self.status_card = StatusCard()
|
||
layout.addWidget(self.status_card)
|
||
|
||
# Activation widget
|
||
self.activation_widget = ActivationWidget(self)
|
||
layout.addWidget(self.activation_widget)
|
||
|
||
# Footer buttons
|
||
footer_layout = QHBoxLayout()
|
||
|
||
refresh_btn = QPushButton('Обновить')
|
||
refresh_btn.clicked.connect(self.refresh_status)
|
||
footer_layout.addWidget(refresh_btn)
|
||
|
||
portal_btn = QPushButton('Открыть портал')
|
||
portal_btn.clicked.connect(self.open_portal)
|
||
footer_layout.addWidget(portal_btn)
|
||
|
||
close_btn = QPushButton('Закрыть')
|
||
close_btn.clicked.connect(self.close)
|
||
footer_layout.addWidget(close_btn)
|
||
|
||
layout.addLayout(footer_layout)
|
||
|
||
def refresh_status(self):
|
||
status = LicenseManager.get_status()
|
||
self.status_card.update_status(status)
|
||
|
||
def open_portal(self):
|
||
try:
|
||
subprocess.Popen(['xdg-open', 'https://saikyo-os.ru/license'],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
except:
|
||
pass
|
||
|
||
|
||
def main():
|
||
app = QApplication(sys.argv)
|
||
app.setStyle('Fusion')
|
||
app.setStyleSheet(STYLESHEET)
|
||
|
||
# Dark palette
|
||
palette = QPalette()
|
||
palette.setColor(QPalette.ColorRole.Window if PYQT_VERSION == 6 else QPalette.Window, QColor(COLORS['bg_dark']))
|
||
palette.setColor(QPalette.ColorRole.WindowText if PYQT_VERSION == 6 else QPalette.WindowText, QColor(COLORS['text']))
|
||
palette.setColor(QPalette.ColorRole.Base if PYQT_VERSION == 6 else QPalette.Base, QColor(COLORS['bg_input']))
|
||
palette.setColor(QPalette.ColorRole.Text if PYQT_VERSION == 6 else QPalette.Text, QColor(COLORS['text']))
|
||
palette.setColor(QPalette.ColorRole.Button if PYQT_VERSION == 6 else QPalette.Button, QColor(COLORS['bg_card']))
|
||
palette.setColor(QPalette.ColorRole.ButtonText if PYQT_VERSION == 6 else QPalette.ButtonText, QColor(COLORS['text']))
|
||
palette.setColor(QPalette.ColorRole.Highlight if PYQT_VERSION == 6 else QPalette.Highlight, QColor(COLORS['accent']))
|
||
app.setPalette(palette)
|
||
|
||
window = MainWindow()
|
||
window.show()
|
||
|
||
sys.exit(app.exec() if PYQT_VERSION == 6 else app.exec_())
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|