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:
Пути к внешним утилитам. Открывается кнопкой «Настройки» в боковой панели.
Путь к kitty.exe. Если не задан — используется soft/kitty.exe
+
Путь к kitty. Если не задан — используется soft/kitty
рядом с программой. KiTTY подключается автоматически: программа передаёт хост, порт,
-логин и пароль через аргументы командной строки.
/usr/bin/kitty или аналог)
{_screenshot("kitty_window", "KiTTY с автоматическим подключением")}
Путь к VNC-клиенту (vncviewer.exe от TightVNC, UltraVNC или RealVNC)
-и шаблон аргументов.
Путь к VNC-клиенту (vncviewer от TightVNC, UltraVNC или RealVNC)
+и шаблон аргументов. На Linux: /usr/bin/vncviewer или tightvncviewer.
Переменные для аргументов:
| Переменная | Значение |
|---|
| Переменная | Значение |
|---|
| Папка/файл | Содержимое |
|---|---|
soft/ | kitty.exe, vncviewer.exe и другие утилиты |
soft/ | SSH, VNC и другие утилиты |
commands/ | JSON-файлы пользовательских команд |
data/ | Настройки, сессии, журнал |
docs/screenshots/ | Скриншоты для справочника |