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 — SSH-клиент
- vncviewer.exe — VNC-клиент
- WinSCP.exe — файловый менеджер
+ kitty — SSH-клиент
+ vncviewer — VNC-клиент
+ WinSCP — файловый менеджер
-Если положить kitty.exe в папку soft/
-рядом с программой — путь задавать не нужно.
+Если положить kitty в папку soft/
+рядом с программой — путь задавать не нужно. На Linux утилиты обычно в /usr/bin/.
3. Добавить кассу
Нажмите + на панели вкладок и введите IP-адрес кассы.
@@ -429,7 +429,7 @@ def _page_first_start() -> str:
Структура папок программы
| Папка/файл | Содержимое |
- soft/ | kitty.exe, vncviewer.exe и другие утилиты |
+ soft/ | SSH, VNC и другие утилиты |
commands/ | JSON-файлы пользовательских команд |
data/ | Настройки, сессии, журнал |
docs/screenshots/ | Скриншоты для справочника |
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"