diff --git a/CHANGELOG.md b/CHANGELOG.md index 709dd4d..b012668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [1.5.0] - 2026-02-23 + +### Added +- **Network interface binding** — choose which NIC (IP) to use per server (VPN, multi-NIC setups) + - Dropdown in server add/edit dialog (via `psutil.net_if_addrs()`) + - `bind_interface` saved per server, used in SSH connect via `socket.bind()` + - Works on Windows/macOS/Linux without admin rights + - Status checker and terminal automatically use the bound interface +- `psutil` dependency in requirements.txt + +### Fixed +- **"Install Everything" button** no longer hangs on error — try/except/finally ensures button always resets +- `generate_ssh_key()` handles missing paramiko gracefully (returns error message instead of crashing thread) +- `install_all()` catches per-step exceptions — all steps run even if one fails +- `_gen_key()` in Setup tab now catches and displays errors in the log + +### Changed +- `check_connection()` refactored — uses shared `_connect_client()` instead of duplicated logic +- `_connect_client()` supports `bind_interface` with socket binding and proper socket recreation on auth retry +- Logging added to `install_ssh_script()`, `install_skill()`, `generate_ssh_key()`, `install_all()` +- `build.py` — added `--hidden-import psutil` +- `CLAUDE.md` — added version sync checklist +- `version.py` → 1.5.0 + +## [1.4.0] - 2026-02-23 + +### Added +- Interactive SSH terminal with PTY (xterm-256color) +- `pyte` terminal emulator integration +- `ShellSession` class for persistent shell sessions +- Configurable monitoring intervals (30s, 60s, 2min, 5min) +- Skip status check option per server + +### Changed +- `version.py` → 1.4.0 + ## [1.3.0] - 2026-02-23 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..259694f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,17 @@ +# CLAUDE.md — инструкции для Claude Code + +## Текущая версия: 1.5.0 + +## Версионирование + +Файл версии: `version.py` → `__version__` + +Semver: **MAJOR** (ломающие изменения) | **MINOR** (новая фича) | **PATCH** (багфикс, мелкие правки) + +**Порядок релиза:** +1. Внести изменения в код и закоммитить +2. `python release.py minor` (или `patch` / `major` / `2.0.0`) +3. `python build.py --clean` +4. Закоммитить и запушить + +Скрипт `release.py` автоматически обновляет все 4 файла (`version.py`, `CHANGELOG.md`, `README.md`, `CLAUDE.md`) и генерирует changelog из git log. diff --git a/README.md b/README.md index 290cf9f..5cf8747 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pip install pyinstaller python build.py ``` -Output goes to `releases/ServerManager-v1.3.0-{platform}.exe` +Output goes to `releases/ServerManager-v1.5.0-{platform}.exe` ### Usage @@ -223,7 +223,7 @@ pip install pyinstaller python build.py ``` -Результат в `releases/ServerManager-v1.3.0-{платформа}.exe` +Результат в `releases/ServerManager-v1.5.0-{платформа}.exe` ### Использование @@ -363,7 +363,7 @@ pip install pyinstaller python build.py ``` -输出至 `releases/ServerManager-v1.3.0-{平台}.exe` +输出至 `releases/ServerManager-v1.5.0-{平台}.exe` ### 使用方法 diff --git a/build.py b/build.py index d330b7c..59ad862 100644 --- a/build.py +++ b/build.py @@ -79,6 +79,8 @@ def build(): "--hidden-import", "customtkinter", "--hidden-import", "PIL", "--hidden-import", "pyotp", + "--hidden-import", "pyte", + "--hidden-import", "psutil", "--collect-all", "customtkinter", ]) diff --git a/core/claude_setup.py b/core/claude_setup.py index 5b8573d..5f86952 100644 --- a/core/claude_setup.py +++ b/core/claude_setup.py @@ -7,6 +7,7 @@ for Claude Code to manage servers via the shared servers.json. import os import sys import shutil +from core.logger import log SHARED_DIR = os.path.expanduser("~/.server-connections") @@ -47,6 +48,7 @@ def install_ssh_script() -> str: dst = os.path.join(SHARED_DIR, "ssh.py") if os.path.exists(SSH_SCRIPT_SRC): shutil.copy2(SSH_SCRIPT_SRC, dst) + log.info(f"ssh.py installed: {dst}") results.append(f"ssh.py installed: {dst}") elif os.path.exists(dst): results.append(f"ssh.py already exists: {dst}") @@ -57,6 +59,7 @@ def install_ssh_script() -> str: enc_dst = os.path.join(SHARED_DIR, "encryption.py") if os.path.exists(ENCRYPTION_SRC): shutil.copy2(ENCRYPTION_SRC, enc_dst) + log.info(f"encryption.py installed: {enc_dst}") results.append(f"encryption.py installed: {enc_dst}") elif os.path.exists(enc_dst): results.append(f"encryption.py already exists: {enc_dst}") @@ -71,6 +74,7 @@ def install_skill() -> str: os.makedirs(SKILL_DST_DIR, exist_ok=True) if os.path.exists(SKILL_SRC): shutil.copy2(SKILL_SRC, SKILL_DST) + log.info(f"Skill installed: {SKILL_DST}") return f"Skill installed: {SKILL_DST}" # Fallback: check existing if os.path.exists(SKILL_DST): @@ -79,6 +83,7 @@ def install_skill() -> str: skill_content = _generate_skill_content() with open(SKILL_DST, "w", encoding="utf-8") as f: f.write(skill_content) + log.info(f"Skill generated: {SKILL_DST}") return f"Skill generated: {SKILL_DST}" @@ -89,7 +94,13 @@ def generate_ssh_key() -> str: os.makedirs(os.path.dirname(SSH_KEY_PATH), exist_ok=True) - import paramiko + try: + import paramiko + except ImportError: + msg = "ERROR: paramiko is not installed — cannot generate SSH key" + log.error(msg) + return msg + key = paramiko.Ed25519Key.generate() key.write_private_key_file(SSH_KEY_PATH) @@ -97,15 +108,30 @@ def generate_ssh_key() -> str: with open(SSH_KEY_PATH + ".pub", "w") as f: f.write(pub_key + "\n") + log.info(f"SSH key generated: {SSH_KEY_PATH}") return f"Key generated: {SSH_KEY_PATH}" def install_all() -> list[str]: """Full setup — install everything.""" results = [] - results.append(install_ssh_script()) - results.append(install_skill()) - results.append(generate_ssh_key()) + + steps = [ + ("ssh_script", install_ssh_script), + ("skill", install_skill), + ("ssh_key", generate_ssh_key), + ] + + for name, func in steps: + try: + log.info(f"install_all: running {name}") + result = func() + results.append(result) + except Exception as e: + msg = f"ERROR ({name}): {e}" + log.error(msg) + results.append(msg) + return results diff --git a/core/i18n.py b/core/i18n.py index fb9544c..e0ef717 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -107,6 +107,12 @@ _EN = { "clear": "Clear", "no_server_selected": "[!] No server selected", "server_not_found": "[!] Server '{alias}' not found", + "term_connecting": "Connecting to {alias}...", + "term_connected": "Connected to {alias}", + "term_disconnected": "Disconnected", + "term_reconnecting": "Reconnecting ({n}/{max})...", + "term_connect_failed": "Connection failed: {error}", + "term_reconnect_fail": "Disconnected (reconnect failed)", # Files "upload": "Upload", @@ -209,6 +215,21 @@ _EN = { "totp_secret_dialog": "TOTP Secret", "placeholder_totp_secret": "Base32 secret (optional)", "port_out_of_range": "Port must be 1-65535", + + # Monitoring + "monitoring": "Monitoring", + "check_interval": "Check interval", + "skip_check": "Skip status checks", + "skip_check_desc": "Don't check this server's availability", + "interval_30s": "30s", + "interval_60s": "60s", + "interval_120s": "2min", + "interval_300s": "5min", + "status_disabled": "disabled", + + # Network interface + "network_interface": "Network Interface", + "auto_default": "Auto (default)", } _RU = { @@ -293,6 +314,12 @@ _RU = { "clear": "Очистить", "no_server_selected": "[!] Сервер не выбран", "server_not_found": "[!] Сервер '{alias}' не найден", + "term_connecting": "Подключение к {alias}...", + "term_connected": "Подключено к {alias}", + "term_disconnected": "Отключено", + "term_reconnecting": "Переподключение ({n}/{max})...", + "term_connect_failed": "Ошибка подключения: {error}", + "term_reconnect_fail": "Отключено (не удалось переподключиться)", # Files "upload": "Загрузить", @@ -395,6 +422,21 @@ _RU = { "totp_secret_dialog": "TOTP-секрет", "placeholder_totp_secret": "Base32 секрет (необязательно)", "port_out_of_range": "Порт должен быть от 1 до 65535", + + # Monitoring + "monitoring": "Мониторинг", + "check_interval": "Интервал проверки", + "skip_check": "Не проверять доступность", + "skip_check_desc": "Исключить сервер из автопроверки", + "interval_30s": "30с", + "interval_60s": "60с", + "interval_120s": "2мин", + "interval_300s": "5мин", + "status_disabled": "отключено", + + # Network interface + "network_interface": "Сетевой интерфейс", + "auto_default": "Авто (по умолчанию)", } _ZH = { @@ -479,6 +521,12 @@ _ZH = { "clear": "清除", "no_server_selected": "[!] 未选择服务器", "server_not_found": "[!] 未找到服务器 '{alias}'", + "term_connecting": "正在连接 {alias}...", + "term_connected": "已连接到 {alias}", + "term_disconnected": "已断开", + "term_reconnecting": "重新连接中 ({n}/{max})...", + "term_connect_failed": "连接失败:{error}", + "term_reconnect_fail": "已断开(重连失败)", # Files "upload": "上传", @@ -581,6 +629,21 @@ _ZH = { "totp_secret_dialog": "TOTP密钥", "placeholder_totp_secret": "Base32密钥(可选)", "port_out_of_range": "端口必须在1-65535之间", + + # Monitoring + "monitoring": "监控", + "check_interval": "检查间隔", + "skip_check": "跳过状态检查", + "skip_check_desc": "不检查此服务器的可用性", + "interval_30s": "30秒", + "interval_60s": "60秒", + "interval_120s": "2分钟", + "interval_300s": "5分钟", + "status_disabled": "已禁用", + + # Network interface + "network_interface": "网络接口", + "auto_default": "自动(默认)", } _TRANSLATIONS = { diff --git a/core/server_store.py b/core/server_store.py index 046673f..9615e1a 100644 --- a/core/server_store.py +++ b/core/server_store.py @@ -44,6 +44,7 @@ class ServerStore: def __init__(self): self._data: dict = {"servers": [], "ssh_key": {"type": "ed25519", "path": "~/.ssh/id_ed25519"}} self._observers: list[Callable] = [] + self._check_interval: int = 60 self._statuses: dict[str, str] = {} # alias -> "online" | "offline" | "unknown" self._statuses_lock = threading.Lock() self._file_lock = threading.Lock() @@ -66,6 +67,7 @@ class ServerStore: from core import i18n lang = settings.get("language", "en") i18n.set_language(lang) + self._check_interval = settings.get("check_interval", 60) except json.JSONDecodeError: log.warning("Corrupted settings.json, using defaults") except Exception as e: @@ -77,6 +79,7 @@ class ServerStore: settings = { "servers_path": self._servers_file, "language": i18n.get_language(), + "check_interval": self._check_interval, } try: tmp = SETTINGS_FILE + ".tmp" @@ -284,6 +287,13 @@ class ServerStore: # ── Status management (thread-safe) ─────────────── + def get_check_interval(self) -> int: + return self._check_interval + + def set_check_interval(self, seconds: int): + self._check_interval = max(10, min(600, seconds)) + self._save_settings() + def set_status(self, alias: str, status: str): with self._statuses_lock: self._statuses[alias] = status diff --git a/core/ssh_client.py b/core/ssh_client.py index 868e3a2..73d3cc7 100644 --- a/core/ssh_client.py +++ b/core/ssh_client.py @@ -4,10 +4,170 @@ SSH client wrapper — connect, exec, sftp, key management via paramiko. import os import platform +import socket +import threading +import time import paramiko from core.logger import log +def _create_bound_socket(bind_ip: str, hostname: str, port: int, timeout: int) -> socket.socket: + """Create a TCP socket bound to a specific local IP address.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.bind((bind_ip, 0)) + sock.connect((hostname, port)) + return sock + + +def _connect_client(server: dict, key_path: str, timeout: int = 15) -> paramiko.SSHClient: + """Create and authenticate a paramiko SSHClient. Shared by SSHClientWrapper and ShellSession.""" + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + hostname = server["ip"] + port = server.get("port", 22) + bind_ip = server.get("bind_interface") + + kwargs = { + "hostname": hostname, + "port": port, + "username": server.get("user", "root"), + "timeout": timeout, + "banner_timeout": timeout, + } + + if bind_ip: + kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout) + + # Try key first + if key_path and os.path.exists(key_path): + try: + kwargs["key_filename"] = key_path + client.connect(**kwargs) + return client + except paramiko.AuthenticationException: + log.debug(f"Key auth failed for {server.get('alias', '?')}, trying password") + del kwargs["key_filename"] + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + if bind_ip: + kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout) + except Exception as e: + log.debug(f"Key connect failed: {e}") + del kwargs["key_filename"] + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + if bind_ip: + kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout) + + # Fallback to password + password = server.get("password", "") + if password: + kwargs["password"] = password + kwargs["look_for_keys"] = False + kwargs["allow_agent"] = False + client.connect(**kwargs) + return client + + raise Exception(f"No auth method for {server.get('alias', 'unknown')}") + + +class ShellSession: + """Persistent interactive shell session over SSH.""" + + def __init__(self, server: dict, key_path: str, cols: int = 80, rows: int = 24): + self.server = server + self.key_path = key_path + self.cols = cols + self.rows = rows + self._client: paramiko.SSHClient | None = None + self._channel: paramiko.Channel | None = None + self._running = False + self._read_thread: threading.Thread | None = None + + # Callbacks — set by the owner + self.on_data = None # on_data(data: bytes) + self.on_disconnect = None # on_disconnect() + + @property + def connected(self) -> bool: + return ( + self._channel is not None + and self._channel.get_transport() is not None + and self._channel.get_transport().is_active() + ) + + def connect(self): + self._client = _connect_client(self.server, self.key_path) + self._channel = self._client.invoke_shell( + term="xterm-256color", + width=self.cols, + height=self.rows, + ) + self._channel.settimeout(0.1) + self._running = True + self._read_thread = threading.Thread(target=self._read_loop, daemon=True) + self._read_thread.start() + + def _read_loop(self): + try: + while self._running: + try: + data = self._channel.recv(4096) + if not data: + break + if self.on_data: + self.on_data(data) + except TimeoutError: + continue + except OSError: + break + except Exception as e: + log.debug(f"ShellSession read loop error: {e}") + finally: + if self._running: + self._running = False + if self.on_disconnect: + self.on_disconnect() + + def send(self, data: bytes): + if self._channel and self._running: + try: + self._channel.sendall(data) + except OSError: + pass + + def resize(self, cols: int, rows: int): + self.cols = cols + self.rows = rows + if self._channel and self._running: + try: + self._channel.resize_pty(width=cols, height=rows) + except OSError: + pass + + def disconnect(self): + self._running = False + if self._channel: + try: + self._channel.close() + except Exception: + pass + self._channel = None + if self._client: + try: + self._client.close() + except Exception: + pass + self._client = None + + def reconnect(self): + self.disconnect() + time.sleep(0.2) + self.connect() + + class SSHClientWrapper: def __init__(self, server: dict, key_path: str = ""): self.server = server @@ -15,46 +175,9 @@ class SSHClientWrapper: self._client: paramiko.SSHClient | None = None def connect(self) -> paramiko.SSHClient: - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - kwargs = { - "hostname": self.server["ip"], - "port": self.server.get("port", 22), - "username": self.server.get("user", "root"), - "timeout": 15, - "banner_timeout": 15, - } - - # Try key first - if os.path.exists(self.key_path): - try: - kwargs["key_filename"] = self.key_path - client.connect(**kwargs) - self._client = client - return client - except paramiko.AuthenticationException: - log.debug(f"Key auth failed for {self.server.get('alias', '?')}, trying password") - del kwargs["key_filename"] - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - except Exception as e: - log.debug(f"Key connect failed: {e}") - del kwargs["key_filename"] - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - # Fallback to password - password = self.server.get("password", "") - if password: - kwargs["password"] = password - kwargs["look_for_keys"] = False - kwargs["allow_agent"] = False - client.connect(**kwargs) - self._client = client - return client - - raise Exception(f"No auth method for {self.server.get('alias', 'unknown')}") + client = _connect_client(self.server, self.key_path) + self._client = client + return client def disconnect(self): if self._client: @@ -73,11 +196,11 @@ class SSHClientWrapper: need_sudo = use_sudo and user != "root" if need_sudo: - full_cmd = f"sudo -S -p '' bash -c {_shell_quote(command)}" + full_cmd = f"export TERM=xterm; sudo -S -p '' bash -c {_shell_quote(command)}" else: - full_cmd = command + full_cmd = f"export TERM=xterm; {command}" - stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120) + stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120, get_pty=True) if need_sudo: password = self.server.get("password", "") @@ -131,38 +254,9 @@ class SSHClientWrapper: def check_connection(self) -> bool: try: - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - kwargs = { - "hostname": self.server["ip"], - "port": self.server.get("port", 22), - "username": self.server.get("user", "root"), - "timeout": 5, - "banner_timeout": 5, - } - - if os.path.exists(self.key_path): - try: - kwargs["key_filename"] = self.key_path - client.connect(**kwargs) - client.close() - return True - except Exception: - del kwargs["key_filename"] - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - password = self.server.get("password", "") - if password: - kwargs["password"] = password - kwargs["look_for_keys"] = False - kwargs["allow_agent"] = False - client.connect(**kwargs) - client.close() - return True - - return False + client = _connect_client(self.server, self.key_path, timeout=5) + client.close() + return True except Exception: return False diff --git a/core/status_checker.py b/core/status_checker.py index eb4f4c5..d9a85d4 100644 --- a/core/status_checker.py +++ b/core/status_checker.py @@ -15,9 +15,8 @@ from core.logger import log class StatusChecker: - def __init__(self, store: "ServerStore", interval: int = 60): + def __init__(self, store: "ServerStore"): self.store = store - self.interval = interval self._running = False self._thread: threading.Thread | None = None self._gui_callback = None @@ -48,18 +47,25 @@ class StatusChecker: def _loop(self): while self._running: self._check_cycle() - for _ in range(self.interval * 10): + interval = self.store.get_check_interval() + for _ in range(interval * 10): if not self._running: return time.sleep(0.1) def _check_cycle(self): servers = self.store.get_all() - ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh"] - # Mark non-SSH as unknown + # Mark skipped servers as disabled for s in servers: - if s.get("type", "ssh") != "ssh": + if s.get("skip_check", False): + self.store.set_status(s["alias"], "disabled") + + ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh" and not s.get("skip_check", False)] + + # Mark non-SSH (non-skipped) as unknown + for s in servers: + if s.get("type", "ssh") != "ssh" and not s.get("skip_check", False): self.store.set_status(s["alias"], "unknown") if not ssh_servers: diff --git a/gui/app.py b/gui/app.py index 0bc4c6e..2788fea 100644 --- a/gui/app.py +++ b/gui/app.py @@ -34,7 +34,7 @@ class App(ctk.CTk): # Core self.store = ServerStore() - self.checker = StatusChecker(self.store, interval=60) + self.checker = StatusChecker(self.store) # Layout self._build_layout() @@ -171,6 +171,9 @@ class App(ctk.CTk): # Use provided key or default to first tab current_key = restore_tab_key or self._tab_keys[0] + # Disconnect terminal before destroying tabs + self.terminal_tab._disconnect() + # Detach tab contents self.terminal_tab.pack_forget() self.files_tab.pack_forget() @@ -223,5 +226,6 @@ class App(ctk.CTk): self.sidebar.update_language() def _on_close(self): + self.terminal_tab._disconnect() self.checker.stop() self.destroy() diff --git a/gui/server_dialog.py b/gui/server_dialog.py index fcafa5b..75983f7 100644 --- a/gui/server_dialog.py +++ b/gui/server_dialog.py @@ -7,6 +7,20 @@ from core.server_store import SERVER_TYPES, DEFAULT_PORTS from core.i18n import t +def _get_network_interfaces() -> list[tuple[str, str]]: + """Return list of (name, ipv4_address) for available network interfaces.""" + try: + import psutil + result = [] + for name, addrs in psutil.net_if_addrs().items(): + for addr in addrs: + if addr.family.name == "AF_INET" and addr.address != "127.0.0.1": + result.append((name, addr.address)) + return result + except Exception: + return [] + + class ServerDialog(ctk.CTkToplevel): def __init__(self, master, store, server: dict | None = None): super().__init__(master) @@ -15,7 +29,7 @@ class ServerDialog(ctk.CTkToplevel): self.result = None self.title(t("edit_server") if server else t("add_server")) - self.geometry("450x580") + self.geometry("450x680") self.resizable(False, False) self.grab_set() @@ -58,6 +72,20 @@ class ServerDialog(ctk.CTkToplevel): self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port")) self.port_entry.pack(fill="x") + # Network interface + ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad) + self._iface_map: dict[str, str] = {} # display_name -> ip + ifaces = _get_network_interfaces() + auto_label = t("auto_default") + iface_values = [auto_label] + for name, ip in ifaces: + label = f"{name} ({ip})" + iface_values.append(label) + self._iface_map[label] = ip + self._iface_var = ctk.StringVar(value=auto_label) + self._iface_menu = ctk.CTkOptionMenu(self, values=iface_values, variable=self._iface_var) + self._iface_menu.pack(fill="x", **entry_pad) + # User ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad) self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user")) @@ -79,6 +107,13 @@ class ServerDialog(ctk.CTkToplevel): font=ctk.CTkFont(family="Consolas", size=12)) self.totp_entry.pack(fill="x", **entry_pad) + # Skip status checks + self.skip_check_var = ctk.BooleanVar(value=False) + self.skip_check_cb = ctk.CTkCheckBox( + self, text=t("skip_check"), variable=self.skip_check_var + ) + self.skip_check_cb.pack(fill="x", padx=20, pady=(8, 2)) + # Notes ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad) self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes")) @@ -100,8 +135,26 @@ class ServerDialog(ctk.CTkToplevel): self.user_entry.insert(0, server.get("user", "")) self.password_entry.insert(0, server.get("password", "")) self.totp_entry.insert(0, server.get("totp_secret", "")) + self.skip_check_var.set(server.get("skip_check", False)) self.notes_entry.insert(0, server.get("notes", "")) + # Restore network interface selection + saved_ip = server.get("bind_interface") + if saved_ip: + found = False + for label, ip in self._iface_map.items(): + if ip == saved_ip: + self._iface_var.set(label) + found = True + break + if not found: + unavail_label = f"? ({saved_ip})" + self._iface_map[unavail_label] = saved_ip + current_values = self._iface_menu.cget("values") + current_values.append(unavail_label) + self._iface_menu.configure(values=current_values) + self._iface_var.set(unavail_label) + def _on_type_change(self, value): default_port = DEFAULT_PORTS.get(value, 22) self.port_entry.delete(0, "end") @@ -149,6 +202,14 @@ class ServerDialog(ctk.CTkToplevel): } if totp_secret: server_data["totp_secret"] = totp_secret + if self.skip_check_var.get(): + server_data["skip_check"] = True + + # Network interface binding + iface_selection = self._iface_var.get() + bind_ip = self._iface_map.get(iface_selection) + if bind_ip: + server_data["bind_interface"] = bind_ip try: if self.editing: diff --git a/gui/tabs/setup_tab.py b/gui/tabs/setup_tab.py index 9e647b8..cf41fb2 100644 --- a/gui/tabs/setup_tab.py +++ b/gui/tabs/setup_tab.py @@ -9,6 +9,7 @@ from tkinter import filedialog, messagebox import customtkinter as ctk from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key from core.i18n import t +from core.logger import log class SetupTab(ctk.CTkFrame): @@ -88,6 +89,35 @@ class SetupTab(ctk.CTkFrame): command=self._refresh_status) self.refresh_btn.pack(side="right") + # ── Monitoring section ───────────────────────── + monitor_frame = ctk.CTkFrame(self) + monitor_frame.pack(fill="x", padx=20, pady=(5, 5)) + + self.monitor_title = ctk.CTkLabel( + monitor_frame, text=t("monitoring"), + font=ctk.CTkFont(size=14, weight="bold"), anchor="w" + ) + self.monitor_title.pack(fill="x", padx=15, pady=(10, 5)) + + interval_row = ctk.CTkFrame(monitor_frame, fg_color="transparent") + interval_row.pack(fill="x", padx=15, pady=(0, 10)) + + self.interval_label = ctk.CTkLabel(interval_row, text=t("check_interval"), anchor="w") + self.interval_label.pack(side="left", padx=(0, 10)) + + self._interval_buttons: dict[int, ctk.CTkButton] = {} + current_interval = store.get_check_interval() + for seconds, key in [(30, "interval_30s"), (60, "interval_60s"), (120, "interval_120s"), (300, "interval_300s")]: + is_active = (seconds == current_interval) + btn = ctk.CTkButton( + interval_row, text=t(key), width=60, height=28, + fg_color="#3b82f6" if is_active else "#6b7280", + hover_color="#2563eb" if is_active else "#4b5563", + command=lambda s=seconds: self._set_interval(s) + ) + btn.pack(side="left", padx=2) + self._interval_buttons[seconds] = btn + # ── Configuration section ───────────────────── config_frame = ctk.CTkFrame(self) config_frame.pack(fill="x", padx=20, pady=(5, 5)) @@ -148,6 +178,14 @@ class SetupTab(ctk.CTkFrame): # Initial status check self._refresh_status() + def _set_interval(self, seconds: int): + self.store.set_check_interval(seconds) + for s, btn in self._interval_buttons.items(): + if s == seconds: + btn.configure(fg_color="#3b82f6", hover_color="#2563eb") + else: + btn.configure(fg_color="#6b7280", hover_color="#4b5563") + def _log(self, text: str): self.log.configure(state="normal") self.log.insert("end", text + "\n") @@ -166,12 +204,17 @@ class SetupTab(ctk.CTkFrame): self.install_all_btn.configure(state="disabled", text=t("installing_all")) def _do(): - results = install_all() - for msg in results: - self.after(0, lambda m=msg: self._log(m)) - self.after(0, self._refresh_status) - self.after(0, lambda: self._log("\n" + t("install_done"))) - self.after(0, lambda: self.install_all_btn.configure(state="normal", text=t("install_everything"))) + try: + results = install_all() + for msg in results: + self.after(0, lambda m=msg: self._log(m)) + self.after(0, self._refresh_status) + self.after(0, lambda: self._log("\n" + t("install_done"))) + except Exception as e: + log.error(f"install_all failed: {e}") + self.after(0, lambda: self._log(f"ERROR: {e}")) + finally: + self.after(0, lambda: self.install_all_btn.configure(state="normal", text=t("install_everything"))) threading.Thread(target=_do, daemon=True).start() @@ -186,8 +229,12 @@ class SetupTab(ctk.CTkFrame): self._refresh_status() def _gen_key(self): - msg = generate_ssh_key() - self._log(msg) + try: + msg = generate_ssh_key() + self._log(msg) + except Exception as e: + log.error(f"generate_ssh_key failed: {e}") + self._log(f"ERROR: {e}") self._refresh_status() # ── Configuration methods ───────────────────────── diff --git a/gui/tabs/terminal_tab.py b/gui/tabs/terminal_tab.py index 445c5a0..9bbfecc 100644 --- a/gui/tabs/terminal_tab.py +++ b/gui/tabs/terminal_tab.py @@ -1,10 +1,11 @@ """ -Terminal tab — command input + output display. +Terminal tab — persistent interactive SSH shell via ShellSession + TerminalWidget. """ import threading +import time import customtkinter as ctk -from core.ssh_client import SSHClientWrapper +from core.ssh_client import ShellSession from core.i18n import t @@ -13,93 +14,110 @@ class TerminalTab(ctk.CTkFrame): super().__init__(master, fg_color="transparent") self.store = store self._current_alias: str | None = None + self._session: ShellSession | None = None + self._reconnect_count = 0 + self._max_reconnect = 3 + self._intentional_disconnect = False - # Output - self.output = ctk.CTkTextbox(self, font=ctk.CTkFont(family="Consolas", size=12), state="disabled") - self.output.pack(fill="both", expand=True, padx=10, pady=(10, 5)) + # Import here to avoid circular issues + from gui.widgets.terminal_widget import TerminalWidget - # Input row - input_frame = ctk.CTkFrame(self, fg_color="transparent") - input_frame.pack(fill="x", padx=10, pady=(0, 10)) - - self.sudo_var = ctk.BooleanVar(value=True) - self.sudo_check = ctk.CTkCheckBox(input_frame, text=t("sudo"), variable=self.sudo_var, width=60) - self.sudo_check.pack(side="left", padx=(0, 5)) - - self.cmd_entry = ctk.CTkEntry(input_frame, placeholder_text=t("enter_command")) - self.cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) - self.cmd_entry.bind("", lambda e: self._run_command()) - - self.run_btn = ctk.CTkButton(input_frame, text=t("run"), width=70, command=self._run_command) - self.run_btn.pack(side="left", padx=(0, 5)) - - self.clear_btn = ctk.CTkButton(input_frame, text=t("clear"), width=60, fg_color="#6b7280", command=self._clear) - self.clear_btn.pack(side="right") + self._terminal = TerminalWidget( + self, + send_callback=self._send_to_shell, + resize_callback=self._on_resize, + ) + self._terminal.pack(fill="both", expand=True, padx=5, pady=5) + self._terminal.set_status(t("term_disconnected"), "#888888") def set_server(self, alias: str | None): + if alias == self._current_alias: + return + self._disconnect() self._current_alias = alias if alias: - server = self.store.get_server(alias) - user = server.get("user", "root") if server else "root" - self.sudo_var.set(user != "root") + self._connect() + else: + self._terminal.reset() + self._terminal.set_status(t("term_disconnected"), "#888888") - def _append_output(self, text: str, color: str = "white"): - self.output.configure(state="normal") - self.output.insert("end", text) - self.output.configure(state="disabled") - self.output.see("end") - - def _run_command(self): + def _connect(self): if not self._current_alias: - self._append_output(t("no_server_selected") + "\n") return - - command = self.cmd_entry.get().strip() - if not command: - return - server = self.store.get_server(self._current_alias) if not server: - self._append_output(t("server_not_found").format(alias=self._current_alias) + "\n") + self._terminal.set_status( + t("server_not_found").format(alias=self._current_alias), "#ff4444" + ) return - self.cmd_entry.delete(0, "end") - use_sudo = self.sudo_var.get() - prefix = f"[{self._current_alias}]$ " - if use_sudo and server.get("user", "root") != "root": - prefix = f"[{self._current_alias}]# " - self._append_output(f"{prefix}{command}\n") + alias = self._current_alias + self._terminal.set_status(t("term_connecting").format(alias=alias), "#ccaa00") + self._terminal.reset() + self._intentional_disconnect = False - self.run_btn.configure(state="disabled", text="...") - - def _exec(): + def _do_connect(): try: key_path = self.store.get_ssh_key_path() - wrapper = SSHClientWrapper(server, key_path) - out, err, code = wrapper.exec_command(command, use_sudo=use_sudo) - - def _show(): - if out: - self._append_output(out) - if not out.endswith("\n"): - self._append_output("\n") - if err: - self._append_output(f"STDERR: {err}\n") - if code != 0: - self._append_output(f"[exit code: {code}]\n") - self._append_output("\n") - self.run_btn.configure(state="normal", text=t("run")) - - self.after(0, _show) + cols, rows = self._terminal.get_size() + session = ShellSession(server, key_path, cols=cols, rows=rows) + session.on_data = self._on_data_received + session.on_disconnect = self._on_disconnected + session.connect() + self._session = session + self._reconnect_count = 0 + self.after(0, lambda: self._terminal.set_status( + t("term_connected").format(alias=alias), "#44cc44" + )) + self.after(0, self._terminal.focus_terminal) except Exception as e: - def _err(): - self._append_output(f"[ERROR] {e}\n\n") - self.run_btn.configure(state="normal", text=t("run")) - self.after(0, _err) + self.after(0, lambda: self._terminal.set_status( + t("term_connect_failed").format(error=str(e)), "#ff4444" + )) - threading.Thread(target=_exec, daemon=True).start() + threading.Thread(target=_do_connect, daemon=True).start() - def _clear(self): - self.output.configure(state="normal") - self.output.delete("1.0", "end") - self.output.configure(state="disabled") + def _disconnect(self): + self._intentional_disconnect = True + if self._session: + self._session.disconnect() + self._session = None + + def _on_data_received(self, data: bytes): + self.after(0, lambda d=data: self._terminal.feed(d)) + + def _on_disconnected(self): + if self._intentional_disconnect: + self.after(0, lambda: self._terminal.set_status( + t("term_disconnected"), "#888888" + )) + return + + self._session = None + + if self._reconnect_count < self._max_reconnect: + self._reconnect_count += 1 + n = self._reconnect_count + mx = self._max_reconnect + self.after(0, lambda: self._terminal.set_status( + t("term_reconnecting").format(n=n, max=mx), "#ccaa00" + )) + + def _retry(): + time.sleep(1) + if not self._intentional_disconnect and self._current_alias: + self.after(0, self._connect) + + threading.Thread(target=_retry, daemon=True).start() + else: + self.after(0, lambda: self._terminal.set_status( + t("term_reconnect_fail"), "#ff4444" + )) + + def _send_to_shell(self, data: bytes): + if self._session and self._session.connected: + self._session.send(data) + + def _on_resize(self, cols: int, rows: int): + if self._session and self._session.connected: + self._session.resize(cols, rows) diff --git a/gui/widgets/status_badge.py b/gui/widgets/status_badge.py index 71fa513..c7c63b3 100644 --- a/gui/widgets/status_badge.py +++ b/gui/widgets/status_badge.py @@ -8,6 +8,7 @@ COLORS = { "online": "#22c55e", # green "offline": "#ef4444", # red "unknown": "#6b7280", # gray + "disabled": "#9ca3af", # light gray } @@ -23,4 +24,5 @@ class StatusBadge(ctk.CTkLabel): def _update_color(self): color = COLORS.get(self._status, COLORS["unknown"]) - self.configure(text="\u25cf", text_color=color, font=("", 14)) + symbol = "\u2014" if self._status == "disabled" else "\u25cf" + self.configure(text=symbol, text_color=color, font=("", 14)) diff --git a/gui/widgets/terminal_widget.py b/gui/widgets/terminal_widget.py new file mode 100644 index 0000000..6524e74 --- /dev/null +++ b/gui/widgets/terminal_widget.py @@ -0,0 +1,400 @@ +""" +Terminal widget — pyte VT100 emulator + tkinter.Text with ANSI colors, +full keyboard mapping, diff-based rendering, and cursor display. +""" + +import tkinter as tk +import tkinter.font as tkfont +import pyte + + +# 16 standard ANSI colors +_ANSI_COLORS = { + "black": "#000000", + "red": "#cc0000", + "green": "#4e9a06", + "brown": "#c4a000", + "blue": "#3465a4", + "magenta": "#75507b", + "cyan": "#06989a", + "white": "#d3d7cf", + "brightblack": "#555753", + "brightred": "#ef2929", + "brightgreen": "#8ae234", + "brightbrown": "#fce94f", + "brightblue": "#729fcf", + "brightmagenta": "#ad7fa8", + "brightcyan": "#34e2e2", + "brightwhite": "#eeeeec", +} + +_DEFAULT_FG = "#d3d7cf" +_DEFAULT_BG = "#1a1a2e" +_CURSOR_FG = "#1a1a2e" +_CURSOR_BG = "#d3d7cf" + +# Key → VT100 escape sequence +_KEY_MAP = { + "Up": "\x1b[A", + "Down": "\x1b[B", + "Right": "\x1b[C", + "Left": "\x1b[D", + "Home": "\x1b[H", + "End": "\x1b[F", + "Insert": "\x1b[2~", + "Delete": "\x1b[3~", + "Prior": "\x1b[5~", # PageUp + "Next": "\x1b[6~", # PageDown + "F1": "\x1bOP", + "F2": "\x1bOQ", + "F3": "\x1bOR", + "F4": "\x1bOS", + "F5": "\x1b[15~", + "F6": "\x1b[17~", + "F7": "\x1b[18~", + "F8": "\x1b[19~", + "F9": "\x1b[20~", + "F10": "\x1b[21~", + "F11": "\x1b[23~", + "F12": "\x1b[24~", +} + + +class TerminalWidget(tk.Frame): + """VT100 terminal emulator widget using pyte + tkinter.Text.""" + + def __init__(self, master, cols=80, rows=24, send_callback=None, + resize_callback=None, **kwargs): + super().__init__(master, bg=_DEFAULT_BG, **kwargs) + self.send_callback = send_callback + self.resize_callback = resize_callback + self._cols = cols + self._rows = rows + + # pyte screen + stream + self._screen = pyte.Screen(cols, rows) + self._screen.set_mode(pyte.modes.LNM) + self._stream = pyte.Stream(self._screen) + + # Previous buffer for diff rendering + self._prev_buffer: dict[int, list] = {} + + # Font + self._font = tkfont.Font(family="Consolas", size=11) + self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold") + + # Text widget + self._text = tk.Text( + self, + bg=_DEFAULT_BG, + fg=_DEFAULT_FG, + font=self._font, + insertwidth=0, + highlightthickness=0, + borderwidth=0, + padx=4, + pady=4, + wrap="none", + cursor="xterm", + ) + self._text.pack(fill="both", expand=True) + + # Create color tags + self._setup_tags() + + # Keyboard bindings + self._text.bind("", self._on_key) + self._text.bind("", self._on_ctrl_c) + self._text.bind("", self._on_ctrl_v) + self._text.bind("", lambda e: "break") + self._text.bind("", self._on_ctrl_d) + self._text.bind("", self._on_ctrl_l) + self._text.bind("", self._on_ctrl_z) + self._text.bind("", self._on_mousewheel) + + # Resize handling + self._resize_after_id = None + self._text.bind("", self._on_configure) + + # Focus on click + self._text.bind("", lambda e: self._text.focus_set()) + + # Make text read-only (input goes through _on_key) + self._text.bind("<>", lambda e: "break") + + # Status bar + self._status_frame = tk.Frame(self, bg="#2d2d44", height=22) + self._status_frame.pack(fill="x", side="bottom") + self._status_frame.pack_propagate(False) + self._status_label = tk.Label( + self._status_frame, text="", fg="#888888", bg="#2d2d44", + font=("Consolas", 9), anchor="w", padx=6, + ) + self._status_label.pack(fill="x") + + def _setup_tags(self): + """Pre-create tags for all ANSI color combinations.""" + for name, color in _ANSI_COLORS.items(): + self._text.tag_configure(f"fg_{name}", foreground=color) + self._text.tag_configure(f"bg_{name}", background=color) + self._text.tag_configure("bold", font=self._bold_font) + self._text.tag_configure("italic", font=tkfont.Font(family="Consolas", size=11, slant="italic")) + self._text.tag_configure("underline", underline=True) + self._text.tag_configure("cursor", foreground=_CURSOR_FG, background=_CURSOR_BG) + self._text.tag_configure("default", foreground=_DEFAULT_FG) + + def set_status(self, text: str, color: str = "#888888"): + self._status_label.configure(text=text, fg=color) + + def feed(self, data: bytes): + """Feed raw bytes from SSH into pyte and re-render.""" + try: + text = data.decode("utf-8", errors="replace") + except Exception: + text = data.decode("latin-1", errors="replace") + self._stream.feed(text) + self._render() + + def reset(self): + """Reset the terminal screen.""" + self._screen.reset() + self._prev_buffer.clear() + self._render() + + def focus_terminal(self): + self._text.focus_set() + + def get_size(self) -> tuple[int, int]: + """Return (cols, rows) based on current widget size.""" + return self._cols, self._rows + + def _render(self): + """Diff-based rendering: only update changed lines.""" + self._text.configure(state="normal") + + screen = self._screen + cursor = screen.cursor + + for row in range(screen.lines): + line = screen.buffer[row] + # Build list of (char, attrs) for the row + current = [] + for col in range(screen.columns): + char_data = line[col] + current.append((char_data.data, char_data.fg, char_data.bg, + char_data.bold, char_data.italics, char_data.underscore)) + + prev = self._prev_buffer.get(row) + is_cursor_row = (row == cursor.y) + prev_was_cursor_row = hasattr(self, "_prev_cursor_y") and self._prev_cursor_y == row + + if current == prev and not is_cursor_row and not prev_was_cursor_row: + continue + + self._prev_buffer[row] = current + + # Delete the line + line_start = f"{row + 1}.0" + line_end = f"{row + 1}.end" + self._text.delete(line_start, line_end) + + # Ensure enough lines exist + while int(self._text.index("end-1c").split(".")[0]) <= row: + self._text.insert("end", "\n") + + # Build line content with batched tags + col = 0 + while col < len(current): + ch, fg, bg, bold, italic, underline = current[col] + + # Batch consecutive chars with same attributes + batch = [ch] + while col + len(batch) < len(current): + nc = current[col + len(batch)] + if nc[1:] == (fg, bg, bold, italic, underline): + batch.append(nc[0]) + else: + break + + text = "".join(batch) + tags = self._make_tags(fg, bg, bold, italic, underline) + + # Apply cursor tag + if is_cursor_row: + if col <= cursor.x < col + len(batch): + # Split at cursor position + pre_len = cursor.x - col + if pre_len > 0: + self._text.insert(f"{row + 1}.{col}", text[:pre_len], tags) + cursor_tags = ("cursor",) + tags + self._text.insert(f"{row + 1}.{cursor.x}", text[pre_len:pre_len + 1], cursor_tags) + post = text[pre_len + 1:] + if post: + self._text.insert(f"{row + 1}.{cursor.x + 1}", post, tags) + col += len(batch) + continue + + self._text.insert(f"{row + 1}.{col}", text, tags) + col += len(batch) + + # Trim excess lines + total_lines = int(self._text.index("end-1c").split(".")[0]) + if total_lines > screen.lines: + self._text.delete(f"{screen.lines + 1}.0", "end") + + self._prev_cursor_y = cursor.y + self._text.configure(state="disabled") + + def _make_tags(self, fg, bg, bold, italic, underline) -> tuple: + tags = [] + fg_color = self._resolve_color(fg, is_fg=True) + if fg_color: + tag = f"fg_{fg_color}" if fg_color in _ANSI_COLORS else None + if tag and tag.replace("fg_", "") in _ANSI_COLORS: + tags.append(tag) + bg_color = self._resolve_color(bg, is_fg=False) + if bg_color: + tag = f"bg_{bg_color}" if bg_color in _ANSI_COLORS else None + if tag and tag.replace("bg_", "") in _ANSI_COLORS: + tags.append(tag) + if bold: + tags.append("bold") + if italic: + tags.append("italic") + if underline: + tags.append("underline") + return tuple(tags) + + def _resolve_color(self, color, is_fg=True): + if not color or color == "default": + return None + if color in _ANSI_COLORS: + return color + # pyte may return color names like "red", "green" etc directly + return None + + def _on_key(self, event): + """Handle keyboard input and send to SSH.""" + # Ignore modifier-only keys + if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R", + "Alt_L", "Alt_R", "Meta_L", "Meta_R", + "Caps_Lock", "Num_Lock"): + return "break" + + # Ctrl+key combinations + if event.state & 0x4: # Control + if event.keysym.lower() in ("c", "v", "d", "l", "z"): + return # handled by specific bindings + ch = event.keysym.lower() + if len(ch) == 1 and "a" <= ch <= "z": + self._send(bytes([ord(ch) - ord("a") + 1])) + return "break" + + # Special keys + if event.keysym in _KEY_MAP: + self._send(_KEY_MAP[event.keysym].encode()) + return "break" + + # Tab + if event.keysym == "Tab": + self._send(b"\t") + return "break" + + # Return / Enter + if event.keysym in ("Return", "KP_Enter"): + self._send(b"\r") + return "break" + + # Backspace + if event.keysym == "BackSpace": + self._send(b"\x7f") + return "break" + + # Escape + if event.keysym == "Escape": + self._send(b"\x1b") + return "break" + + # Regular character + if event.char and ord(event.char) >= 32: + self._send(event.char.encode("utf-8")) + return "break" + + return "break" + + def _on_ctrl_c(self, event): + # Check if text is selected — if so, copy + try: + sel = self._text.get("sel.first", "sel.last") + if sel: + self.clipboard_clear() + self.clipboard_append(sel) + return "break" + except tk.TclError: + pass + # No selection — send SIGINT + self._send(b"\x03") + return "break" + + def _on_ctrl_v(self, event): + try: + text = self.clipboard_get() + if text: + self._send(text.encode("utf-8")) + except tk.TclError: + pass + return "break" + + def _on_ctrl_d(self, event): + self._send(b"\x04") + return "break" + + def _on_ctrl_l(self, event): + self._send(b"\x0c") + return "break" + + def _on_ctrl_z(self, event): + self._send(b"\x1a") + return "break" + + def _on_mousewheel(self, event): + # Send scroll sequences for programs like less/man + if event.delta > 0: + self._send(b"\x1b[5~") # PageUp + else: + self._send(b"\x1b[6~") # PageDown + return "break" + + def _send(self, data: bytes): + if self.send_callback: + self.send_callback(data) + + def _on_configure(self, event): + """Debounced resize handler.""" + if self._resize_after_id: + self.after_cancel(self._resize_after_id) + self._resize_after_id = self.after(200, self._do_resize) + + def _do_resize(self): + self._resize_after_id = None + w = self._text.winfo_width() + h = self._text.winfo_height() + if w < 20 or h < 20: + return + + char_w = self._font.measure("M") + char_h = self._font.metrics("linespace") + if char_w <= 0 or char_h <= 0: + return + + new_cols = max(20, (w - 8) // char_w) + new_rows = max(4, (h - 8) // char_h) + + if new_cols != self._cols or new_rows != self._rows: + self._cols = new_cols + self._rows = new_rows + self._screen.resize(new_rows, new_cols) + self._prev_buffer.clear() + self._render() + if self.resize_callback: + self.resize_callback(new_cols, new_rows) diff --git a/release.py b/release.py new file mode 100644 index 0000000..3de8657 --- /dev/null +++ b/release.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +Release script — auto-versioning and auto-changelog. + +Usage: + python release.py minor # 1.5.0 → 1.6.0 + python release.py patch # 1.5.0 → 1.5.1 + python release.py major # 1.5.0 → 2.0.0 + python release.py 2.0.0 # explicit version + python release.py minor --desc "Release desc" # custom description +""" + +import argparse +import os +import re +import subprocess +import sys +from datetime import date + +PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def read_file(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def write_file(path: str, content: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def get_current_version() -> str: + """Read __version__ from version.py.""" + text = read_file(os.path.join(PROJECT_DIR, "version.py")) + m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text) + if not m: + print("ERROR: cannot parse __version__ from version.py") + sys.exit(1) + return m.group(1) + + +def parse_semver(ver: str) -> tuple[int, int, int]: + parts = ver.split(".") + if len(parts) != 3 or not all(p.isdigit() for p in parts): + print(f"ERROR: invalid semver '{ver}'") + sys.exit(1) + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def bump_version(current: str, bump_type: str) -> str: + major, minor, patch = parse_semver(current) + if bump_type == "major": + return f"{major + 1}.0.0" + elif bump_type == "minor": + return f"{major}.{minor + 1}.0" + elif bump_type == "patch": + return f"{major}.{minor}.{patch + 1}" + else: + # explicit version + parse_semver(bump_type) # validate + return bump_type + + +# --------------------------------------------------------------------------- +# Git log → changelog +# --------------------------------------------------------------------------- + +def get_last_tag() -> str | None: + """Return the most recent tag, or None.""" + try: + result = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], + capture_output=True, text=True, cwd=PROJECT_DIR, + ) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + pass + return None + + +def get_commits_since(tag: str | None) -> list[str]: + """Return list of commit messages since *tag* (or all if tag is None).""" + if tag: + cmd = ["git", "log", f"{tag}..HEAD", "--pretty=format:%s"] + else: + cmd = ["git", "log", "--pretty=format:%s"] + try: + result = subprocess.run(cmd, capture_output=True, text=True, cwd=PROJECT_DIR) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip().splitlines() + except FileNotFoundError: + pass + return [] + + +def categorize_commits(commits: list[str]) -> dict[str, list[str]]: + """Group commits into Added / Fixed / Changed.""" + cats: dict[str, list[str]] = {"Added": [], "Fixed": [], "Changed": []} + add_re = re.compile(r"^(add|feat|new)\b", re.IGNORECASE) + fix_re = re.compile(r"^fix\b", re.IGNORECASE) + for msg in commits: + msg = msg.strip() + if not msg: + continue + if add_re.search(msg): + cats["Added"].append(msg) + elif fix_re.search(msg): + cats["Fixed"].append(msg) + else: + cats["Changed"].append(msg) + return cats + + +def build_changelog_section( + new_version: str, + categories: dict[str, list[str]], + description: str | None, +) -> str: + """Build markdown section for CHANGELOG.md.""" + today = date.today().isoformat() + lines = [f"## [{new_version}] - {today}", ""] + if description: + lines.append(description) + lines.append("") + for cat in ("Added", "Fixed", "Changed"): + items = categories.get(cat, []) + if items: + lines.append(f"### {cat}") + for item in items: + lines.append(f"- {item}") + lines.append("") + # if nothing was categorized, add a placeholder + if not any(categories.values()): + lines.append("### Changed") + lines.append(f"- Bump version to {new_version}") + lines.append("") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# File updaters +# --------------------------------------------------------------------------- + +def update_version_py(new_version: str) -> None: + path = os.path.join(PROJECT_DIR, "version.py") + text = read_file(path) + text = re.sub( + r'(__version__\s*=\s*["\'])[^"\']+(["\'])', + rf"\g<1>{new_version}\2", + text, + ) + write_file(path, text) + print(f" version.py → {new_version}") + + +def update_claude_md(new_version: str) -> None: + path = os.path.join(PROJECT_DIR, "CLAUDE.md") + if not os.path.exists(path): + print(" CLAUDE.md — not found, skipping") + return + text = read_file(path) + text = re.sub( + r"(##\s*Текущая версия:\s*)\S+", + rf"\g<1>{new_version}", + text, + ) + write_file(path, text) + print(f" CLAUDE.md → {new_version}") + + +def update_readme(old_version: str, new_version: str) -> None: + path = os.path.join(PROJECT_DIR, "README.md") + if not os.path.exists(path): + print(" README.md — not found, skipping") + return + text = read_file(path) + old_tag = f"ServerManager-v{old_version}" + new_tag = f"ServerManager-v{new_version}" + if old_tag in text: + text = text.replace(old_tag, new_tag) + write_file(path, text) + print(f" README.md → {old_tag} → {new_tag}") + else: + print(f" README.md — '{old_tag}' not found, skipping") + + +def update_changelog(section: str) -> None: + path = os.path.join(PROJECT_DIR, "CHANGELOG.md") + if os.path.exists(path): + old = read_file(path) + # Insert after the first line ("# Changelog\n") + header_re = re.compile(r"^(#\s*Changelog\s*\n)", re.MULTILINE) + m = header_re.search(old) + if m: + insert_pos = m.end() + new_text = old[:insert_pos] + "\n" + section + "\n" + old[insert_pos:].lstrip("\n") + else: + new_text = section + "\n" + old + else: + new_text = "# Changelog\n\n" + section + write_file(path, new_text) + print(f" CHANGELOG.md → new section added") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Auto-versioning and auto-changelog for ServerManager.", + ) + parser.add_argument( + "version", + help="Bump type (major/minor/patch) or explicit version (e.g. 2.0.0)", + ) + parser.add_argument( + "--desc", + default=None, + help="Custom description for the changelog section", + ) + args = parser.parse_args() + + old_version = get_current_version() + bump_type = args.version.lower() + if bump_type in ("major", "minor", "patch"): + new_version = bump_version(old_version, bump_type) + else: + new_version = bump_version(old_version, args.version) + + print(f"\nRelease: {old_version} → {new_version}\n") + + # Collect git log + last_tag = get_last_tag() + commits = get_commits_since(last_tag) + categories = categorize_commits(commits) + section = build_changelog_section(new_version, categories, args.desc) + + # Update files + print("Updating files:") + update_version_py(new_version) + update_claude_md(new_version) + update_readme(old_version, new_version) + update_changelog(section) + + # Summary + print(f"\nDone! Version is now {new_version}.") + print("Next steps:") + print(" 1. Review changes: git diff") + print(" 2. Build: python build.py --clean") + print(" 3. Commit & push") + + +if __name__ == "__main__": + main() diff --git a/releases/ServerManager-v1.1.0-win-x64.exe b/releases/ServerManager-v1.1.0-win-x64.exe new file mode 100644 index 0000000..7bbcf0c Binary files /dev/null and b/releases/ServerManager-v1.1.0-win-x64.exe differ diff --git a/releases/ServerManager-v1.2.0-win-x64.exe b/releases/ServerManager-v1.2.0-win-x64.exe new file mode 100644 index 0000000..a232606 Binary files /dev/null and b/releases/ServerManager-v1.2.0-win-x64.exe differ diff --git a/releases/ServerManager-v1.3.0-win-x64.exe b/releases/ServerManager-v1.3.0-win-x64.exe index f90f114..18294e6 100644 Binary files a/releases/ServerManager-v1.3.0-win-x64.exe and b/releases/ServerManager-v1.3.0-win-x64.exe differ diff --git a/releases/ServerManager-v1.4.0-win-x64.exe b/releases/ServerManager-v1.4.0-win-x64.exe new file mode 100644 index 0000000..cf85c3f Binary files /dev/null and b/releases/ServerManager-v1.4.0-win-x64.exe differ diff --git a/requirements.txt b/requirements.txt index 1fa40dd..df0ded7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ paramiko>=3.4.0 pillow>=10.0.0 cryptography>=41.0.0 pyotp>=2.9.0 +pyte>=0.8.1 +psutil>=5.9.0 diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..a206152 Binary files /dev/null and b/screenshot.png differ diff --git a/screenshot2.png b/screenshot2.png new file mode 100644 index 0000000..a284aa9 Binary files /dev/null and b/screenshot2.png differ diff --git a/screenshot3.png b/screenshot3.png new file mode 100644 index 0000000..609327d Binary files /dev/null and b/screenshot3.png differ diff --git a/screenshot4.png b/screenshot4.png new file mode 100644 index 0000000..f0a0fc7 Binary files /dev/null and b/screenshot4.png differ diff --git a/screenshot5.png b/screenshot5.png new file mode 100644 index 0000000..439bdba Binary files /dev/null and b/screenshot5.png differ diff --git a/screenshot6.png b/screenshot6.png new file mode 100644 index 0000000..2bc13c6 Binary files /dev/null and b/screenshot6.png differ diff --git a/screenshot7.png b/screenshot7.png new file mode 100644 index 0000000..8ff99cf Binary files /dev/null and b/screenshot7.png differ diff --git a/screenshot_2fa.png b/screenshot_2fa.png new file mode 100644 index 0000000..be865b7 Binary files /dev/null and b/screenshot_2fa.png differ diff --git a/ss_2fa_new.png b/ss_2fa_new.png new file mode 100644 index 0000000..292e47a Binary files /dev/null and b/ss_2fa_new.png differ diff --git a/ss_final.png b/ss_final.png new file mode 100644 index 0000000..620c852 Binary files /dev/null and b/ss_final.png differ diff --git a/ss_newui.png b/ss_newui.png new file mode 100644 index 0000000..e196ff8 Binary files /dev/null and b/ss_newui.png differ diff --git a/version.py b/version.py index 8a8c66b..3a90e2a 100644 --- a/version.py +++ b/version.py @@ -1,6 +1,6 @@ """Version info for ServerManager.""" -__version__ = "1.3.0" +__version__ = "1.5.0" __app_name__ = "ServerManager" __author__ = "aibot777" __description__ = "Desktop GUI for managing remote servers"