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 @@ + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + R + S + 3 + F + + 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 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + R + S + 3 + F + ! + + 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"]}, )