""" Filename: ssh_remote_plugin.py Description: ssh-remote-pack plugin (PyInstaller Single-Binary Edition with file-based result transfer) Author: (you) License: GPL-3.0 """ import base64 import json import os import pickle import shlex import tempfile import time import uuid from multiprocessing import Queue from pathlib import Path from typing import Optional import paramiko from PySide6.QtCore import QObject, QThread, QTimer, Signal from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QApplication, QCheckBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMenu, QMessageBox, QPushButton, QSpinBox, QVBoxLayout, QWidget, ) from src.shared.shareddata import DATA_SCHEMA CONFIG_DIR = Path.home() / ".config" / "ssh_remote_pack" CONFIG_PATH = CONFIG_DIR / "connection.json" def load_connection_config() -> dict: if CONFIG_PATH.is_file(): try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {"host": "", "port": 22, "username": "", "remote_executable": ""} def save_connection_config(host: str, port: int, username: str, remote_executable: str = "") -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump({ "host": host, "port": port, "username": username, "remote_executable": remote_executable, }, f, indent=2) class SshSession: """Holds SSH connection details for the current process only.""" def __init__(self) -> None: self.host: str = "" self.port: int = 22 self.username: str = "" self.password: str = "" self.remote_executable: str = "" def is_configured(self) -> bool: return bool(self.host and self.username and self.password and self.remote_executable) def clear_password(self) -> None: self.password = "" SESSION = SshSession() def _connect(session: SshSession, timeout: int = 10) -> paramiko.SSHClient: client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect( session.host, port=session.port, username=session.username, password=session.password, timeout=timeout, look_for_keys=False, allow_agent=False, ) return client class _FakeRemoteProcess: def __init__(self) -> None: self._alive = False self.exitcode: Optional[int] = 0 self.pid: int = 99999 def mark_alive(self, value: bool) -> None: self._alive = value self.exitcode = None if value else 0 def is_alive(self) -> bool: return self._alive def terminate(self) -> None: self._alive = False self.exitcode = -15 def join(self, timeout: Optional[float] = None) -> None: pass class RemoteTaskWorker(QObject): """Uploads the job to the remote server, launches the PyInstaller worker binary over SSH, and streams results back. Small messages (heartbeats, progress ticks, completion, errors) come back inline, pickled and base64-encoded, over the SSH channel's stdout. Anything carrying a real per-participant result (MNE objects, DataFrames, figure bytes, etc.) is written to a file on the remote box instead - the worker sends a small pointer message, and this class fetches that one file over the still-open SFTP session. That avoids both the size overhead of inlining large pickles as base64 text and the lossy JSON/str() conversion the old transport used. Everything ends up on `result_queue` - the same queue check_for_pipeline_results already polls - including progress ticks, which the old version sent to `progress_queue` where nothing in the app was actually reading them. """ finished = Signal() log_signal = Signal(str) def __init__( self, session: SshSession, config: dict, result_queue: "Queue", progress_queue: Optional["Queue"] = None, remote_workdir: str = "/tmp/flares_remote", ) -> None: super().__init__() self.session = session self.config = config self.result_queue = result_queue # Kept for backward compatibility with callers that still pass one # in, but nothing is routed here anymore - see class docstring. self.progress_queue = progress_queue if progress_queue is not None else result_queue self.remote_workdir = remote_workdir.rstrip("/") self._channel = None self._client: Optional[paramiko.SSHClient] = None self._sftp = None self._remote_pid: Optional[str] = None self._remote_job_dir: Optional[str] = None self._cancelled = False def cancel(self) -> None: self._cancelled = True # No process group here (setsid was removed - see run()), so this # targets the worker's own PID directly. If it has spawned # multiprocessing children by the time this fires, killing the # parent won't necessarily reap them immediately; SIGTERM to the # main process is still the right first move; escalate manually on # the server if orphans turn out to be a real problem. if self._remote_pid and self._client is not None: try: self._client.exec_command(f"kill -TERM {self._remote_pid}") except Exception: pass if self._channel is not None: try: self._channel.close() except Exception: pass def run(self) -> None: client = None try: self.log_signal.emit(f"Connecting to {self.session.username}@{self.session.host}:{self.session.port}...") client = _connect(self.session) self._client = client sftp = client.open_sftp() self._sftp = sftp job_id = uuid.uuid4().hex[:8] remote_job_dir = f"{self.remote_workdir}/{job_id}" self._remote_job_dir = remote_job_dir self.log_signal.emit(f"Creating remote workdir: {remote_job_dir}") self._mkdir_p(sftp, remote_job_dir) snirf_files = self.config.get("SNIRF_FILES", []) remote_config = dict(self.config) remote_files = [] path_map: dict[str, str] = {} for i, local_path in enumerate(snirf_files, 1): if self._cancelled: return fname = Path(local_path).name remote_path = f"{remote_job_dir}/{fname}" self.log_signal.emit(f"SFTP Upload ({i}/{len(snirf_files)}): {fname}") sftp.put(local_path, remote_path) remote_files.append(remote_path) path_map[remote_path] = local_path remote_config["SNIRF_FILES"] = remote_files # This is what the worker's remap_payload() reads to translate # its own abspaths back to paths the GUI recognizes. remote_config["PATH_MAP"] = path_map self.log_signal.emit("Uploading config.pkl...") with tempfile.NamedTemporaryFile(suffix=".pkl", delete=False) as tmp: pickle.dump(remote_config, tmp) local_config_path = tmp.name remote_config_path = f"{remote_job_dir}/config.pkl" sftp.put(local_config_path, remote_config_path) os.remove(local_config_path) # sftp deliberately stays open (self._sftp) past this point - # result files get fetched through it as they're reported. transport = client.get_transport() channel = transport.open_session() self._channel = channel # $0/$1 keep the actual paths out of the single-quoted script # text, avoiding nested-quoting headaches. No setsid here - # depending on the util-linux version, setsid can fork and let # its immediate parent return almost instantly while the real # work continues detached, which reads to the SSH channel as # "process exited with code 0" right after the PID line, long # before the worker has done anything. exec keeps this shell's # PID as the worker's real PID, which is enough for cancel(). pid_script = 'echo __PID__:$$; exec "$0" "$1"' command = ( f"sh -c {shlex.quote(pid_script)} " f"{shlex.quote(self.session.remote_executable)} {shlex.quote(remote_config_path)}" ) self.log_signal.emit(f"Executing: {command}") channel.exec_command(command) stdout_buf = "" stderr_buf = "" while True: if self._cancelled: break if channel.recv_ready(): chunk = channel.recv(4096).decode("utf-8", errors="replace") stdout_buf += chunk while "\n" in stdout_buf: line, stdout_buf = stdout_buf.split("\n", 1) self._handle_line(line) if channel.recv_stderr_ready(): chunk = channel.recv_stderr(4096).decode("utf-8", errors="replace") stderr_buf += chunk while "\n" in stderr_buf: line, stderr_buf = stderr_buf.split("\n", 1) self.log_signal.emit(f"[Remote Stderr] {line}") if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready(): if stdout_buf: self._handle_line(stdout_buf) if stderr_buf: self.log_signal.emit(f"[Remote Stderr] {stderr_buf}") break time.sleep(0.05) if not self._cancelled: exit_status = channel.recv_exit_status() self.log_signal.emit(f"Remote process completed with exit code: {exit_status}") if exit_status != 0: self.result_queue.put({ "success": False, "error": f"Remote executable exited with code {exit_status}.", }) except Exception as e: self.log_signal.emit(f"Remote execution error: {e}") self.result_queue.put({ "success": False, "error": f"Remote execution failed: {e}", }) finally: # Best-effort cleanup: the uploaded SNIRFs, config.pkl, and any # result files that weren't already removed after download all # live under this one directory, so a single rm -rf covers it. # Runs regardless of how the job ended (success, failure, or # cancel) as long as we still have a connection to use. if self._remote_job_dir and client is not None: try: self.log_signal.emit(f"Cleaning up remote workdir: {self._remote_job_dir}") _, cleanup_stdout, _ = client.exec_command( f"rm -rf {shlex.quote(self._remote_job_dir)}" ) cleanup_stdout.channel.recv_exit_status() # wait so this finishes before we close the connection except Exception as e: self.log_signal.emit(f"Non-fatal: failed to clean up remote workdir: {e}") if self._sftp is not None: try: self._sftp.close() except Exception: pass if client is not None: try: client.close() except Exception as e: self.log_signal.emit(f"Non-fatal error closing SSH connection: {e}") self.log_signal.emit("Worker execution finished.") self.finished.emit() def _handle_line(self, line: str) -> None: clean_line = line.strip() if not clean_line: return if clean_line.startswith("__PID__:"): self._remote_pid = clean_line.split(":", 1)[1].strip() self.log_signal.emit(f"Remote PID: {self._remote_pid} (process group leader)") return if clean_line.startswith("FLARES_B64:"): payload = clean_line[len("FLARES_B64:"):].strip() try: msg = pickle.loads(base64.b64decode(payload)) except Exception as e: self.log_signal.emit(f"Failed to unpickle FLARES_B64 payload: {e}") return self._route_message(msg) return if clean_line.startswith("FLARES_RESULT_FILE:"): payload = clean_line[len("FLARES_RESULT_FILE:"):].strip() try: meta = pickle.loads(base64.b64decode(payload)) except Exception as e: self.log_signal.emit(f"Failed to unpickle result-file metadata: {e}") return self._fetch_and_route_result(meta) return if clean_line.startswith("FLARES_REMOTE_ERROR:"): err_msg = clean_line[len("FLARES_REMOTE_ERROR:"):].strip() self.log_signal.emit(f"[Remote Error] {err_msg}") # This used to only be logged, never reaching the GUI as an # actual message - only the generic exit-code fallback did. self.result_queue.put({"success": False, "error": err_msg}) return self.log_signal.emit(f"[Remote Output] {clean_line}") def _route_message(self, msg) -> None: """Puts a fully-materialized message onto result_queue, in the shape check_for_pipeline_results already expects.""" if isinstance(msg, dict) and msg.get("type") == "progress": self.result_queue.put(("progress", msg.get("file"), msg.get("step"))) return self.result_queue.put(msg) def _fetch_and_route_result(self, meta: dict) -> None: """meta is the message minus its "result" key, plus a "result_path" pointing at the pickle file the worker wrote on the remote box. Downloads it, unpickles it, reattaches it as "result", and routes the completed message like any other.""" remote_result_path = meta.pop("result_path", None) if not remote_result_path or self._sftp is None: self.log_signal.emit("Result-file message missing a path or SFTP session - dropping the result payload.") self._route_message(meta) return local_tmp_path = None try: with tempfile.NamedTemporaryFile(suffix=".pkl", delete=False) as tmp: local_tmp_path = tmp.name self.log_signal.emit(f"SFTP Download: {os.path.basename(remote_result_path)}") self._sftp.get(remote_result_path, local_tmp_path) with open(local_tmp_path, "rb") as f: meta["result"] = pickle.load(f) try: self._sftp.remove(remote_result_path) except Exception: pass # best-effort cleanup, not worth failing the run over except Exception as e: self.log_signal.emit(f"Failed to download result file: {e}") meta["success"] = False meta["error"] = f"Failed to download remote result: {e}" finally: if local_tmp_path and os.path.exists(local_tmp_path): try: os.remove(local_tmp_path) except Exception: pass self._route_message(meta) @staticmethod def _mkdir_p(sftp, remote_dir: str) -> None: parts = remote_dir.strip("/").split("/") path = "" for part in parts: path += "/" + part try: sftp.mkdir(path) except IOError: pass class RemoteExecutionController(QObject): def __init__(self, main_window: QWidget) -> None: super().__init__() self.main_window = main_window self._active = False self._original_clicked_slot = None self._original_text: Optional[str] = None self._thread: Optional[QThread] = None self._worker: Optional[RemoteTaskWorker] = None @property def active(self) -> bool: return self._active def enable(self) -> None: mw = self.main_window if self._active: return if not hasattr(mw, "button1"): raise RuntimeError("Main window has no 'button1' to hook into.") if mw.button1.text() != "Process": raise RuntimeError("Can't switch to remote mode while a task is running.") self._original_text = mw.button1.text() try: mw.button1.clicked.disconnect(mw.on_run_task) self._original_clicked_slot = mw.on_run_task except (TypeError, RuntimeError): self._original_clicked_slot = None mw.button1.clicked.connect(self._on_run_task_remote) mw.button1.setText("Process (Remote)") self._active = True def disable(self) -> None: mw = self.main_window if not self._active: return try: mw.button1.clicked.disconnect(self._on_run_task_remote) except (TypeError, RuntimeError): pass if self._original_clicked_slot is not None: mw.button1.clicked.connect(self._original_clicked_slot) mw.button1.setText(self._original_text or "Process") self._active = False def _on_run_task_remote(self) -> None: mw = self.main_window if not SESSION.is_configured(): QMessageBox.warning(mw, "Not Connected", "Configure and test the SSH connection first.") return if not getattr(mw, "analysis_clearing_bypass", False): if mw.button3.isVisible(): msg = QMessageBox(mw) msg.setWindowTitle("Confirm - FLARES") msg.setText( "Processing new data will clear the current analysis and close all " "other windows. Continue? (If you do not want this dialog box to " "appear, toggle 'Analysis Clearing Bypass' from the Preferences menu.)" ) msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) msg.setDefaultButton(QMessageBox.StandardButton.Cancel) if msg.exec() != QMessageBox.StandardButton.Ok: return mw.button3.setVisible(False) for item in DATA_SCHEMA: setattr(mw, item["key"], {}) for bubble in mw.bubble_widgets.values(): bubble.reset() for widget in QApplication.topLevelWidgets(): if widget is not mw and widget.isVisible(): widget.close() try: mw.button1.clicked.disconnect(self._on_run_task_remote) except (TypeError, RuntimeError): pass mw.button1.setText("Cancel") mw.button1.clicked.connect(self._cancel_remote_task) mw.first_run = False snirf_files: list[str] = [] if getattr(mw, "selected_paths", None): for path in mw.selected_paths: p = Path(path) if p.is_dir(): snirf_files += [str(f) for f in p.glob("*.snirf")] elif p.is_file() and p.suffix == ".snirf": snirf_files.append(str(p)) elif getattr(mw, "selected_path", None): p = Path(mw.selected_path) if p.is_dir(): snirf_files += [str(f) for f in p.glob("*.snirf")] elif p.is_file() and p.suffix == ".snirf": snirf_files.append(str(p)) else: QMessageBox.critical(mw, "Error", "No file(s) selected") self._reset_button_to_idle() return if not snirf_files: QMessageBox.critical(mw, "Error", "No .snirf files found in selection") self._reset_button_to_idle() return mw.files_total = len(snirf_files) mw.files_done = set() mw.files_failed = set() mw.files_results = {} all_params = {} for section_widget in mw.param_sections: all_params.update(section_widget.get_param_values()) if getattr(mw, "folding_bypass", False): all_params["FOLDING_BYP"] = True collected_data = { "SNIRF_FILES": snirf_files, "PARAMS": all_params, "METADATA": mw.get_all_metadata(), } mw.result_queue = Queue() mw.progress_queue = Queue() mw.ack_queue = Queue() mw.result_process = _FakeRemoteProcess() mw.result_process.mark_alive(True) self._thread = QThread() self._worker = RemoteTaskWorker( SESSION, collected_data, result_queue=mw.result_queue, progress_queue=mw.progress_queue, ) self._worker.moveToThread(self._thread) self._worker.log_signal.connect(mw.statusbar.showMessage) self._worker.log_signal.connect(lambda msg: print(f"[SSH Plugin] {msg}")) self._thread.started.connect(self._worker.run) self._worker.finished.connect(self._on_remote_task_finished) self._worker.finished.connect(self._thread.quit) self._worker.finished.connect(self._worker.deleteLater) self._thread.finished.connect(self._thread.deleteLater) self._thread.start() mw.result_timer = QTimer() mw.result_timer.timeout.connect(mw.check_for_pipeline_results) mw.result_timer.start() def _on_remote_task_finished(self) -> None: mw = self.main_window if hasattr(mw, "result_process") and mw.result_process is not None: mw.result_process.mark_alive(False) QTimer.singleShot(300, self._finalize_remote_ui) def _finalize_remote_ui(self) -> None: """On a normal completion, the real FINISHED_SUCCESSFULLY message from flares.gui_entry() now flows through result_queue exactly like a local run, so check_for_pipeline_results (untouched app code) already stopped the timer, revealed button3, and reset button1 - we just need to put button1 back into remote mode, since that handler unconditionally reconnects it to local on_run_task. The one case that handler doesn't cover: a fatal-crash message (success=False with no "type") stops the timer but never resets button1 off "Cancel" - catch that here as a safety net. """ mw = self.main_window if mw.button1.text() == "Cancel": try: mw.button1.clicked.disconnect() except (TypeError, RuntimeError): pass mw.button1.clicked.connect(self._on_run_task_remote) mw.button1.setText("Process (Remote)") else: self._reapply_hijack_if_needed() mw.statusbar.showMessage("Remote execution completed.") def _cancel_remote_task(self) -> None: mw = self.main_window if self._worker is not None: self._worker.cancel() mw.statusbar.showMessage("Cancelling remote task...") self._reset_button_to_idle() def _reset_button_to_idle(self) -> None: mw = self.main_window try: mw.button1.clicked.disconnect() except (TypeError, RuntimeError): pass if self._active: mw.button1.setText("Process (Remote)") mw.button1.clicked.connect(self._on_run_task_remote) else: mw.button1.setText("Process") if self._original_clicked_slot is not None: mw.button1.clicked.connect(self._original_clicked_slot) def _reapply_hijack_if_needed(self) -> None: if not self._active: return mw = self.main_window try: mw.button1.clicked.disconnect(mw.on_run_task) except (TypeError, RuntimeError): pass try: mw.button1.clicked.disconnect(self._on_run_task_remote) except (TypeError, RuntimeError): pass mw.button1.clicked.connect(self._on_run_task_remote) mw.button1.setText("Process (Remote)") class ConnectionTestWorker(QObject): finished = Signal() success = Signal(str) failure = Signal(str) def __init__(self, session: SshSession) -> None: super().__init__() self.session = session def run(self) -> None: try: client = _connect(self.session) try: transport = client.get_transport() banner = transport.remote_version if transport else "connected" self.success.emit(banner) finally: client.close() except Exception as e: self.failure.emit(str(e)) finally: self.finished.emit() class SshRemoteSettingsWidget(QWidget): def __init__(self, controller: Optional[RemoteExecutionController] = None, parent: QWidget | None = None) -> None: super().__init__(parent) self.controller = controller self.setWindowTitle("SSH Remote Processing Settings") self.resize(460, 260) self._thread: QThread | None = None self._worker: QObject | None = None self._setup_ui() self._load_existing_config() def _setup_ui(self) -> None: main_layout = QVBoxLayout(self) conn_box = QGroupBox("Remote Server", self) form = QFormLayout(conn_box) self.txt_host = QLineEdit(self) self.txt_host.setPlaceholderText("e.g. 192.168.1.50 or myserver.example.com") self.spin_port = QSpinBox(self) self.spin_port.setRange(1, 65535) self.spin_port.setValue(22) self.txt_username = QLineEdit(self) self.txt_username.setPlaceholderText("remote username") self.txt_password = QLineEdit(self) self.txt_password.setEchoMode(QLineEdit.EchoMode.Password) self.txt_password.setPlaceholderText("required each app session - never saved to disk") self.txt_remote_executable = QLineEdit(self) self.txt_remote_executable.setPlaceholderText("e.g. /usr/local/bin/flares_worker or /home/user/flares_bin") form.addRow("Host / IP:", self.txt_host) form.addRow("Port:", self.spin_port) form.addRow("Username:", self.txt_username) form.addRow("Password:", self.txt_password) form.addRow("Remote Executable Path:", self.txt_remote_executable) main_layout.addWidget(conn_box) btn_layout = QHBoxLayout() self.btn_apply = QPushButton("Save && Apply", self) self.btn_apply.clicked.connect(self._on_apply) self.btn_test = QPushButton("Test Connection", self) self.btn_test.clicked.connect(self._on_test_connection) self.btn_forget = QPushButton("Forget Password", self) self.btn_forget.clicked.connect(self._on_forget_password) btn_layout.addWidget(self.btn_apply) btn_layout.addWidget(self.btn_test) btn_layout.addWidget(self.btn_forget) main_layout.addLayout(btn_layout) self.chk_remote = QCheckBox("Route processing (button1) through this remote server", self) self.chk_remote.toggled.connect(self._on_toggle_remote) main_layout.addWidget(self.chk_remote) self.lbl_status = QLabel("", self) self.lbl_status.setWordWrap(True) main_layout.addWidget(self.lbl_status) main_layout.addStretch() def _load_existing_config(self) -> None: cfg = load_connection_config() self.txt_host.setText(cfg.get("host", "")) self.spin_port.setValue(int(cfg.get("port", 22))) self.txt_username.setText(cfg.get("username", "")) self.txt_remote_executable.setText(cfg.get("remote_executable", "")) SESSION.remote_executable = cfg.get("remote_executable", "") if self.controller is not None: self.chk_remote.setChecked(self.controller.active) def _on_apply(self) -> None: host = self.txt_host.text().strip() port = self.spin_port.value() username = self.txt_username.text().strip() password = self.txt_password.text() remote_executable = self.txt_remote_executable.text().strip() if not host or not username: QMessageBox.warning(self, "Missing Fields", "Host and username are required.") return if not remote_executable: QMessageBox.warning(self, "Missing Fields", "Remote Executable Path is required.") return if not password and not SESSION.password: QMessageBox.warning(self, "Missing Password", "Enter the password for this session.") return save_connection_config(host, port, username, remote_executable) SESSION.host = host SESSION.port = port SESSION.username = username SESSION.remote_executable = remote_executable if password: SESSION.password = password self.lbl_status.setText(f"Applied settings for {username}@{host}:{port} (password held in memory only).") def _on_forget_password(self) -> None: SESSION.clear_password() self.txt_password.clear() if self.controller is not None and self.controller.active: self.controller.disable() self.chk_remote.setChecked(False) self.lbl_status.setText("Password cleared from memory.") def _on_test_connection(self) -> None: self._on_apply() if not SESSION.is_configured(): return self.btn_test.setEnabled(False) self.lbl_status.setText("Connecting...") self._thread = QThread() self._worker = ConnectionTestWorker(SESSION) self._worker.moveToThread(self._thread) self._thread.started.connect(self._worker.run) self._worker.success.connect(self._on_test_success) self._worker.failure.connect(self._on_test_failure) self._worker.finished.connect(self._thread.quit) self._worker.finished.connect(self._worker.deleteLater) self._thread.finished.connect(self._thread.deleteLater) self._thread.finished.connect(lambda: self.btn_test.setEnabled(True)) self._thread.start() def _on_test_success(self, banner: str) -> None: self.lbl_status.setText(f"Connected successfully. Server: {banner}") def _on_test_failure(self, error: str) -> None: self.lbl_status.setText(f"Connection failed: {error}") QMessageBox.critical(self, "Connection Failed", error) def _on_toggle_remote(self, checked: bool) -> None: if self.controller is None: return if checked: if not SESSION.is_configured(): QMessageBox.warning(self, "Not Connected", "Save settings and test the connection first.") self.chk_remote.setChecked(False) return try: self.controller.enable() self.lbl_status.setText("Remote processing is ON - button1 now runs jobs on the remote server.") except Exception as e: QMessageBox.critical(self, "Couldn't Enable", str(e)) self.chk_remote.setChecked(False) else: self.controller.disable() self.lbl_status.setText("Remote processing is OFF - button1 runs locally again.") class Plugin: def __init__(self, main_window: QWidget) -> None: self.main_window = main_window self.name = "SSH Remote Processing" self.widget_instance: SshRemoteSettingsWidget | None = None self.controller = RemoteExecutionController(main_window) def register_menu(self, plugin_menu: QMenu) -> None: open_action = QAction("Configure SSH Remote...", self.main_window) open_action.triggered.connect(self.show_widget) about_action = QAction("About SSH Remote Processing", self.main_window) about_action.triggered.connect(self.show_about) plugin_menu.addAction(open_action) plugin_menu.addAction(about_action) def show_widget(self) -> None: if self.widget_instance is None or not self.widget_instance.isVisible(): self.widget_instance = SshRemoteSettingsWidget(controller=self.controller) self.widget_instance.show() else: self.widget_instance.raise_() self.widget_instance.activateWindow() def show_about(self) -> None: title = "SSH Remote Processing" version = "0.3" author = "Unknown" description = ( "Runs the app's flares processing job on a remote server using a " "standalone PyInstaller executable over SSH, and streams results " "back - small messages inline, per-participant results via SFTP." ) message = ( f"{title}
" f"Version: {version}  |  Author: {author}

" f"{description}" ) QMessageBox.about(self.main_window, f"About {title}", message)