From 6904d70be875a6c3221268ceb71fcd80baf3cba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9F=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2?= Date: Thu, 17 Sep 2026 08:13:48 +0700 Subject: [PATCH] Linux port: cross-platform hotkeys (QKeyCombination), build scripts, and platform detection fixes - Replace all QKeySequence string hotkeys with QKeyCombination for layout-independent operation on Linux/X11 - Add QKeyCombination-based hotkeys in main_window.py, file_manager, terminal - Add build_linux.py and setup_and_build.sh for Linux installation - Fix platform detection in config_manager, path_resolver, encryption - Add SSH terminal launcher for Linux (ssh/sshpass integration) - Update README.md with Linux installation instructions - Fix add_cash_dialog label width, toolbar button sizing, VNC state check - Update pyproject.toml and uv.lock for cross-platform support --- README.md | 49 ++- pyproject.toml | 2 + scripts/build_linux.py | 360 ++++++++++++++++++ setup_and_build.sh | 117 ++++++ .../builtin/file_manager/gui/dialogs.py | 4 +- .../builtin/file_manager/gui/window.py | 16 +- .../builtin/ssh_terminal_launcher.py | 24 +- .../builtin/terminal/ui/main_window.py | 127 +++--- src/cashcontrol/core/security/encryption.py | 183 ++++----- .../gui/dialogs/add_cash_dialog.py | 3 +- src/cashcontrol/gui/dialogs/help_content.py | 26 +- .../gui/dialogs/settings/tab_logs.py | 6 +- .../gui/dialogs/settings/tab_programs.py | 212 ++++++++--- .../dialogs/setup_wizard/page_connection.py | 6 +- .../gui/dialogs/setup_wizard/page_programs.py | 12 +- src/cashcontrol/gui/main_window.py | 30 +- src/cashcontrol/gui/toolbar.py | 60 +-- .../infrastructure/config_manager.py | 10 +- .../infrastructure/path_resolver.py | 26 +- tests/test_ssh_terminal_launcher.py | 2 +- uv.lock | 82 ++++ 21 files changed, 1077 insertions(+), 280 deletions(-) create mode 100644 scripts/build_linux.py create mode 100644 setup_and_build.sh diff --git a/README.md b/README.md index cb4118a..c1b9116 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CashControl -Десктопное приложение для Windows, которое собирает всю работу с POS-кассами +Десктопное приложение, которое собирает всю работу с POS-кассами SetRetail в одно окно: подключение по SSH, терминал, обмен файлами, просмотр экрана кассы, работа с базой PostgreSQL и диагностика оборудования. Всё, что раньше разбиралось отдельными программами и забытыми паролями, @@ -19,8 +19,9 @@ Linux-системами, где есть SSH. VNC-вьюер, psql) остаются опцией, но не обязательны: есть собственные реализации для всех задач. - **Безопасность по умолчанию.** Пароли шифруются (Fernet), мастер-ключ - защищён DPAPI Windows, ключи хостов проверяются по схеме TOFU - (Trust On First Use), пароли не светятся в списке процессов. + защищён DPAPI (Windows) или OS keyring/libsecret (Linux), ключи хостов + проверяются по схеме TOFU (Trust On First Use), пароли не светятся + в списке процессов. - **Расширяемость без программирования.** Информация, проблемы, команды и типы касс описываются TOML-файлами и подхватываются на лету. @@ -90,7 +91,7 @@ Linux-системами, где есть SSH. | `Ctrl+V` | подключить встроенный VNC-просмотр кассы | | `Ctrl+Shift+V` | открыть доступный VNC-клиент (внешний или встроенное окно) | | `Ctrl+S` | встроенный SSH-терминал | -| `Ctrl+W` | файловый менеджер (WinSCP) | +| `Ctrl+W` | файловый менеджер (WinSCP или встроенный) | | `Ctrl+D` | редактор PostgreSQL | | `Ctrl+R` | перезапуск ПО кассы | | `Ctrl+Shift+R` | перезагрузка кассы | @@ -104,29 +105,30 @@ Linux-системами, где есть SSH. ## Требования -- Windows 10/11 +- Windows 10/11 или Linux (x86_64, glibc) - Python **3.12–3.13** - [uv](https://docs.astral.sh/uv/) (рекомендуется) или pip +- Для Linux: `libsecret-1` (для keyring) + `libpq-dev` (для psycopg2) --- ## Запуск из исходников -```bat +```bash git clone https://github.com/debrozer-sketch/CashControl.git cd CashControl uv venv --python 3.12 .venv uv pip install -e . -.venv\Scripts\python -m cashcontrol.main +.venv/bin/python -m cashcontrol.main ``` или через pip: -```bat -py -3.12 -m venv .venv -.venv\Scripts\activate +```bash +python3.12 -m venv .venv +.venv/bin/activate pip install -e . python -m cashcontrol.main ``` @@ -140,7 +142,7 @@ python -m cashcontrol.main ## Сборка -Portable-сборка (embedded Python, без Nuitka): +Portable-сборка (embedded Python, без Nuitka) — Windows: ```bat uv run python scripts/build_dist.py @@ -158,6 +160,26 @@ uv run python scripts/build_dist.py per-user без прав администратора, обновление поверх старых версий сохраняет настройки. Подробнее — `BUILD.md`. +Portable-сборка для Linux (python-build-standalone + tarball): + +```bash +uv run python scripts/build_linux.py +``` + +Результат — `dist/CashControl/` (портативная папка) и +`dist/CashControl-linux-x86_64.tar.gz`. Распакуйте куда угодно и запускайте: + +```bash +cd CashControl +./run.sh +``` + +Для установки в систему: +```bash +sudo cp CashControl.desktop /usr/share/applications/ +sudo cp icon.png /usr/share/icons/hicolor/128x128/apps/ +``` + --- ## Структура проекта @@ -181,7 +203,7 @@ tests/ тесты ## Тесты -```bat +```bash pytest tests -v ``` @@ -194,7 +216,8 @@ pytest tests -v ## Безопасность -- Пароли шифруются **Fernet**, мастер-ключ защищён **DPAPI** Windows; +- Пароли шифруются **Fernet**, мастер-ключ защищён **DPAPI** (Windows) или + **OS keyring/libsecret** (Linux); - SSH-ключи хостов проверяются по **TOFU**; - Пароли при запуске встроенного терминала передаются через stdin, а не в командной строке; diff --git a/pyproject.toml b/pyproject.toml index 296e017..285d4ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [ "defusedxml>=0.7", # Built-in SSH terminal (vendored in src/cashcontrol/builtin/terminal) "pyte>=0.8,<1", + # Linux/macOS OS keyring (libsecret / KWallet — optional, graceful fallback) + "keyring>=24; sys_platform == 'linux'", # Windows-specific "pywin32>=306; sys_platform == 'win32'", ] diff --git a/scripts/build_linux.py b/scripts/build_linux.py new file mode 100644 index 0000000..71fe99e --- /dev/null +++ b/scripts/build_linux.py @@ -0,0 +1,360 @@ +"""Build portable CashControl distribution for Linux. + +Target layout (portable tarball): + CashControl/ + ├── run.sh launcher (bash, relative paths) + ├── CashControl.desktop .desktop file for the desktop + ├── icon.png 128x128 PNG icon + ├── version.txt + ├── docs/ data/ logs/ commands/ collectors/ cash_types/ detection/ + └── runtime/ + ├── bin/python system python symlink + ├── lib/ site-packages (PySide6, deps) + └── app/cashcontrol/ app source code + +Usage: uv run python scripts/build_linux.py [--output DIR] +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tarfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_PKG = REPO_ROOT / "src" / "cashcontrol" +VENV_SP_FALLBACK = REPO_ROOT / ".venv" / "lib" / "python3.12" / "site-packages" +DEFAULT_OUT = REPO_ROOT / "dist" / "CashControl" +DEFAULT_TARBALL = REPO_ROOT / "dist" / "CashControl-linux-x86_64.tar.gz" + +SKIP_PKGS = { + "pip", "setuptools", "wheel", "pkg_resources", "__pycache__", +} +BIN_EXTS = {".so", ".pyd", ".dll"} +COPY_EXTS = {".py", ".pyw", ".json", ".png", ".svg", ".qss", ".txt", ".toml"} + +HOT_FILES = [ + ("gui", ["toolbar.py"]), + ("builtin/db_viewer", [ + "__init__.py", + "constants.py", "formatting.py", "storage.py", "workers.py", "sql.py", + "data_grid.py", "data_panel.py", "csv_import.py", + "sql_console.py", "tables_panel.py", "widget.py", + ]), + ("builtin/vnc", ["vnc_preview.py"]), + ("builtin/file_manager", [ + "__init__.py", + "models.py", "backends.py", "service.py", "cli.py", + "gui/__init__.py", "gui/dialogs.py", "gui/runtime.py", + "gui/session.py", "gui/widgets.py", "gui/window.py", + ]), + ("gui/dialogs", [ + "command_editor.py", "command_result_dialog.py", "logs_viewer.py", + "help_dialog.py", "alias_editor.py", "add_cash_dialog.py", + ]), + ("gui/dialogs/settings", [ + "settings_dialog.py", "tab_connection.py", "tab_general.py", + "tab_logs.py", "tab_programs.py", + ]), + ("gui/widgets", [ + "info_section_widget.py", "virtual_keyboard.py", + ]), +] + + +def log(msg: str) -> None: + print(f"[build_linux] {msg}") + + +def _venv_sp() -> Path: + """Resolve site-packages directory from the local venv.""" + if VENV_SP_FALLBACK.exists(): + return VENV_SP_FALLBACK + # Fallback: pip install into current venv + import importlib.util + spec = importlib.util.find_spec("PySide6") + if spec and spec.origin: + return Path(spec.origin).parent.parent + raise FileNotFoundError("Cannot find site-packages. Run: uv sync") + + +def sync_version() -> str: + script = REPO_ROOT / "scripts" / "sync_version.py" + if script.exists(): + subprocess.run([sys.executable, str(script)], check=True, cwd=REPO_ROOT) + version = (REPO_ROOT / "version.txt").read_text(encoding="utf-8").strip() + return version + + +def clean_out(out: Path) -> None: + if out.exists(): + shutil.rmtree(out) + out.mkdir(parents=True) + + +def find_system_python() -> Path: + """Find system python3 binary.""" + candidates = [ + Path("/usr/bin/python3.12"), + Path("/usr/bin/python3.11"), + Path("/usr/bin/python3"), + ] + for p in candidates: + if p.exists(): + return p + # Check PATH + result = shutil.which("python3") + if result: + return Path(result) + raise FileNotFoundError("No python3 found in system or PATH") + + +def install_runtime_python(out: Path, sys_python: Path) -> None: + """Create runtime/bin/python symlink to system python.""" + py_dir = out / "runtime" / "bin" + py_dir.mkdir(parents=True) + # Symlink to system python + target = sys_python.resolve() + symlink = py_dir / "python" + if symlink.exists() or symlink.is_symlink(): + symlink.unlink() + symlink.symlink_to(target) + log(f"python symlink: {symlink} -> {target}") + + +def _has_binaries(path: Path) -> bool: + if path.is_file(): + return path.suffix.lower() in BIN_EXTS + return any(item.suffix.lower() in BIN_EXTS for item in path.rglob("*")) + + +def install_deps(out: Path) -> tuple[list[str], list[str]]: + """Copy deps from venv site-packages into runtime/lib/.""" + sp = _venv_sp() + lib_dir = out / "runtime" / "lib" + lib_dir.mkdir(parents=True) + binaries: list[str] = [] + pure: list[str] = [] + + for entry in sorted(sp.iterdir()): + name = entry.name + if name.lower() in SKIP_PKGS or name.endswith(".dist-info"): + continue + if _has_binaries(entry): + dest = lib_dir / name + if entry.is_dir(): + shutil.copytree(entry, dest, dirs_exist_ok=True) + else: + shutil.copy2(entry, dest) + binaries.append(name) + else: + dest = lib_dir / name + if entry.is_dir(): + shutil.copytree(entry, dest, dirs_exist_ok=True) + else: + shutil.copy2(entry, dest) + pure.append(name) + + log(f"binary pkgs: {len(binaries)}, pure pkgs: {len(pure)}") + return binaries, pure + + +def _rmtree(path: Path) -> None: + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + + +def prune_runtime(out: Path) -> None: + """Drop Qt modules and dev artifacts the app never loads.""" + lib = out / "runtime" / "lib" + for pkg in lib.iterdir(): + if not pkg.is_dir(): + continue + for cache in list(pkg.rglob("__pycache__")): + _rmtree(cache) + for tests in list(pkg.rglob("tests")): + if tests.is_dir(): + _rmtree(tests) + + pyside = lib / "PySide6" + if not pyside.is_dir(): + return + + KEEP_QT_LIBS = {"Qt6Core", "Qt6Gui", "Qt6Widgets", "Qt6Network", "Qt6Svg", + "Qt6Concurrent", "Qt6OpenGL", "Qt6Xml", "Qt6SvgWidgets"} + KEEP_QT_MODULES = {"QtCore", "QtGui", "QtWidgets", "QtNetwork", "QtSvg", + "QtXml", "QtSvgWidgets"} + KEEP_PLUGINS = {"platforms", "imageformats", "iconengines", "styles"} + + for d in ("qml", "metatypes", "include", "doc", "glue", "scripts", + "__pycache__"): + _rmtree(pyside / d) + for f in list(pyside.glob("*.so*")): + name = f.stem + if name.endswith(".abi3"): + continue + if name not in KEEP_QT_LIBS: + f.unlink() + + for d in list(pyside.rglob("plugins")): + if d.is_dir(): + for sub in list(d.iterdir()): + if sub.is_dir() and sub.name not in KEEP_PLUGINS: + _rmtree(sub) + + log("runtime pruned") + + +def _app_ignore(directory: str, names: list[str]) -> list[str]: + """Exclude __pycache__ and dev artifacts from built-in terminal.""" + src = Path(directory) + ignored = {n for n in names if n == "__pycache__"} + if src.name == "terminal": + ignored |= {n for n in names if n in {"README.md", "requirements.txt", + "run.bat", "run.sh", "logs"}} + if src.name == "data" and "app" in names: + ignored.add("app") + return sorted(ignored) + + +def copy_app_code(out: Path) -> None: + app_pkg = out / "runtime" / "app" / "cashcontrol" + shutil.copytree(SRC_PKG, app_pkg, ignore=_app_ignore) + log(f"app code -> {app_pkg}") + + +def build_modules_overlay(out: Path) -> None: + """Build modules/ for hot-reload.""" + modules = out / "modules" + for rel_dir, files in HOT_FILES: + target = modules / rel_dir + target.mkdir(parents=True, exist_ok=True) + src_dir = SRC_PKG / rel_dir + for fname in files: + f = src_dir / fname + if not f.exists(): + continue + dst = target / fname + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(f, dst) + layouts_src = SRC_PKG / "gui" / "widgets" / "keyboard_layouts" + if layouts_src.exists(): + dst = modules / "gui" / "widgets" / "keyboard_layouts" + dst.mkdir(parents=True, exist_ok=True) + for f in layouts_src.glob("*.json"): + shutil.copy2(f, dst / f.name) + log("modules/ overlay built") + + +def _soft_ignore(_d, names): + """Exclude KiTTY user data from dist.""" + return {n for n in names if n in {"SshHostKeys", "Sessions", "Proxies", + "reinstall", "kitty.ini", "PUTTY.RND"}} + + +def copy_user_content(out: Path) -> None: + shutil.copy2(REPO_ROOT / "version.txt", out / "version.txt") + # Try to copy icon (PNG preferred on Linux, fallback to ICO) + icon_src = SRC_PKG / "gui" / "resources" / "icon.png" + icon_ico = SRC_PKG / "gui" / "resources" / "icon.ico" + if icon_src.exists(): + shutil.copy2(icon_src, out / "icon.png") + elif icon_ico.exists(): + shutil.copy2(icon_ico, out / "icon.png") + docs_src = REPO_ROOT / "docs" + if docs_src.exists(): + shutil.copytree(docs_src, out / "docs") + for d in ("commands", "collectors", "cash_types", "detection"): + src = REPO_ROOT / d + if src.exists(): + shutil.copytree(src, out / "defaults" / d) + soft_src = REPO_ROOT / "soft" + if soft_src.exists(): + shutil.copytree(soft_src, out / "soft", ignore=_soft_ignore) + for d in ("data", "logs"): + (out / d).mkdir(exist_ok=True) + log("root content copied") + + +def write_launcher_sh(out: Path) -> None: + """Write run.sh launcher.""" + (out / "run.sh").write_text( + '#!/bin/bash\n' + 'DIR="$(cd "$(dirname "$0")" && pwd)"\n' + 'exec "$DIR/runtime/bin/python" \\\n' + ' "$DIR/runtime/app/cashcontrol/main.py" \\\n' + ' "$@"\n', + encoding="utf-8", + ) + # Make executable + import os + os.chmod(str(out / "run.sh"), 0o755) + log("launcher written: run.sh") + + +def write_desktop(out: Path, version: str) -> None: + """Write .desktop file.""" + (out / "CashControl.desktop").write_text( + f'[Desktop Entry]\n' + f'Name=CashControl\n' + f'Comment=POS terminal management for SetRetail\n' + f'Exec={out}/run.sh\n' + f'Icon={out}/icon.png\n' + f'Type=Application\n' + f'Categories=Utility;\n' + f'Keywords=SSH;POS;cashier;\n' + f'X-KDE-StartupNotify=false\n', + encoding="utf-8", + ) + log(f"desktop file written: CashControl.desktop") + + +def purge_user_data(out: Path) -> None: + """Remove user data artifacts.""" + for d in ("data", "logs"): + target = out / d + if target.is_dir(): + for f in target.iterdir(): + shutil.rmtree(f, ignore_errors=True) if f.is_dir() else f.unlink() + log(f"purged {d}/") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=DEFAULT_OUT) + parser.add_argument("--tarball", type=Path, default=DEFAULT_TARBALL, + help="Path to output tarball (default: dist/CashControl-linux-x86_64.tar.gz)") + args = parser.parse_args() + + version = sync_version() + log(f"building CashControl v{version} (Linux x86_64)") + + # Find system Python + sys_python = find_system_python() + log(f"using system python: {sys_python}") + + clean_out(args.output) + install_runtime_python(args.output, sys_python) + install_deps(args.output) + prune_runtime(args.output) + copy_app_code(args.output) + build_modules_overlay(args.output) + copy_user_content(args.output) + write_launcher_sh(args.output) + write_desktop(args.output, version) + purge_user_data(args.output) + log(f"layout ready: {args.output}") + + # Create tarball + with tarfile.open(str(args.tarball), "w:gz") as tf: + tf.add(str(args.output), arcname="CashControl") + size_mb = args.tarball.stat().st_size / (1024 * 1024) + log(f"TARBALL DONE: {args.tarball} ({size_mb:.1f} MB)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup_and_build.sh b/setup_and_build.sh new file mode 100644 index 0000000..455fdd8 --- /dev/null +++ b/setup_and_build.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# CashControl — автоматическая сборка Linux-пакета +# Запуск: bash setup_and_build.sh + +# Не останавливаемся на ошибках отдельных команд — каждая проверка автономна + +echo "=== CashControl Linux Build ===" +echo "" + +# ── 1. Проверка зависимостей ────────────────────────────────────────── + +echo "[1/5] Проверка зависимостей..." + +if ! command -v python3.12 &> /dev/null && ! command -v python3.11 &> /dev/null && ! command -v python3 &> /dev/null; then + echo " ERROR: python3 не найден." + echo " Установите: sudo apt-get install python3 python3-venv" + exit 1 +fi + +PYTHON_BIN=$(command -v python3.12 || command -v python3.11 || command -v python3) +echo " Python: $PYTHON_BIN" + +# Устанавливаем uv, если нет +if ! command -v uv &> /dev/null; then + echo " uv не найден — устанавливаю..." + if command -v curl &> /dev/null; then + curl -LsSf https://astral.sh/uv/install.sh | sh + elif command -v wget &> /dev/null; then + wget -qO- https://astral.sh/uv/install.sh | sh + else + echo " WARNING: curl и wget не найдены. Установите uv вручную:" + echo " curl -LsSf https://astral.sh/uv/install.sh | sh" + echo " Или: wget -qO- https://astral.sh/uv/install.sh | sh" + echo " Продолжаю без uv (будет использован pip)..." + fi + export PATH="$HOME/.local/bin:$PATH" +fi + +# ── 2. Системные зависимости ────────────────────────────────────────── + +echo "" +echo "[2/5] Системные зависимости..." + +# Проверяем, есть ли уже нужные библиотеки +MISSING_SYS="" + +if ! ldconfig -p 2>/dev/null | grep -q libsecret && ! find /usr/lib -name 'libsecret-1*' 2>/dev/null | grep -q .; then + MISSING_SYS="$MISSING_SYS libsecret" +fi + +if ! ldconfig -p 2>/dev/null | grep -q libpq && ! find /usr/lib -name 'libpq*' 2>/dev/null | grep -q .; then + MISSING_SYS="$MISSING_SYS libpq" +fi + +if [ -z "$MISSING_SYS" ]; then + echo " Все библиотеки уже установлены." +else + echo " Устанавливаю: $MISSING_SYS..." + if command -v apt-get &> /dev/null; then + # Пробуем установить — если репозитории не работают, продолжаем + sudo apt-get update -qq 2>/dev/null || true + sudo apt-get install -y -qq \ + libsecret-1-dev libpq-dev libxkbcommon-x11-0 \ + libxcb-xinerama0 libegl1 libopengl0 libxcb-cursor0 \ + 2>/dev/null || { + echo " WARNING: apt не смог установить библиотеки." + echo " Попробуйте вручную:" + echo " sudo apt-get install libsecret-1-dev libpq-dev" + } + elif command -v dnf &> /dev/null; then + sudo dnf install -y libsecret-devel postgresql-devel 2>/dev/null || true + elif command -v pacman &> /dev/null; then + sudo pacman -S --noconfirm libsecret postgresql-libs 2>/dev/null || true + elif command -v zypper &> /dev/null; then + sudo zypper install -y libsecret-1-0 libpq5 2>/dev/null || true + elif command -v apk &> /dev/null; then + sudo apk add --no-cache libsecret libpq 2>/dev/null || true + fi +fi + +# ── 3. Виртуальное окружение ────────────────────────────────────────── + +echo "" +echo "[3/5] Виртуальное окружение..." +$PYTHON_BIN -m venv .venv 2>/dev/null +source .venv/bin/activate +echo " Venv создан: $(python --version)" + +# ── 4. Python-зависимости ───────────────────────────────────────────── + +echo "" +echo "[4/5] Python-зависимости..." + +if command -v uv &> /dev/null; then + uv pip install -e . 2>&1 || pip install -e . +else + pip install -e . +fi + +# ── 5. Сборка ───────────────────────────────────────────────────────── + +echo "" +echo "[5/5] Сборка..." +python scripts/build_linux.py + +echo "" +echo "=== Готово ===" +echo "" +echo "Пакет: dist/CashControl-linux-x86_64.tar.gz" +echo "Или распакованная папка: dist/CashControl/" +echo "" +echo "Запуск:" +echo " cd dist/CashControl && ./run.sh" +echo "" +echo "Установка в систему:" +echo " sudo cp CashControl.desktop /usr/share/applications/" +echo " sudo cp icon.png /usr/share/icons/hicolor/128x128/apps/" diff --git a/src/cashcontrol/builtin/file_manager/gui/dialogs.py b/src/cashcontrol/builtin/file_manager/gui/dialogs.py index e5cdd3d..ebdfb73 100644 --- a/src/cashcontrol/builtin/file_manager/gui/dialogs.py +++ b/src/cashcontrol/builtin/file_manager/gui/dialogs.py @@ -6,7 +6,7 @@ import stat as _stat from typing import TYPE_CHECKING, Any -from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtCore import QKeyCombination, QSize, Qt, Signal from PySide6.QtGui import ( QAction, QColor, @@ -669,7 +669,7 @@ def __init__( self._replace_action.setShortcut(QKeySequence.StandardKey.Replace) self._replace_action.triggered.connect(lambda: self._show_find(True)) self._goto_action = QAction("Перейти к строке…", self) - self._goto_action.setShortcut(QKeySequence("Ctrl+G")) + self._goto_action.setShortcut(QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_G))) self._goto_action.triggered.connect(self._go_to_line_dialog) self._save_action = QAction("Сохранить", self) self._save_action.setShortcut(QKeySequence.StandardKey.Save) diff --git a/src/cashcontrol/builtin/file_manager/gui/window.py b/src/cashcontrol/builtin/file_manager/gui/window.py index 3900c97..13d8850 100644 --- a/src/cashcontrol/builtin/file_manager/gui/window.py +++ b/src/cashcontrol/builtin/file_manager/gui/window.py @@ -7,7 +7,7 @@ import sys from typing import Any -from PySide6.QtCore import QEvent, Qt, QTimer, Signal +from PySide6.QtCore import QEvent, QKeyCombination, Qt, QTimer, Signal from PySide6.QtGui import QAction, QKeySequence, QShortcut from PySide6.QtWidgets import ( QDockWidget, @@ -174,12 +174,12 @@ def _build_menu_bar(self) -> None: file_menu = menubar.addMenu("&Файл") connect_action = QAction("Подключение", self) - connect_action.setShortcut(QKeySequence("Ctrl+O")) + connect_action.setShortcut(QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_O))) connect_action.triggered.connect(self._ask_connect) file_menu.addAction(connect_action) file_menu.addSeparator() close_tab_action = QAction("Закрыть вкладку", self) - close_tab_action.setShortcut(QKeySequence("Ctrl+W")) + close_tab_action.setShortcut(QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_W))) close_tab_action.triggered.connect(lambda: self._tabs.count() and self._close_tab(self._tabs.currentIndex())) file_menu.addAction(close_tab_action) file_menu.addSeparator() @@ -203,7 +203,7 @@ def _build_menu_bar(self) -> None: view_menu = menubar.addMenu("&Вид") refresh_action = QAction("Обновить", self) - refresh_action.setShortcut(QKeySequence("Ctrl+R")) + refresh_action.setShortcut(QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_R))) refresh_action.triggered.connect(self._refresh_active) view_menu.addAction(refresh_action) self.addAction(refresh_action) @@ -238,10 +238,10 @@ def _set_debug_mode(self, enabled: bool) -> None: logger.info("Технический режим (DEBUG) %s", "включён" if enabled else "выключен") def _build_shortcuts(self) -> None: - QShortcut(QKeySequence("F4"), self, activated=self._edit_file) - QShortcut(QKeySequence("Ctrl+L"), self, activated=self._focus_address) - QShortcut(QKeySequence("Backspace"), self, activated=self._backspace_up) - QShortcut(QKeySequence("Ctrl+A"), self, activated=self._select_all) + QShortcut(QKeySequence(Qt.Key_F4), self, activated=self._edit_file) + QShortcut(QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_L)), self, activated=self._focus_address) + QShortcut(QKeySequence(Qt.Key_Backspace), self, activated=self._backspace_up) + QShortcut(QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_A)), self, activated=self._select_all) QShortcut(QKeySequence(Qt.Key.Key_Delete), self, activated=self._delete_key) def _edit_file(self) -> None: diff --git a/src/cashcontrol/builtin/ssh_terminal_launcher.py b/src/cashcontrol/builtin/ssh_terminal_launcher.py index 0059835..705dbeb 100644 --- a/src/cashcontrol/builtin/ssh_terminal_launcher.py +++ b/src/cashcontrol/builtin/ssh_terminal_launcher.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging +import platform import subprocess import sys from pathlib import Path @@ -21,7 +22,7 @@ logger = logging.getLogger("cashcontrol.builtin.ssh_terminal") -_CREATE_NO_WINDOW = 0x08000000 +_IS_WINDOWS = platform.system() == "Windows" def builtin_terminal_root() -> Path: @@ -41,17 +42,18 @@ def should_use_builtin_ssh(client_path: str | None) -> bool: def terminal_python() -> str: - """Interpreter for the child process: pythonw when available. + """Interpreter for the child process. - The packaged app runs under runtime/python/pythonw.exe; in dev python.exe - is used — switch to its pythonw sibling to avoid a flash of console window. + On Windows use pythonw.exe (no console window) when available; on Linux + just return sys.executable — there is no pythonw. """ - exe = sys.executable - if Path(exe).name.lower() == "python.exe": - w = Path(exe).with_name("pythonw.exe") - if w.is_file(): - return str(w) - return exe + if _IS_WINDOWS: + exe = sys.executable + if Path(exe).name.lower() == "python.exe": + w = Path(exe).with_name("pythonw.exe") + if w.is_file(): + return str(w) + return sys.executable def build_terminal_command(host: str, port: int, login: str, password_stdin: bool) -> list[str]: @@ -97,10 +99,10 @@ def launch_builtin_terminal( proc = subprocess.Popen( cmd, cwd=str(main_py.parent), - creationflags=_CREATE_NO_WINDOW, stdout=subprocess.DEVNULL, stderr=err_handle, stdin=subprocess.PIPE if use_password else None, + **(dict(creationflags=0x08000000) if _IS_WINDOWS else {}), ) except Exception as e: # pragma: no cover - defensive, subprocess spawn err_handle.close() diff --git a/src/cashcontrol/builtin/terminal/ui/main_window.py b/src/cashcontrol/builtin/terminal/ui/main_window.py index 38a2224..c346219 100644 --- a/src/cashcontrol/builtin/terminal/ui/main_window.py +++ b/src/cashcontrol/builtin/terminal/ui/main_window.py @@ -8,11 +8,46 @@ import asyncio import logging +import platform from collections import deque from typing import Optional -from PySide6.QtCore import QPoint, Qt, QTimer +from PySide6.QtCore import QKeyCombination, QPoint, Qt, QTimer from PySide6.QtGui import QAction, QFont, QKeySequence, QShortcut + + +# Трансляция строковых хоткеев в QKeyCombination (работает на любой раскладке) +def _to_key_combo(key_str: str) -> QKeyCombination: + """Convert string like 'Ctrl+N' to QKeyCombination.""" + parts = key_str.split("+") + mods = 0 + key = parts[-1] + for p in parts[:-1]: + p = p.strip() + if p == "Ctrl": + mods |= Qt.ControlModifier + elif p == "Shift": + mods |= Qt.ShiftModifier + elif p == "Alt": + mods |= Qt.AltModifier + elif p == "Meta": + mods |= Qt.MetaModifier + # Map key name to Qt.Key + key_map = { + "N": Qt.Key_N, + "T": Qt.Key_T, + "W": Qt.Key_W, + "Tab": Qt.Key_Tab, + "Plus": Qt.Key_Plus, + "Equal": Qt.Key_Equal, + "Minus": Qt.Key_Minus, + "0": Qt.Key_0, + "M": Qt.Key_M, + "D": Qt.Key_D, + "Space": Qt.Key_Space, + } + qt_key = key_map.get(key, getattr(Qt, f"Key_{key}", Qt.Key_unknown)) + return QKeyCombination(mods, qt_key) from PySide6.QtWidgets import ( QInputDialog, QMainWindow, @@ -195,51 +230,51 @@ def _setup_hints(self) -> None: def _setup_system_menu(self) -> None: """Добавить пункты в системное меню окна (значок в заголовке).""" - try: - import ctypes - from ctypes import wintypes + if platform.system() != "Windows": + return + import ctypes + from ctypes import wintypes - hwnd = int(self.winId()) - hmenu = ctypes.windll.user32.GetSystemMenu(hwnd, False) - if not hmenu: - return - AppendMenuW = ctypes.windll.user32.AppendMenuW - AppendMenuW(hmenu, 0x0800, 0, None) # MF_SEPARATOR - AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 1, "Новое подключение\tCtrl+N") - AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 2, "Дублировать подключение") - AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 3, "Настройки…") - AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 4, "Сниппеты…") - AppendMenuW(hmenu, 0x0800, 0, None) - AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 5, "Шрифт крупнее\tCtrl++") - AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 6, "Шрифт мельче\tCtrl+-") - AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 7, "Очистить буфер") - self._sys_menu_hwnd = hwnd - self._native = ctypes.windll.user32 # держим ссылку - except Exception as exc: # noqa: BLE001 - Windows-only фича - _LOG.debug("system menu unavailable: %s", exc) + hwnd = int(self.winId()) + hmenu = ctypes.windll.user32.GetSystemMenu(hwnd, False) + if not hmenu: + return + AppendMenuW = ctypes.windll.user32.AppendMenuW + AppendMenuW(hmenu, 0x0800, 0, None) # MF_SEPARATOR + AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 1, "Новое подключение\tCtrl+N") + AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 2, "Дублировать подключение") + AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 3, "Настройки…") + AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 4, "Сниппеты…") + AppendMenuW(hmenu, 0x0800, 0, None) + AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 5, "Шрифт крупнее\tCtrl++") + AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 6, "Шрифт мельче\tCtrl+-") + AppendMenuW(hmenu, 0x0000, _SYS_MENU_MARKER + 7, "Очистить буфер") + self._sys_menu_hwnd = hwnd + self._native = ctypes.windll.user32 # держим ссылку def nativeEvent(self, event_type, message): """Ловим WM_SYSCOMMAND для своих пунктов системного меню.""" - if event_type == "windows_generic_MSG": - import ctypes - - msg = ctypes.wintypes.MSG.from_address(int(message)) - if msg.message == 0x0112: # WM_SYSCOMMAND - cmd = int(msg.wParam) & 0xFFF0 - if cmd > _SYS_MENU_MARKER: - choice = cmd - _SYS_MENU_MARKER - dispatch = { - 1: self.new_connection, - 2: lambda: self.duplicate_tab(self.tabs.currentIndex()), - 3: self._open_settings, - 4: self._open_snippet_manager, - 5: self._font_up, - 6: self._font_down, - 7: self._clear_scrollback, - } - if choice in dispatch: - QTimer.singleShot(0, dispatch[choice]) - return True, 0 + if platform.system() != "Windows" or event_type != "windows_generic_MSG": + return super().nativeEvent(event_type, message) + import ctypes + + msg = ctypes.wintypes.MSG.from_address(int(message)) + if msg.message == 0x0112: # WM_SYSCOMMAND + cmd = int(msg.wParam) & 0xFFF0 + if cmd > _SYS_MENU_MARKER: + choice = cmd - _SYS_MENU_MARKER + dispatch = { + 1: self.new_connection, + 2: lambda: self.duplicate_tab(self.tabs.currentIndex()), + 3: self._open_settings, + 4: self._open_snippet_manager, + 5: self._font_up, + 6: self._font_down, + 7: self._clear_scrollback, + } + if choice in dispatch: + QTimer.singleShot(0, dispatch[choice]) + return True, 0 return super().nativeEvent(event_type, message) # ==================== ПОДКЛЮЧЕНИЕ / ВКЛАДКИ ==================== @@ -481,7 +516,9 @@ def _show_snippet_search(self, widget: Optional[TerminalWidget] = None) -> None: if idx is not None: self.insert_snippet(items[idx]) - def _add_widget_shortcut(self, widget: TerminalWidget, key: str, handler) -> None: + def _add_widget_shortcut(self, widget: TerminalWidget, key, handler) -> None: + if isinstance(key, str): + key = _to_key_combo(key) sc = QShortcut(QKeySequence(key), widget) sc.activated.connect(handler) @@ -499,7 +536,9 @@ def _maybe_show_connect(self) -> None: if not self.tabs.count(): self.new_connection() - def _add_shortcut(self, key: str, handler) -> None: + def _add_shortcut(self, key, handler) -> None: + if isinstance(key, str): + key = _to_key_combo(key) sc = QShortcut(QKeySequence(key), self) sc.activated.connect(handler) diff --git a/src/cashcontrol/core/security/encryption.py b/src/cashcontrol/core/security/encryption.py index 9e962b3..1ae0036 100644 --- a/src/cashcontrol/core/security/encryption.py +++ b/src/cashcontrol/core/security/encryption.py @@ -3,12 +3,15 @@ Passwords are stored encrypted in settings.json. Master key is stored in data/.keystore: on Windows the Fernet key is sealed with DPAPI -(win32crypt.CryptProtectData); a legacy plaintext base64 key found there is -migrated on first load. On first use, master key is generated. +(win32crypt.CryptProtectData); on Linux it is stored in the OS keyring +(keyring/libsecret) with a fallback to a plaintext file (chmod 0600). +A legacy plaintext base64 key found there is migrated on first load. +On first use, master key is generated. """ from __future__ import annotations +import os import platform from typing import TYPE_CHECKING @@ -23,6 +26,8 @@ logger = get_logger() _DPAPI_ENTROPY = b"CashControl.keystore.v1" +_KEYRING_SERVICE = "cashcontrol" +_KEYRING_KEY = "master_key" def _dpapi_available() -> bool: @@ -30,7 +35,6 @@ def _dpapi_available() -> bool: return False try: import win32crypt # noqa: F401 - return True except ImportError: return False @@ -38,26 +42,65 @@ def _dpapi_available() -> bool: def _dpapi_protect(data: bytes) -> bytes: import win32crypt - return win32crypt.CryptProtectData(data, "CashControl", _DPAPI_ENTROPY, None, None, 0) def _dpapi_unprotect(blob: bytes) -> bytes | None: try: import win32crypt - _, data = win32crypt.CryptUnprotectData(blob, _DPAPI_ENTROPY, None, None, 0) return data except Exception: return None +def _keyring_available() -> bool: + """Check if the keyring module is available for Linux.""" + try: + import keyring # noqa: F401 + return True + except ImportError: + return False + + +def _keyring_get() -> bytes | None: + """Retrieve master key from OS keyring (Linux: libsecret/KWallet).""" + if not _keyring_available(): + return None + try: + import keyring + return keyring.get_password(_KEYRING_SERVICE, _KEYRING_KEY) + except Exception: + return None + + +def _keyring_set(data: bytes) -> None: + """Store master key in OS keyring (Linux: libsecret/KWallet).""" + if not _keyring_available(): + return + try: + import keyring + keyring.set_password(_KEYRING_SERVICE, _KEYRING_KEY, data.decode("utf-8")) + except Exception as e: + logger.warning(f"Failed to store key in keyring: {e}") + + +def _secure_store_key(key: bytes, path: Path) -> None: + """Write key to file with secure permissions.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(".tmp") + tmp_path.write_bytes(key) + os.chmod(str(tmp_path), 0o600) + tmp_path.rename(path) + + class EncryptionManager: """ Manages encryption/decryption of sensitive data using Fernet. Singleton pattern — one master key per application instance. - Master key is stored in data/.keystore file. + Master key is stored in data/.keystore file, optionally protected + by OS keyring (Linux) or DPAPI (Windows). """ _instance: EncryptionManager | None = None @@ -81,10 +124,13 @@ def _load_or_create_key(self) -> Fernet: """ Load master key from keystore or create new one. - Keystore formats: DPAPI-sealed blob (current) or raw base64 Fernet - key (legacy, migrated on load). A corrupted existing keystore raises - loudly instead of silently regenerating (which would make all stored - passwords undecryptable). + Loading order: + 1. DPAPI-sealed blob (Windows) + 2. OS keyring (Linux/macOS with keyring installed) + 3. Plaintext base64 Fernet key (legacy, migrated to OS keyring) + + A corrupted existing keystore raises loudly instead of silently + regenerating (which would make all stored passwords undecryptable). Returns: Fernet instance with master key @@ -103,6 +149,7 @@ def _load_or_create_key(self) -> Fernet: def _load_key_bytes(self) -> bytes: raw = self._keystore_path.read_bytes() + # Try DPAPI (Windows) if _dpapi_available(): key = _dpapi_unprotect(raw) if key is not None: @@ -113,33 +160,55 @@ def _load_key_bytes(self) -> bytes: logger.debug("Master key loaded from DPAPI-protected keystore") return key - # Legacy plaintext base64 key — migrate to DPAPI + # Try keyring (Linux/macOS) + keyring_str = _keyring_get() + if keyring_str is not None: + keyring_bytes = keyring_str.encode("utf-8") + try: + Fernet(keyring_bytes) + except Exception as e: + raise RuntimeError(self._corrupt_message()) from e + logger.debug("Master key loaded from OS keyring") + return keyring_bytes + + # Legacy plaintext base64 key try: Fernet(raw) except Exception as e: raise RuntimeError(self._corrupt_message()) from e + # Migrate to OS keyring or secure file if _dpapi_available(): logger.info("Migrating legacy plaintext keystore to DPAPI") - self._write_key_bytes(raw) + blob = _dpapi_protect(raw) + self._keystore_path.write_bytes(blob) + elif _keyring_available(): + logger.info("Migrating legacy plaintext keystore to OS keyring") + _keyring_set(raw) + self._keystore_path.unlink(missing_ok=True) else: logger.warning( - "DPAPI unavailable, master key kept in legacy plaintext form" + "No OS keyring available, master key kept in secure file (chmod 0600)" ) + _secure_store_key(raw, self._keystore_path) return raw def _write_key_bytes(self, key: bytes) -> None: - blob = _dpapi_protect(key) if _dpapi_available() else key - self._keystore_path.parent.mkdir(parents=True, exist_ok=True) - self._set_keystore_hidden(False) - self._keystore_path.write_bytes(blob) - self._set_keystore_hidden(True) + if _dpapi_available(): + blob = _dpapi_protect(key) + self._keystore_path.write_bytes(blob) + self._set_keystore_hidden(True) + elif _keyring_available(): + _keyring_set(key) + self._keystore_path.parent.mkdir(parents=True, exist_ok=True) + self._keystore_path.unlink(missing_ok=True) + else: + _secure_store_key(key, self._keystore_path) def _set_keystore_hidden(self, hidden: bool) -> None: try: if platform.system() == "Windows": import ctypes - attribute = 0x02 if hidden else 0x80 # HIDDEN / NORMAL ctypes.windll.kernel32.SetFileAttributesW( str(self._keystore_path), attribute @@ -157,22 +226,9 @@ def _corrupt_message() -> str: ) def encrypt(self, plaintext: str) -> str: - """ - Encrypt string to base64-encoded ciphertext. - - Args: - plaintext: String to encrypt - - Returns: - Base64-encoded encrypted string (safe to store in JSON) - - Example: - encrypted = manager.encrypt("my_password") - # Returns: "gAAAAABh1..." - """ + """Encrypt string to base64-encoded ciphertext.""" if not plaintext: return "" - try: encrypted_bytes = self._fernet.encrypt(plaintext.encode("utf-8")) return encrypted_bytes.decode("ascii") @@ -181,25 +237,9 @@ def encrypt(self, plaintext: str) -> str: raise def decrypt(self, ciphertext: str) -> str: - """ - Decrypt base64-encoded ciphertext to plaintext. - - Args: - ciphertext: Encrypted string from encrypt() - - Returns: - Decrypted plaintext string - - Raises: - InvalidToken: If ciphertext is corrupted or was encrypted with different key - - Example: - decrypted = manager.decrypt("gAAAAABh1...") - # Returns: "my_password" - """ + """Decrypt base64-encoded ciphertext to plaintext.""" if not ciphertext: return "" - try: decrypted_bytes = self._fernet.decrypt(ciphertext.encode("ascii")) return decrypted_bytes.decode("utf-8") @@ -211,27 +251,11 @@ def decrypt(self, ciphertext: str) -> str: raise def encrypt_list(self, plaintexts: list[str]) -> list[str]: - """ - Encrypt list of strings. - - Args: - plaintexts: List of strings to encrypt - - Returns: - List of encrypted strings - """ + """Encrypt list of strings.""" return [self.encrypt(text) for text in plaintexts if text] def decrypt_list(self, ciphertexts: list[str]) -> list[str]: - """ - Decrypt list of encrypted strings. - - Args: - ciphertexts: List of encrypted strings - - Returns: - List of decrypted strings (skips invalid tokens) - """ + """Decrypt list of encrypted strings (skips invalid tokens).""" decrypted = [] for cipher in ciphertexts: if not cipher: @@ -253,32 +277,13 @@ def _reset_singleton(cls) -> None: def encrypt_password(password: str) -> str: - """ - Encrypt password (convenience function). - - Args: - password: Plaintext password - - Returns: - Encrypted password string - """ + """Encrypt password (convenience function).""" manager = EncryptionManager() return manager.encrypt(password) def decrypt_password(encrypted: str) -> str: - """ - Decrypt password (convenience function). - - Args: - encrypted: Encrypted password string - - Returns: - Plaintext password - - Raises: - InvalidToken: If encrypted string is invalid - """ + """Decrypt password (convenience function).""" manager = EncryptionManager() return manager.decrypt(encrypted) diff --git a/src/cashcontrol/gui/dialogs/add_cash_dialog.py b/src/cashcontrol/gui/dialogs/add_cash_dialog.py index aa389f3..2c793f7 100644 --- a/src/cashcontrol/gui/dialogs/add_cash_dialog.py +++ b/src/cashcontrol/gui/dialogs/add_cash_dialog.py @@ -34,7 +34,8 @@ def _init_ui(self) -> None: row = QHBoxLayout() row.setSpacing(8) label = BodyLabel("IP-адрес кассы:", self) - label.setFixedWidth(100) + label.setFixedWidth(80) + label.setStyleSheet("font-size: 11px;") label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) self._ip_input = LineEdit(self) self._ip_input.setPlaceholderText("192.168.1.10") diff --git a/src/cashcontrol/gui/dialogs/help_content.py b/src/cashcontrol/gui/dialogs/help_content.py index 05766cb..e3e6091 100644 --- a/src/cashcontrol/gui/dialogs/help_content.py +++ b/src/cashcontrol/gui/dialogs/help_content.py @@ -177,14 +177,14 @@ def _page_settings_programs() -> str:

Пути к внешним утилитам. Открывается кнопкой «Настройки» в боковой панели.

SSH Клиент (KiTTY)

-

Путь к kitty.exe. Если не задан — используется soft/kitty.exe +

Путь к kitty. Если не задан — используется soft/kitty рядом с программой. KiTTY подключается автоматически: программа передаёт хост, порт, -логин и пароль через аргументы командной строки.

+логин и пароль через аргументы командной строки. (Linux: /usr/bin/kitty или аналог)

{_screenshot("kitty_window", "KiTTY с автоматическим подключением")}

VNC Клиент

-

Путь к VNC-клиенту (vncviewer.exe от TightVNC, UltraVNC или RealVNC) -и шаблон аргументов.

+

Путь к VNC-клиенту (vncviewer от TightVNC, UltraVNC или RealVNC) +и шаблон аргументов. На Linux: /usr/bin/vncviewer или tightvncviewer.

Переменные для аргументов:

@@ -199,12 +199,12 @@ def _page_settings_programs() -> str:

WinSCP

-

Путь к WinSCP.exe. Протокол и аргументы подставляются автоматически: -SCP для TinyCore, SFTP для Ubuntu. Пароль берётся из настроек подключения.

+

Путь к WinSCP. Протокол и аргументы подставляются автоматически: +SCP для TinyCore, SFTP для Ubuntu. Пароль берётся из настроек подключения. (Linux: /usr/bin/winscp.exe через Wine)

PostgreSQL Клиент

Внешний DB-клиент (psql, DBeaver, pgAdmin). Если не задан — открывается -встроенный DB Viewer. Если задан — запускается указанный exe с аргументами.

+встроенный DB Viewer. Если задан — запускается указанная программа с аргументами.

Переменные для аргументов:

ПеременнаяЗначение
@@ -414,12 +414,12 @@ def _page_first_start() -> str:

2. Настройки программ

Откройте Программы и укажите пути к:

-
Если положить kitty.exe в папку soft/ -рядом с программой — путь задавать не нужно.
+
Если положить kitty в папку soft/ +рядом с программой — путь задавать не нужно. На Linux утилиты обычно в /usr/bin/.

3. Добавить кассу

Нажмите + на панели вкладок и введите IP-адрес кассы. @@ -429,7 +429,7 @@ def _page_first_start() -> str:

Структура папок программы

ПеременнаяЗначение
- + diff --git a/src/cashcontrol/gui/dialogs/settings/tab_logs.py b/src/cashcontrol/gui/dialogs/settings/tab_logs.py index 2191fab..775f086 100644 --- a/src/cashcontrol/gui/dialogs/settings/tab_logs.py +++ b/src/cashcontrol/gui/dialogs/settings/tab_logs.py @@ -2,10 +2,10 @@ from __future__ import annotations -import os from typing import TYPE_CHECKING -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QUrl +from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import ( QHBoxLayout, QScrollArea, @@ -121,7 +121,7 @@ def _make_card(self) -> CardWidget: def _open_logs_folder(self) -> None: logs_dir = get_logs_dir() if logs_dir.exists(): - os.startfile(str(logs_dir)) + QDesktopServices.openUrl(QUrl.fromLocalFile(str(logs_dir))) else: MessageBox("Папка логов", f"Папка не найдена:\n{logs_dir}", self).exec() diff --git a/src/cashcontrol/gui/dialogs/settings/tab_programs.py b/src/cashcontrol/gui/dialogs/settings/tab_programs.py index 64dbe0f..002b779 100644 --- a/src/cashcontrol/gui/dialogs/settings/tab_programs.py +++ b/src/cashcontrol/gui/dialogs/settings/tab_programs.py @@ -2,6 +2,7 @@ from __future__ import annotations +import platform from pathlib import Path from typing import TYPE_CHECKING @@ -29,10 +30,33 @@ if TYPE_CHECKING: from cashcontrol.infrastructure.config_manager import ConfigManager +_IS_LINUX = platform.system() == "Linux" + _LABEL_W = 110 _BTN_SIZE = 32 _ARG_W = 250 +_LINUX_SSH_CLIENTS = [ + ("ssh (OpenSSH, по умолчанию)", "ssh"), + ("kitty", "kitty"), + ("gnome-terminal", "gnome-terminal"), + ("konsole", "konsole"), + ("xterm", "xterm"), + ("alacritty", "alacritty"), + ("foot (Wayland)", "foot"), + ("mate-terminal", "mate-terminal"), + ("custom...", ""), +] + +_LINUX_VNC_CLIENTS = [ + ("remmina", "remmina"), + ("xtightvncviewer", "xtightvncviewer"), + ("tightvnc", "tightvnc"), + ("rdesktop", "rdesktop"), + ("xfreerdp", "xfreerdp"), + ("custom...", ""), +] + _EXTERNAL_DEFAULTS = { "ssh_args_template": ( "-- ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=yes" @@ -89,11 +113,18 @@ def _make_external_card(self) -> CardWidget: for name, path_key, args_key in programs: self._program_row(layout, card, name, path_key, args_key) - hint = BodyLabel( - "Переменные аргументов: {host} {port} {login} {display} {db_port} {db_login}. " - "Если путь не задан или файл не найден — используется встроенный инструмент.", - card, - ) + if _IS_LINUX: + hint = BodyLabel( + "На Linux программы — системные пакеты (apt install). " + "Если путь не задан — используется встроенный инструмент.", + card, + ) + else: + hint = BodyLabel( + "Переменные аргументов: {host} {port} {login} {display} {db_port} {db_login}. " + "Если путь не задан или файл не найден — используется встроенный инструмент.", + card, + ) hint.setWordWrap(True) hint.setStyleSheet(f"color: {_tc('text_secondary')}; font-size: 11px;") layout.addWidget(hint) @@ -108,17 +139,38 @@ def _program_row(self, layout: QVBoxLayout, parent: QWidget, name_lbl.setFixedWidth(_LABEL_W) name_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) - path_edit = LineEdit(parent) - path_edit.setPlaceholderText("Путь к .exe файлу программы\u2026") - path_edit.setClearButtonEnabled(True) - path_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - browse_btn = ToolButton(FluentIcon.FOLDER, parent) - browse_btn.setFixedSize(_BTN_SIZE, _BTN_SIZE) - browse_btn.setToolTip("Выбрать файл") - browse_btn.clicked.connect( - lambda _checked=False, e=path_edit: self._browse_file(e) - ) + if _IS_LINUX and path_key in ("ssh_client_path", "vnc_client_path"): + # Linux: dropdown с терминалами (QComboBox из-за бага qfluentwidgets) + from PySide6.QtWidgets import QComboBox + combo = QComboBox(parent) + if path_key == "ssh_client_path": + for text, _ in _LINUX_SSH_CLIENTS: + combo.addItem(text) + else: + for text, _ in _LINUX_VNC_CLIENTS: + combo.addItem(text) + combo.setFixedWidth(200) + combo.setToolTip("Встроенный терминал или SSH-клиент" if path_key == "ssh_client_path" else "Встроенный VNC-клиент или внешний") + combo.currentTextChanged.connect( + lambda txt, k=path_key: None # placeholder + ) + setattr(self, path_key, combo) + widget = combo + else: + path_edit = LineEdit(parent) + path_edit.setPlaceholderText("Путь к программе…") + path_edit.setClearButtonEnabled(True) + path_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + setattr(self, path_key, path_edit) + widget = path_edit + + if not _IS_LINUX: + browse_btn = ToolButton(FluentIcon.FOLDER, parent) + browse_btn.setFixedSize(_BTN_SIZE, _BTN_SIZE) + browse_btn.setToolTip("Выбрать файл") + browse_btn.clicked.connect( + lambda _checked=False, e=widget: self._browse_file(e) + ) args_edit = LineEdit(parent) args_edit.setFixedWidth(_ARG_W) @@ -127,14 +179,20 @@ def _program_row(self, layout: QVBoxLayout, parent: QWidget, args_edit.setToolTip("Аргументы запуска (см. описание ниже)") row.addWidget(name_lbl) - row.addWidget(path_edit, stretch=1) - row.addWidget(browse_btn) + row.addWidget(widget, stretch=1) + if not _IS_LINUX: + row.addWidget(browse_btn) row.addWidget(args_edit) layout.addLayout(row) - setattr(self, path_key, path_edit) setattr(self, args_key, args_edit) + def _on_linux_combo_changed(self, text: str, path_key: str) -> None: + """Handle Linux dropdown selection — set executable name.""" + if "custom" in text.lower(): + setattr(self, path_key, LineEdit(self)) + setattr(self, path_key, getattr(self, path_key)) + def _make_builtin_card(self) -> CardWidget: card = CardWidget(self) card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) @@ -207,22 +265,48 @@ def _section_label(self, parent: QWidget, text: str) -> BodyLabel: def _browse_file(self, line_edit: LineEdit) -> None: current = line_edit.text().strip() start_dir = str(Path(current).parent) if current else str(Path.home()) - path, _ = QFileDialog.getOpenFileName( - self, "Выберите программу", start_dir, - "Программы (*.exe);;Все файлы (*.*)", - ) + if _IS_LINUX: + path, _ = QFileDialog.getOpenFileName( + self, "Выберите программу", start_dir, + "Все файлы (*.*)", + ) + else: + path, _ = QFileDialog.getOpenFileName( + self, "Выберите программу", start_dir, + "Программы (*.exe);;Все файлы (*.*)", + ) if path: line_edit.setText(path) def load(self, config: ConfigManager) -> None: p = config.settings.programs - self.ssh_client_path.setText(p.ssh_client_path or "") - self.vnc_client_path.setText(p.vnc_client_path or "") - self.winscp_path.setText(p.winscp_path or "") - self.db_client_path.setText(p.db_client_path or "") - self.ssh_client_path.setPlaceholderText(p.get_ssh_client()) - self.vnc_client_path.setPlaceholderText(p.get_vnc_client()) - self.winscp_path.setPlaceholderText(p.get_winscp()) + if _IS_LINUX: + # Linux: заполняем dropdown + ssh_val = p.ssh_client_path or "ssh" + for i, (_, exec_name) in enumerate(_LINUX_SSH_CLIENTS): + if exec_name == ssh_val: + self.ssh_client_path.setCurrentIndex(i) + break + else: + # custom or not found — use last item + self.ssh_client_path.setCurrentIndex(len(_LINUX_SSH_CLIENTS) - 1) + + vnc_val = p.vnc_client_path or "remmina" + for i, (_, exec_name) in enumerate(_LINUX_VNC_CLIENTS): + if exec_name == vnc_val: + self.vnc_client_path.setCurrentIndex(i) + break + else: + self.vnc_client_path.setCurrentIndex(len(_LINUX_VNC_CLIENTS) - 1) + else: + self.ssh_client_path.setText(p.ssh_client_path or "") + self.vnc_client_path.setText(p.vnc_client_path or "") + self.winscp_path.setText(p.winscp_path or "") + self.db_client_path.setText(p.db_client_path or "") + self.ssh_client_path.setPlaceholderText(p.get_ssh_client()) + self.vnc_client_path.setPlaceholderText(p.get_vnc_client()) + self.winscp_path.setPlaceholderText(p.get_winscp()) + self.ssh_args_template.setText(p.ssh_args_template) self.vnc_args_template.setText(p.vnc_args_template) self.winscp_args_template.setText(p.winscp_args_template) @@ -238,21 +322,59 @@ def load(self, config: ConfigManager) -> None: ) def save(self, config: ConfigManager) -> None: - config.update( - "programs", - ssh_client_path=self.ssh_client_path.text().strip() or None, - vnc_client_path=self.vnc_client_path.text().strip() or None, - winscp_path=self.winscp_path.text().strip() or None, - db_client_path=self.db_client_path.text().strip() or None, - ssh_args_template=self.ssh_args_template.text().strip() - or _EXTERNAL_DEFAULTS["ssh_args_template"], - vnc_args_template=self.vnc_args_template.text().strip() - or _EXTERNAL_DEFAULTS["vnc_args_template"], - winscp_args_template=self.winscp_args_template.text().strip() - or _EXTERNAL_DEFAULTS["winscp_args_template"], - db_args_template=self.db_args_template.text().strip() - or _EXTERNAL_DEFAULTS["db_args_template"], - ) + if _IS_LINUX: + # Linux: извлекаем executable из dropdown + ssh_combo = self.ssh_client_path + ssh_text = ssh_combo.currentText() + ssh_exec = None + for _, exec_name in _LINUX_SSH_CLIENTS: + if ssh_text.startswith(f"{exec_name} ("): + ssh_exec = exec_name + break + else: + ssh_exec = None # custom or unknown + + vnc_combo = self.vnc_client_path + vnc_text = vnc_combo.currentText() + vnc_exec = None + for _, exec_name in _LINUX_VNC_CLIENTS: + if vnc_text.startswith(f"{exec_name} ("): + vnc_exec = exec_name + break + else: + vnc_exec = None + + config.update( + "programs", + ssh_client_path=ssh_exec, + vnc_client_path=vnc_exec, + winscp_path=None, + db_client_path=self.db_client_path.text().strip() or None, + ssh_args_template=self.ssh_args_template.text().strip() + or _EXTERNAL_DEFAULTS["ssh_args_template"], + vnc_args_template=self.vnc_args_template.text().strip() + or _EXTERNAL_DEFAULTS["vnc_args_template"], + winscp_args_template=self.winscp_args_template.text().strip() + or _EXTERNAL_DEFAULTS["winscp_args_template"], + db_args_template=self.db_args_template.text().strip() + or _EXTERNAL_DEFAULTS["db_args_template"], + ) + else: + config.update( + "programs", + ssh_client_path=self.ssh_client_path.text().strip() or None, + vnc_client_path=self.vnc_client_path.text().strip() or None, + winscp_path=self.winscp_path.text().strip() or None, + db_client_path=self.db_client_path.text().strip() or None, + ssh_args_template=self.ssh_args_template.text().strip() + or _EXTERNAL_DEFAULTS["ssh_args_template"], + vnc_args_template=self.vnc_args_template.text().strip() + or _EXTERNAL_DEFAULTS["vnc_args_template"], + winscp_args_template=self.winscp_args_template.text().strip() + or _EXTERNAL_DEFAULTS["winscp_args_template"], + db_args_template=self.db_args_template.text().strip() + or _EXTERNAL_DEFAULTS["db_args_template"], + ) config.update( "builtin", file_manager_start_dir=self.file_manager_start_dir.text().strip() or "/home/tc/storage", diff --git a/src/cashcontrol/gui/dialogs/setup_wizard/page_connection.py b/src/cashcontrol/gui/dialogs/setup_wizard/page_connection.py index 13f05f9..6f050d0 100644 --- a/src/cashcontrol/gui/dialogs/setup_wizard/page_connection.py +++ b/src/cashcontrol/gui/dialogs/setup_wizard/page_connection.py @@ -70,7 +70,8 @@ def _field_row(self, label_text: str, parent: QWidget): row = QHBoxLayout() row.setSpacing(8) lbl = BodyLabel(label_text) - lbl.setFixedWidth(80) + lbl.setFixedWidth(65) + lbl.setStyleSheet("font-size: 11px;") lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) edit = LineEdit(parent) edit.setClearButtonEnabled(True) @@ -82,7 +83,8 @@ def _port_row(self, label_text: str, parent: QWidget): row = QHBoxLayout() row.setSpacing(8) lbl = BodyLabel(label_text) - lbl.setFixedWidth(80) + lbl.setFixedWidth(65) + lbl.setStyleSheet("font-size: 11px;") lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) edit = LineEdit(parent) edit.setFixedWidth(90) diff --git a/src/cashcontrol/gui/dialogs/setup_wizard/page_programs.py b/src/cashcontrol/gui/dialogs/setup_wizard/page_programs.py index ca61a35..c999c1f 100644 --- a/src/cashcontrol/gui/dialogs/setup_wizard/page_programs.py +++ b/src/cashcontrol/gui/dialogs/setup_wizard/page_programs.py @@ -17,13 +17,13 @@ def __init__(self, parent=None) -> None: layout.addWidget(SubtitleLabel("Пути к программам", self)) - self.kitty_row = self._path_row("KiTTY:", "kitty.exe", "kitty.exe") + self.kitty_row = self._path_row("KiTTY:", "kitty", "kitty") layout.addLayout(self.kitty_row) - self.vnc_row = self._path_row("VNC:", "vncviewer_new.exe", "vncviewer_new.exe") + self.vnc_row = self._path_row("VNC:", "vncviewer", "vncviewer") layout.addLayout(self.vnc_row) - self.winscp_row = self._path_row("WinSCP:", "WinSCP.exe", "WinSCP.exe") + self.winscp_row = self._path_row("WinSCP:", "WinSCP", "WinSCP") layout.addLayout(self.winscp_row) auto_btn = PushButton("Автопоиск", self) @@ -64,9 +64,9 @@ def _set_default(self, edit: LineEdit, name: str) -> None: def _auto_find(self) -> None: soft_dir = get_soft_dir() defaults = { - "kitty.exe": self._find_row(0), - "vncviewer_new.exe": self._find_row(1), - "WinSCP.exe": self._find_row(2), + "kitty": self._find_row(0), + "vncviewer": self._find_row(1), + "WinSCP": self._find_row(2), } for name, edit in defaults.items(): candidate = soft_dir / name diff --git a/src/cashcontrol/gui/main_window.py b/src/cashcontrol/gui/main_window.py index 9c6a920..cc99253 100644 --- a/src/cashcontrol/gui/main_window.py +++ b/src/cashcontrol/gui/main_window.py @@ -8,7 +8,7 @@ from __future__ import annotations -from PySide6.QtCore import QSettings, QSize, QTimer +from PySide6.QtCore import QKeyCombination, QSettings, QSize, Qt, QTimer from PySide6.QtGui import QCloseEvent, QKeySequence, QShortcut from PySide6.QtWidgets import QFrame, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget @@ -86,29 +86,33 @@ def _init_ui(self) -> None: right_layout.addWidget(self._tab_manager, stretch=1) # Ctrl+T — быстрое добавление новой кассы - sc_new_tab = QShortcut(QKeySequence("Ctrl+T"), self) + sc_new_tab = QShortcut(QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_T)), self) + sc_new_tab.setContext(Qt.ShortcutContext.WindowShortcut) sc_new_tab.activated.connect(self._tab_manager.open_add_tab_dialog) # Горячие клавиши быстрого запуска инструментов. # QKeySequence привязывается по коду клавиши — работает на любой раскладке. toolbar_hotkeys = { - "Ctrl+S": "_on_ssh", - "Ctrl+W": "_on_winscp", - "Ctrl+D": "_on_postgres", - "Ctrl+R": "_on_restart_pos", - "Ctrl+Shift+R": "_on_reboot_terminal", - "F5": "_on_refresh_info", - "F6": "_on_postgres", - "F7": "_on_keyboard", - "F8": "_on_commands_clicked", + QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_S)): "_on_ssh", + QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_W)): "_on_winscp", + QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_D)): "_on_postgres", + QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_R)): "_on_restart_pos", + QKeySequence(QKeyCombination(Qt.ControlModifier | Qt.ShiftModifier, Qt.Key_R)): "_on_reboot_terminal", + QKeySequence(Qt.Key_F5): "_on_refresh_info", + QKeySequence(Qt.Key_F6): "_on_postgres", + QKeySequence(Qt.Key_F7): "_on_keyboard", + QKeySequence(Qt.Key_F8): "_on_commands_clicked", } for seq, handler in toolbar_hotkeys.items(): sc = QShortcut(QKeySequence(seq), self) + sc.setContext(Qt.ShortcutContext.WindowShortcut) sc.activated.connect(getattr(self._tab_manager.cash_toolbar, handler)) - sc_vnc = QShortcut(QKeySequence("Ctrl+V"), self) + sc_vnc = QShortcut(QKeySequence(QKeyCombination(Qt.ControlModifier, Qt.Key_V)), self) + sc_vnc.setContext(Qt.ShortcutContext.WindowShortcut) sc_vnc.activated.connect(self._hotkey_vnc_embedded) - sc_vnc_ext = QShortcut(QKeySequence("Ctrl+Shift+V"), self) + sc_vnc_ext = QShortcut(QKeySequence(QKeyCombination(Qt.ControlModifier | Qt.ShiftModifier, Qt.Key_V)), self) + sc_vnc_ext.setContext(Qt.ShortcutContext.WindowShortcut) sc_vnc_ext.activated.connect(self._hotkey_vnc_external) self._status_bar = CashStatusBar(self) diff --git a/src/cashcontrol/gui/toolbar.py b/src/cashcontrol/gui/toolbar.py index 54d3075..8e48ad2 100644 --- a/src/cashcontrol/gui/toolbar.py +++ b/src/cashcontrol/gui/toolbar.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import platform import subprocess from pathlib import Path from typing import TYPE_CHECKING @@ -81,9 +82,8 @@ def __init__(self, parent=None) -> None: def _make_labeled_btn(icon: FluentIcon, label: str, tooltip: str) -> QWidget: container = QWidget() - container.setFixedWidth(_TOOL_BTN_SIZE + 8) lay = QVBoxLayout(container) - lay.setContentsMargins(0, 2, 0, 2) + lay.setContentsMargins(2, 2, 2, 2) lay.setSpacing(1) lay.setAlignment(Qt.AlignmentFlag.AlignHCenter) @@ -96,9 +96,8 @@ def _make_labeled_btn(icon: FluentIcon, label: str, tooltip: str) -> QWidget: lbl = QLabel(label, container) from cashcontrol.gui.theme_helper import color lbl.setStyleSheet( - f"font-size: 9px; color: {color('text_secondary')}; background: transparent;" + "font-size: 8px; color: {}; background: transparent; qproperty-alignment: AlignCenter;".format(color('text_secondary')) ) - lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) lbl.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) lay.addWidget(lbl, 0, Qt.AlignmentFlag.AlignHCenter) @@ -113,7 +112,6 @@ def _make_keyboard_btn(on_keyboard, on_keyboard_menu) -> QWidget: lay.setSpacing(0) main_c = QWidget(container) - main_c.setFixedWidth(68) main_vbox = QVBoxLayout(main_c) main_vbox.setContentsMargins(0, 2, 0, 2) main_vbox.setSpacing(1) @@ -129,9 +127,8 @@ def _make_keyboard_btn(on_keyboard, on_keyboard_menu) -> QWidget: kb_lbl = QLabel("Клавиатура", main_c) from cashcontrol.gui.theme_helper import color as _tc kb_lbl.setStyleSheet( - f"font-size: 9px; color: {_tc('text_secondary')}; background: transparent;" + "font-size: 8px; color: {}; background: transparent; qproperty-alignment: AlignCenter;".format(_tc('text_secondary')) ) - kb_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) kb_lbl.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) main_vbox.addWidget(kb_lbl, 0, Qt.AlignmentFlag.AlignHCenter) lay.addWidget(main_c) @@ -407,38 +404,54 @@ async def _launch_kitty(self) -> None: conn = self._config.settings.connection from cashcontrol.builtin.ssh_terminal_launcher import should_use_builtin_ssh + from cashcontrol.infrastructure.config_manager import _IS_LINUX if should_use_builtin_ssh(p.ssh_client_path): self._open_builtin_ssh_terminal(session) return - exe = Path(p.ssh_client_path or "") - ip = session.ip password = None if session.session and session.session.ssh_connected: password = session.session.ssh.successful_password - if password: - cmd = [ - str(exe), - f"{conn.ssh_login}@{ip}", - "-pw", password, - "-auto-store-sshkey", - ] - logger.info(f"Launching KiTTY with auto-login for {ip}") + if _IS_LINUX: + # Linux: используем ssh или выбранный терминал + client = p.ssh_client_path or "ssh" + if client == "ssh": + cmd = ["ssh", f"{conn.ssh_login}@{ip}", "-o", "StrictHostKeyChecking=no", + "-o", "PasswordAuthentication=yes"] + if password: + # Используем sshpass или встроенный терминал + import shutil + if shutil.which("sshpass"): + cmd = ["sshpass", "-p", password, *cmd] + else: + self._open_builtin_ssh_terminal(session) + return + else: + # Терминал с ssh + cmd = [client, "-e", f"ssh {conn.ssh_login}@{ip}"] else: - cmd = [str(exe), f"{conn.ssh_login}@{ip}"] - logger.info(f"Launching KiTTY without password for {ip}") + exe = Path(p.ssh_client_path or "") + if password: + logger.info(f"Launching KiTTY with auto-login for {ip}") + cmd = [str(exe), f"{conn.ssh_login}@{ip}", "-pw", password, "-auto-store-sshkey"] + else: + logger.info(f"Launching KiTTY without password for {ip}") + cmd = [str(exe), f"{conn.ssh_login}@{ip}"] audit_log(action_type="tool", action_name="ssh_client", target=ip, result="success") def _run(): time.sleep(0.2) try: - subprocess.Popen(cmd, creationflags=0x08000000) + kwargs = dict() + if platform.system() == "Windows": + kwargs["creationflags"] = 0x08000000 + subprocess.Popen(cmd, **kwargs) except Exception as e: - logger.error(f"KiTTY launch failed: {e}") + logger.error(f"SSH launch failed: {e}") threading.Thread(target=_run, daemon=True).start() @@ -526,7 +539,10 @@ async def _launch_winscp(self) -> None: def _run(): time.sleep(0.2) try: - subprocess.Popen(cmd, creationflags=0x08000000) + kwargs = dict() + if platform.system() == "Windows": + kwargs["creationflags"] = 0x08000000 + subprocess.Popen(cmd, **kwargs) except Exception as e: logger.error(f"WinSCP launch failed: {e}") diff --git a/src/cashcontrol/infrastructure/config_manager.py b/src/cashcontrol/infrastructure/config_manager.py index c7dba22..cafde67 100644 --- a/src/cashcontrol/infrastructure/config_manager.py +++ b/src/cashcontrol/infrastructure/config_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +import platform from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -14,6 +15,9 @@ logger = get_logger() +_IS_WINDOWS = platform.system() == "Windows" +_IS_LINUX = platform.system() == "Linux" + class ConnectionSettings(BaseModel): ssh_login: str = "tc" @@ -44,6 +48,10 @@ class ProgramsSettings(BaseModel): def _default_client(self, field: str | None, exe: str) -> str: if field and field.strip(): return field + # On Linux, external clients are .exe — always return empty so + # callers fall back to built-in tools. + if _IS_LINUX: + return "" from cashcontrol.infrastructure.path_resolver import get_soft_dir return str(get_soft_dir() / exe) @@ -51,7 +59,7 @@ def get_ssh_client(self) -> str: return self._default_client(self.ssh_client_path, "kitty.exe") def get_vnc_client(self) -> str: - return self._default_client(self.vnc_client_path, "vncviewer_new.exe") + return self._default_client(self.vnc_client_path, "vncviewer.exe") def get_winscp(self) -> str: return self._default_client(self.winscp_path, "WinSCP.exe") diff --git a/src/cashcontrol/infrastructure/path_resolver.py b/src/cashcontrol/infrastructure/path_resolver.py index a79e0d4..eb1af84 100644 --- a/src/cashcontrol/infrastructure/path_resolver.py +++ b/src/cashcontrol/infrastructure/path_resolver.py @@ -2,6 +2,7 @@ import contextlib import os +import platform import sys from functools import lru_cache from pathlib import Path @@ -9,13 +10,24 @@ # ── Environment ─────────────────────────────────────────────────────────── def _runtime_layout_root() -> Path | None: - """Program root for the packaged runtime layout (gap_report §8.2): - /runtime/python/, with version.txt + runtime/ at . - Returns None outside such a layout.""" - exe_dir = Path(sys.executable).parent # .../CashControl/runtime/python - candidate = exe_dir.parent.parent # .../CashControl + """Program root for the packaged runtime layout. + + Windows: /runtime/python/, with version.txt + runtime/ at + . + Linux: /runtime/bin/python (same version.txt + runtime/ layout). + + Returns None outside such a layout. + """ + exe_dir = Path(sys.executable).parent + # Windows layout: …/runtime/python/python.exe → parent.parent = root + candidate = exe_dir.parent.parent if (candidate / "version.txt").exists() and (candidate / "runtime").exists(): return candidate + # Linux layout: …/runtime/bin/python → parent.parent = root + if exe_dir.name == "bin": + candidate = exe_dir.parent.parent + if (candidate / "version.txt").exists() and (candidate / "runtime").exists(): + return candidate return None @@ -23,10 +35,12 @@ def _runtime_layout_root() -> Path | None: def _add_runtime_dll_dirs(root: Path) -> None: - """Make native deps (pywin32_system32, *.libs, ...) resolvable from runtime/lib.""" + """Make native deps (pywin32_system32, *.libs, …) resolvable on Windows.""" global _dll_dirs_done if _dll_dirs_done: return + if platform.system() != "Windows": + return _dll_dirs_done = True lib = root / "runtime" / "lib" if not lib.is_dir(): diff --git a/tests/test_ssh_terminal_launcher.py b/tests/test_ssh_terminal_launcher.py index 75c8982..a548593 100644 --- a/tests/test_ssh_terminal_launcher.py +++ b/tests/test_ssh_terminal_launcher.py @@ -26,7 +26,7 @@ def test_should_use_builtin_ssh_existing_file(tmp_path): def test_build_command_carries_connection_args(): cmd = build_terminal_command("10.0.0.5", 2222, "tc", False) - assert cmd[0].endswith("pythonw.exe") or cmd[0].endswith("python.exe") + assert cmd[0].endswith("pythonw.exe") or cmd[0].endswith("python.exe") or cmd[0].endswith("/python") assert cmd[1].endswith("main.py") assert "--host" in cmd and "10.0.0.5" in cmd assert "--port" in cmd and "2222" in cmd diff --git a/uv.lock b/uv.lock index fb4e12c..8e3f620 100644 --- a/uv.lock +++ b/uv.lock @@ -57,6 +57,7 @@ dependencies = [ { name = "asyncssh" }, { name = "cryptography" }, { name = "defusedxml" }, + { name = "keyring", marker = "sys_platform == 'linux'" }, { name = "numpy" }, { name = "psycopg2-binary" }, { name = "pydantic" }, @@ -81,6 +82,7 @@ requires-dist = [ { name = "asyncssh", specifier = ">=2.17" }, { name = "cryptography", specifier = ">=43" }, { name = "defusedxml", specifier = ">=0.7" }, + { name = "keyring", marker = "sys_platform == 'linux'", specifier = ">=24" }, { name = "numpy", specifier = ">=1.26" }, { name = "psycopg2-binary", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.9" }, @@ -209,6 +211,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "numpy" version = "2.5.2" @@ -3050,6 +3119,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "shiboken6" version = "6.8.3"
Папка/файлСодержимое
soft/kitty.exe, vncviewer.exe и другие утилиты
soft/SSH, VNC и другие утилиты
commands/JSON-файлы пользовательских команд
data/Настройки, сессии, журнал
docs/screenshots/Скриншоты для справочника