diff --git a/assets/logo_base.svg b/assets/logo_base.svg
new file mode 100644
index 0000000..87702ef
--- /dev/null
+++ b/assets/logo_base.svg
@@ -0,0 +1,140 @@
+
+
diff --git a/assets/logo_error.svg b/assets/logo_error.svg
new file mode 100644
index 0000000..efc8416
--- /dev/null
+++ b/assets/logo_error.svg
@@ -0,0 +1,154 @@
+
+
diff --git a/default.nix b/default.nix
index 469c3f9..512bbbf 100644
--- a/default.nix
+++ b/default.nix
@@ -1,2 +1,2 @@
-{ pkgs ? import {} }:
-pkgs.callPackage ./derivation.nix {}
+{ libsForQt5 ? import {} }:
+libsForQt5.callPackage ./derivation.nix {}
diff --git a/derivation.nix b/derivation.nix
index 9051e46..d0bd28e 100644
--- a/derivation.nix
+++ b/derivation.nix
@@ -1,12 +1,18 @@
-{ lib, pkgs, python38Packages }:
+{ lib, pkgs, python3Packages, qt5 }:
-with python38Packages;
+with python3Packages;
buildPythonApplication rec {
- pname = "rs3fc";
- version = "1.0.7";
+ pname = "rs3fc";
+ version = "1.0.7";
- nativeBuildInputs = [ pkgs.sshfs pkgs.gocryptfs ];
+ src = ./.;
- src = ./.;
+ buildInputs = [pkgs.sshfs pkgs.gettext];
+ propagatedBuildInputs = [ pyqt5 secretstorage ];
+ nativeBuildInputs = [ qt5.wrapQtAppsHook ];
+
+ preFixup = ''
+ makeWrapperArgs+=("''${qtWrapperArgs[@]}")
+ '';
}
diff --git a/rs3f/__init__.py b/rs3f/__init__.py
index d9593f3..6782749 100644
--- a/rs3f/__init__.py
+++ b/rs3f/__init__.py
@@ -19,6 +19,7 @@
import hashlib
import logging
import os
+import re
import socket
import subprocess
from typing import Callable, Optional, Union
@@ -39,6 +40,12 @@
logger = logging.getLogger("rs3f")
logger.setLevel(logging.DEBUG)
+# TODO needs to be more restrictive on the server extraction, but this isn't
+# meant to be foolproof
+RE_VOLUME = re.compile(
+ r"^(?P[a-z_][a-zA-Z0-9_-]{0,31})(@(?P[^:@/]+?)(:(?P\d{1,5}))?)?$"
+)
+
class RS3FRuntimeError(RuntimeError):
"""Base runtime error class for all rs3f errors."""
@@ -161,7 +168,7 @@ def _get_remote_uid(target_user: str, server: str, port: Optional[int]) -> int:
+ (
[
"-P",
- port,
+ str(port),
]
if port is not None
else []
diff --git a/rs3fc/__init__.py b/rs3fc/__init__.py
index 77dc69d..be5661c 100644
--- a/rs3fc/__init__.py
+++ b/rs3fc/__init__.py
@@ -19,21 +19,14 @@
from configparser import ConfigParser
import logging
import os
-import re
import sys
from typing import Optional
-from rs3f import __version__, connect, disconnect, RS3FRuntimeError
+from rs3f import __version__, connect, disconnect, RS3FRuntimeError, RE_VOLUME
from .passwordfetchers import fetch_password, get_default_fetchers_order
-# TODO needs to be more restrictive on the server extraction, but this isn't
-# meant to be foolproof
-RE_VOLUME = re.compile(
- r"^(?P[a-z_][a-zA-Z0-9_-]{0,31})(@(?P[^:@/]+?)(:(?P\d{1,5}))?)?$"
-)
-
VERBOSE_FORMATTER = logging.Formatter(
"%(levelname)s %(filename)s+%(lineno)d %(funcName)s: %(message)s"
)
diff --git a/rs3ftray/__init__.py b/rs3ftray/__init__.py
new file mode 100644
index 0000000..e4eef0c
--- /dev/null
+++ b/rs3ftray/__init__.py
@@ -0,0 +1,315 @@
+import os.path
+import subprocess
+import sys
+from typing import Dict, Optional, Set
+
+from PyQt5.QtCore import QSettings
+from PyQt5.QtGui import QIcon
+from PyQt5.QtWidgets import *
+import secretstorage
+
+from rs3f import connect, disconnect, RE_VOLUME
+
+DEFAULT_MOUNT_PATH = os.getenv("HOME")
+MOUNTED_VOLUMES: Dict[str, os.PathLike] = {}
+
+
+class SettingsWindow(QMainWindow):
+ """The settings window."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.settings = QSettings("corexalys", "rs3ftray", parent=self)
+ self.setWindowTitle("RS3F − Settings")
+ self.setMinimumSize(240, 320)
+ window = QWidget(self)
+ self.setCentralWidget(window)
+ main_box = QHBoxLayout(window)
+
+ left_box = QVBoxLayout()
+ left_box.addWidget(QLabel("Volumes:"))
+ self.list_widget = QListWidget()
+ self.list_widget.setSelectionMode(QListWidget.MultiSelection)
+ for volume in self.settings.value("volumes") or []:
+ self.list_widget.addItem(volume)
+
+ left_box.addWidget(self.list_widget)
+ delete_button = QPushButton("Delete selected volumes")
+ delete_button.clicked.connect(self.delete_selected)
+ left_box.addWidget(delete_button)
+ add_volume_row = QHBoxLayout()
+ self.new_volume_name = QLineEdit()
+ self.new_volume_name.returnPressed.connect(self.new_volume_return_pressed)
+ add_volume_row.addWidget(self.new_volume_name)
+ add_volume_button = QPushButton("Add Volume")
+ add_volume_button.clicked.connect(self.add_volume_clicked)
+ add_volume_row.addWidget(add_volume_button)
+ left_box.addLayout(add_volume_row)
+ main_box.addLayout(left_box)
+
+ right_box = QVBoxLayout()
+ right_box.addWidget(QLabel("Mount path:"))
+ mount_path_row = QHBoxLayout()
+ self.mount_path_edit = QLineEdit(
+ self.settings.value("mount_path") or DEFAULT_MOUNT_PATH
+ )
+ mount_path_row.addWidget(self.mount_path_edit)
+ browse_button = QPushButton("…")
+ browse_button.clicked.connect(self.browse_mount_path)
+ browse_button.setFixedSize(22, 22)
+ mount_path_row.addWidget(browse_button)
+ right_box.addLayout(mount_path_row)
+ save_mount_path_button = QPushButton("Save mount path")
+ save_mount_path_button.clicked.connect(self.save_mount_path)
+ right_box.addWidget(save_mount_path_button)
+ right_box.addSpacing(10)
+
+ right_box.addWidget(QLabel("Default server:"))
+ right_box.addWidget(QLabel("SSH hostname/IP:"))
+ self.default_server_edit = QLineEdit(self.settings.value("server"))
+ right_box.addWidget(self.default_server_edit)
+ right_box.addWidget(QLabel("SSH port:"))
+ self.default_server_port_spin = QSpinBox()
+ self.default_server_port_spin.setRange(0, 65535)
+ self.default_server_port_spin.setValue(int(self.settings.value("port") or 0))
+ right_box.addWidget(self.default_server_port_spin)
+ save_default_server_button = QPushButton("Save default server")
+ save_default_server_button.clicked.connect(self.save_default_server)
+ right_box.addWidget(save_default_server_button)
+ right_box.addStretch(1)
+ main_box.addLayout(right_box)
+
+ window.setLayout(main_box)
+
+ def browse_mount_path(self, _checked: bool) -> None:
+ """Open the directory browser to select a mountpath."""
+ directory = QFileDialog.getExistingDirectory(self)
+
+ # Cancelled
+ if not directory:
+ return
+
+ self.mount_path_edit.setText(directory)
+
+ def save_mount_path(self, _checked: bool) -> None:
+ """Save the mount path."""
+ self.settings.setValue("mount_path", self.mount_path_edit.text())
+
+ def save_default_server(self, _checked: bool) -> None:
+ """Save the default server."""
+ self.settings.setValue("server", self.default_server_edit.text())
+ self.settings.setValue("port", self.default_server_port_spin.value())
+
+ def delete_selected(self, _checked: bool) -> None:
+ """Delete the selected volumes."""
+ volumes = self.settings.value("volumes") or []
+
+ for index in self.list_widget.selectedIndexes()[::-1]:
+ row = self.list_widget.item(index.row())
+ self.list_widget.takeItem(index.row())
+ volumes.remove(row.text())
+ # Disconnect the volume if it was mounted
+ if row.text() in MOUNTED_VOLUMES:
+ disconnect(MOUNTED_VOLUMES.pop(row.text()))
+
+ self.settings.setValue("volumes", volumes)
+ self.parent().reload_volumes()
+
+ def add_volume(self, volume_name: str) -> None:
+ """Add a volume to the saved volume list."""
+ stripped = volume_name.strip()
+
+ error_message: Optional[str] = None
+
+ # Check the volume name is valid
+ if not stripped:
+ error_message = "Empty volume name."
+ elif not RE_VOLUME.match(stripped):
+ error_message = "Invalid volume format, expected 'volume[@server[:port]]'."
+ elif stripped in (self.settings.value("volumes") or []):
+ error_message = "Volume already present."
+
+ # Show an error message and exit if the volume name is invalid
+ if error_message is not None:
+ message_box = QMessageBox(self)
+ message_box.setIcon(QMessageBox.Critical)
+ message_box.setWindowTitle("Invalid volume name")
+ message_box.setText(error_message)
+ message_box.show()
+ return
+
+ # Append to the list
+ self.list_widget.addItem(stripped)
+ volumes = self.settings.value("volumes") or []
+ volumes.append(stripped)
+ self.settings.setValue("volumes", volumes)
+ self.parent().reload_volumes()
+
+ def new_volume_return_pressed(self):
+ """Add the volume."""
+ self.add_volume(self.new_volume_name.text())
+
+ def add_volume_clicked(self, _checked: bool):
+ """Add the volume."""
+ self.add_volume(self.new_volume_name.text())
+
+
+class TrayMenu(QMenu):
+ """The tray menu."""
+
+ def __init__(self, app: QApplication) -> None:
+ super().__init__()
+ self.settings = QSettings("corexalys", "rs3ftray", parent=self)
+
+ self.volume_actions: Set[QAction] = set()
+ self.volume_action_map: Dict[str, QAction] = {}
+
+ settings_window = SettingsWindow(parent=self)
+
+ self.addSection("Volumes")
+ self.volumes_end = self.addSeparator()
+ self.reload_volumes()
+
+ self.addAction("Open all")
+ self.addAction("Close all")
+ settings_action = QAction("Settings", parent=self)
+ settings_action.triggered.connect(settings_window.show)
+ self.addAction(settings_action)
+
+ self.addSeparator()
+ quit_action = QAction("Quit", parent=self)
+ quit_action.triggered.connect(app.quit)
+ self.addAction(quit_action)
+
+ def toggle_mount(self, volume: str, mount: bool):
+ """Mount or unmount the given volume."""
+ # Should never happen
+ if volume in MOUNTED_VOLUMES and mount:
+ raise RuntimeError("Volume is already mounted")
+ # Happens when manually disabling a volume after an error during mount
+ if volume not in MOUNTED_VOLUMES and not mount:
+ return
+
+ if mount:
+ # Extract arguments for connection
+ match = RE_VOLUME.match(volume)
+
+ server = match["server"] or self.settings.value("server")
+ if server is None:
+ message_box = QMessageBox(self)
+ message_box.setIcon(QMessageBox.Critical)
+ message_box.setWindowTitle(f"Cannot mount volume: {volume}")
+ message_box.setText(
+ "No server name is in the volume name, and no default server is configured."
+ )
+ message_box.show()
+ self.volume_action_map[volume].setChecked(False)
+ return
+
+ port = match["port"] or self.settings.value("port")
+ if port:
+ port = int(port)
+ else:
+ port = None
+ mountpoint = os.path.join(
+ os.path.expanduser(self.settings.value("mountpoint") or "~"),
+ match["volume"],
+ )
+
+ # Mount the volume
+ connect(
+ match["volume"],
+ server,
+ mountpoint,
+ lambda: self.fetch_password(volume),
+ port=port,
+ allow_init=False,
+ )
+ MOUNTED_VOLUMES[volume] = mountpoint
+
+ # Open the mounted volume in the file explorer
+ subprocess.run(["xdg-open", mountpoint], check=True)
+
+ else:
+ disconnect(MOUNTED_VOLUMES.pop(volume))
+
+ def fetch_password(self, volume_name: str) -> Optional[str]:
+ """Fetch the password for a given volume name."""
+ # Fetch password from the org.freedeskstop.secret dbus interface
+ connection = secretstorage.dbus_init()
+ collection = secretstorage.collection.get_default_collection(connection)
+ if collection.is_locked():
+ collection.unlock()
+ items = collection.search_items({"Title": volume_name}) # Keepass
+ for item in items:
+ return item.get_secret().decode()
+
+ # Password not found in the keyring, prompt it
+ result, ok_pressed = QInputDialog.getText(
+ self,
+ f"Password for {volume_name} not found",
+ "Please specify the password manually:",
+ QLineEdit.Password,
+ )
+ if not ok_pressed:
+ return None
+
+ # Save the password in the keyring if the user agrees
+ message_box = QMessageBox(self)
+ message_box.setIcon(QMessageBox.Question)
+ message_box.setWindowTitle(f"Save the password for {volume_name}?")
+ message_box.setText(
+ f"Do you want to save the password for {volume_name} in the keyring?"
+ )
+ return_code = message_box.exec()
+ if return_code == QMessageBox.Ok:
+ collection.create_item(volume_name, {"Title": volume_name}, result.encode())
+
+ return result
+
+ def reload_volumes(self):
+ """Remove the volumes from the tray menu and re-add them."""
+ volumes = self.settings.value("volumes") or []
+
+ for action in self.volume_actions:
+ self.removeAction(action)
+
+ self.volume_actions.clear()
+
+ if volumes:
+ for volume in list(volumes):
+ # Needed since otherwise '_' are special characters
+ action = QAction(volume.replace("_", "__"), parent=self)
+ action.toggled.connect(
+ lambda checked, volume=volume: self.toggle_mount(volume, checked)
+ )
+ action.setCheckable(True)
+ self.insertAction(self.volumes_end, action)
+ self.volume_actions.add(action)
+ self.volume_action_map[volume] = action
+ else:
+ action = QAction("No volumes configured", parent=self)
+ action.setEnabled(False)
+ self.insertAction(self.volumes_end, action)
+ self.volume_actions.add(action)
+
+
+class TrayIcon(QSystemTrayIcon):
+ """The tray icon."""
+
+ def __init__(self, app: QApplication):
+ super().__init__()
+ ICON_NORMAL = QIcon(os.path.join(os.path.dirname(__file__), "icon_normal.png"))
+ ICON_ERROR = QIcon(os.path.join(os.path.dirname(__file__), "icon_error.png"))
+
+ self.setIcon(ICON_NORMAL)
+ self.setContextMenu(TrayMenu(app))
+
+
+def main():
+ """The main function."""
+ app = QApplication(sys.argv)
+ app.setQuitOnLastWindowClosed(False)
+ tray = TrayIcon(app)
+ tray.setVisible(True)
+ sys.exit(app.exec_())
diff --git a/rs3ftray/__main__.py b/rs3ftray/__main__.py
new file mode 100644
index 0000000..1c71c43
--- /dev/null
+++ b/rs3ftray/__main__.py
@@ -0,0 +1,22 @@
+# Copyright (C) 2021 Corexalys.
+#
+# This file is part of rs3f.
+#
+# rs3f is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# rs3f is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with rs3f. If not, see .
+
+from . import main
+
+
+if __name__ == "__main__":
+ main()
diff --git a/rs3ftray/icon_error.png b/rs3ftray/icon_error.png
new file mode 100644
index 0000000..95cffe7
Binary files /dev/null and b/rs3ftray/icon_error.png differ
diff --git a/rs3ftray/icon_normal.png b/rs3ftray/icon_normal.png
new file mode 100644
index 0000000..68f5aa4
Binary files /dev/null and b/rs3ftray/icon_normal.png differ
diff --git a/setup.py b/setup.py
index 56844e1..bdd212c 100644
--- a/setup.py
+++ b/setup.py
@@ -20,6 +20,10 @@
setup(
name="rs3f",
version="1.0.7",
- packages=["rs3f", "rs3fc"],
- entry_points={"console_scripts": ["rs3fc = rs3fc:main", "rs3f = rs3fc:main"]},
+ packages=["rs3f", "rs3fc", "rs3ftray"],
+ package_data = {
+ "rs3ftray": ["*.png"],
+ },
+ install_requires=["PyQt5", "secretstorage"],
+ entry_points={"console_scripts": ["rs3fc = rs3fc:main", "rs3f = rs3fc:main", "rs3ftray = rs3ftray:main"]},
)