diff --git a/machine-learning-pack/__init__.py b/machine-learning-pack/__init__.py
index bd201d4..8329ab8 100644
--- a/machine-learning-pack/__init__.py
+++ b/machine-learning-pack/__init__.py
@@ -1,3 +1,11 @@
+"""
+Filename: __init__.py
+Description: machine-learning-pack plugin
+
+Author: Tyler de Zeeuw
+License: GPL-3.0
+"""
+
import base64
import io
import json
diff --git a/machine-learning-pack/plugin.json b/machine-learning-pack/plugin.json
index 2db1284..dcfc823 100644
--- a/machine-learning-pack/plugin.json
+++ b/machine-learning-pack/plugin.json
@@ -6,5 +6,5 @@
"description": "Machine Learning on Processed fNIRS Data.",
"min_app_version": "1.7.0",
"platforms": ["win_x64"],
- "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/machine-learning-pack-win-x64.zip"
+ "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/machine-learning-pack.zip"
}
\ No newline at end of file
diff --git a/packages/machine-learning-pack-win-x64.zip b/packages/machine-learning-pack-win-x64.zip
deleted file mode 100644
index abf3194..0000000
Binary files a/packages/machine-learning-pack-win-x64.zip and /dev/null differ
diff --git a/packages/machine-learning-pack.zip b/packages/machine-learning-pack.zip
new file mode 100644
index 0000000..3cd0644
Binary files /dev/null and b/packages/machine-learning-pack.zip differ
diff --git a/packages/remote-processing-pack.zip b/packages/remote-processing-pack.zip
new file mode 100644
index 0000000..ce9bfed
Binary files /dev/null and b/packages/remote-processing-pack.zip differ
diff --git a/packages/roi-builder-pack.zip b/packages/roi-builder-pack.zip
index d678450..e748edb 100644
Binary files a/packages/roi-builder-pack.zip and b/packages/roi-builder-pack.zip differ
diff --git a/remote-processing-pack/__init__.py b/remote-processing-pack/__init__.py
new file mode 100644
index 0000000..078ed26
--- /dev/null
+++ b/remote-processing-pack/__init__.py
@@ -0,0 +1,869 @@
+"""
+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)
\ No newline at end of file
diff --git a/remote-processing-pack/plugin.json b/remote-processing-pack/plugin.json
new file mode 100644
index 0000000..be5f745
--- /dev/null
+++ b/remote-processing-pack/plugin.json
@@ -0,0 +1,10 @@
+{
+ "id": "remote-processing-pack",
+ "name": "Remote Processing Pack",
+ "version": "0.1.0",
+ "description": "Delegates the processing of files to another device.",
+ "author": "Tyler de Zeeuw",
+ "min_app_version": "1.7.0",
+ "platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"],
+ "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/remote-processing-pack.zip"
+}
\ No newline at end of file
diff --git a/roi-builder-pack/__init__.py b/roi-builder-pack/__init__.py
index 402ca00..13cbec2 100644
--- a/roi-builder-pack/__init__.py
+++ b/roi-builder-pack/__init__.py
@@ -1,5 +1,5 @@
"""
-Filename: plugins.py
+Filename: __init__.py
Description: roi-builder-pack plugin
Author: Tyler de Zeeuw