From c791a92743b2a8f59445439fecc1fe38986b291b Mon Sep 17 00:00:00 2001 From: kge Date: Tue, 21 Dec 2021 16:16:17 +0100 Subject: [PATCH 1/8] Prepare tray implementation --- default.nix | 4 ++-- derivation.nix | 18 ++++++++++++------ rs3ftray/__init__.py | 9 +++++++++ rs3ftray/__main__.py | 22 ++++++++++++++++++++++ setup.py | 5 +++-- 5 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 rs3ftray/__init__.py create mode 100644 rs3ftray/__main__.py 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..412caa3 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 ]; + nativeBuildInputs = [ qt5.wrapQtAppsHook ]; + + preFixup = '' + makeWrapperArgs+=("''${qtWrapperArgs[@]}") + ''; } diff --git a/rs3ftray/__init__.py b/rs3ftray/__init__.py new file mode 100644 index 0000000..47a302e --- /dev/null +++ b/rs3ftray/__init__.py @@ -0,0 +1,9 @@ +import sys +from PyQt5.QtWidgets import QApplication, QWidget + +def main(): + app = QApplication(sys.argv) + window = QWidget() + window.show() + 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/setup.py b/setup.py index 56844e1..614e239 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ setup( name="rs3f", version="1.0.7", - packages=["rs3f", "rs3fc"], - entry_points={"console_scripts": ["rs3fc = rs3fc:main", "rs3f = rs3fc:main"]}, + packages=["rs3f", "rs3fc", "rs3ftray"], + install_requires=["PyQt5"], + entry_points={"console_scripts": ["rs3fc = rs3fc:main", "rs3f = rs3fc:main", "rs3ftray = rs3ftray:main"]}, ) From 424d493477e9cac560b7ad36747d579659fff4c2 Mon Sep 17 00:00:00 2001 From: kge Date: Wed, 22 Dec 2021 16:12:14 +0100 Subject: [PATCH 2/8] Tray: Add code for tray icon and settings window --- assets/logo_base.svg | 140 ++++++++++++++++++++++++++++++++ assets/logo_error.svg | 154 ++++++++++++++++++++++++++++++++++++ rs3ftray/__init__.py | 91 ++++++++++++++++++++- rs3ftray/icon_error.png | Bin 0 -> 10199 bytes rs3ftray/icon_normal.png | Bin 0 -> 9621 bytes rs3ftray/menu_mounted.png | Bin 0 -> 3360 bytes rs3ftray/menu_unmounted.png | Bin 0 -> 827 bytes 7 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 assets/logo_base.svg create mode 100644 assets/logo_error.svg create mode 100644 rs3ftray/icon_error.png create mode 100644 rs3ftray/icon_normal.png create mode 100644 rs3ftray/menu_mounted.png create mode 100644 rs3ftray/menu_unmounted.png 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/rs3ftray/__init__.py b/rs3ftray/__init__.py index 47a302e..f854567 100644 --- a/rs3ftray/__init__.py +++ b/rs3ftray/__init__.py @@ -1,9 +1,92 @@ +import os.path import sys -from PyQt5.QtWidgets import QApplication, QWidget + +from PyQt5.QtCore import QSettings +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import * + +from rs3f import connect, disconnect + +class SettingsWindow(QMainWindow): + """The settings window.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + 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")) + left_box.addWidget(QListView()) + add_volume_row = QHBoxLayout() + add_volume_row.addWidget(QLineEdit()) + add_volume_row.addWidget(QPushButton("Add Volume")) + left_box.addLayout(add_volume_row) + main_box.addLayout(left_box) + + right_box = QVBoxLayout() + right_box.addWidget(QLabel("Autostart")) + right_box.addWidget(QPushButton("Enable Autostart")) + right_box.addWidget(QPushButton("Disable Autostart")) + right_box.addStretch(1) + main_box.addLayout(right_box) + + window.setLayout(main_box) + + +class TrayMenu(QMenu): + """The tray menu.""" + + def __init__(self, app: QApplication) -> None: + super().__init__() + + settings = QSettings("corexalys", "rs3ftray", parent=self) + volumes = settings.value("volumes", [], type=str) + + ICON_MOUNTED = QIcon(os.path.join(os.path.dirname(__file__), "menu_mounted.png")) + ICON_UNMOUNTED = QIcon(os.path.join(os.path.dirname(__file__), "menu_unmounted.png")) + settings_window = SettingsWindow(parent=self) + + self.addSection("Volumes") + if volumes: + for volume in volumes: + action = QAction(volume, parent=self) + action.setCheckable(True) + else: + action = QAction("No volumes configured", parent=self) + action.setEnabled(False) + self.addAction(action) + + self.addSeparator() + 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) + + +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(): app = QApplication(sys.argv) - window = QWidget() - window.show() + app.setQuitOnLastWindowClosed(False) + tray = TrayIcon(app) + tray.setVisible(True) sys.exit(app.exec_()) - diff --git a/rs3ftray/icon_error.png b/rs3ftray/icon_error.png new file mode 100644 index 0000000000000000000000000000000000000000..95cffe7aac73c378b79438165a4ff4f244e414be GIT binary patch literal 10199 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 literal 0 HcmV?d00001 diff --git a/rs3ftray/menu_mounted.png b/rs3ftray/menu_mounted.png new file mode 100644 index 0000000000000000000000000000000000000000..f610db21e3139dd36ff6fb5df13fd13e657deefa GIT binary patch literal 3360 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%0A=;>L(=A> z1q|1)N-jB7V!f3QKsvF! z)#mQv%^M?gIMOd30Y`Rqpc|?Ra5Yh13bzvHQz5kBPq)XfsP%;6ygY=#<8+3nG00*3?Qw4~ht0L!Qi7H{`x zBkaU=>KJ?|u;{;O!ble8!{`QS;e9Gw@+@mg#FIhCZO_q0k$A|q8*xPWBCLh&HFN_; zuq%AC2PTCpmYw|u$VTd0coVsO*Z_yw#O`XsMpmZCz{0>?eu|gV@3_cGQsPY-rfP0O zn7|cWE^jbGH&m;-&uP;ip4aQHU_`4a*RGfHNMqD139AXW=cl=&1j5v?9^^NUW9UCQ upmHT}>2pI1+rZl|5KRI(&u--&;~_$#+Z-B>oA$K01g1 literal 0 HcmV?d00001 diff --git a/rs3ftray/menu_unmounted.png b/rs3ftray/menu_unmounted.png new file mode 100644 index 0000000000000000000000000000000000000000..205e39895dea757a88afc30a7d1824734d47332d GIT binary patch literal 827 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|G$5xb)7d$|)7e>}peR2rGbfdS zL1SX=L~9RbM-kWlz(AFQhZK~=qE}313>5V4brKM93i%MeWA{&ul Date: Wed, 22 Dec 2021 16:17:06 +0100 Subject: [PATCH 3/8] Setup: Bundle rs3ftray assets --- rs3ftray/__init__.py | 10 ++++++++-- setup.py | 3 +++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/rs3ftray/__init__.py b/rs3ftray/__init__.py index f854567..be1a37e 100644 --- a/rs3ftray/__init__.py +++ b/rs3ftray/__init__.py @@ -7,6 +7,7 @@ from rs3f import connect, disconnect + class SettingsWindow(QMainWindow): """The settings window.""" @@ -46,8 +47,12 @@ def __init__(self, app: QApplication) -> None: settings = QSettings("corexalys", "rs3ftray", parent=self) volumes = settings.value("volumes", [], type=str) - ICON_MOUNTED = QIcon(os.path.join(os.path.dirname(__file__), "menu_mounted.png")) - ICON_UNMOUNTED = QIcon(os.path.join(os.path.dirname(__file__), "menu_unmounted.png")) + ICON_MOUNTED = QIcon( + os.path.join(os.path.dirname(__file__), "menu_mounted.png") + ) + ICON_UNMOUNTED = QIcon( + os.path.join(os.path.dirname(__file__), "menu_unmounted.png") + ) settings_window = SettingsWindow(parent=self) self.addSection("Volumes") @@ -84,6 +89,7 @@ def __init__(self, app: QApplication): self.setIcon(ICON_NORMAL) self.setContextMenu(TrayMenu(app)) + def main(): app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) diff --git a/setup.py b/setup.py index 614e239..ffd9db9 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,9 @@ name="rs3f", version="1.0.7", packages=["rs3f", "rs3fc", "rs3ftray"], + package_data = { + "rs3ftray": ["*.png"], + }, install_requires=["PyQt5"], entry_points={"console_scripts": ["rs3fc = rs3fc:main", "rs3f = rs3fc:main", "rs3ftray = rs3ftray:main"]}, ) From 65f3d7579365f86ec7f5f1eb69eba9fe360bf0d5 Mon Sep 17 00:00:00 2001 From: kge Date: Mon, 3 Jan 2022 11:07:27 +0100 Subject: [PATCH 4/8] Tray: Implement volume management --- rs3f/__init__.py | 7 +++ rs3fc/__init__.py | 9 +--- rs3ftray/__init__.py | 107 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 100 insertions(+), 23 deletions(-) diff --git a/rs3f/__init__.py b/rs3f/__init__.py index d9593f3..8aef8f8 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.""" 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 index be1a37e..fbd3776 100644 --- a/rs3ftray/__init__.py +++ b/rs3ftray/__init__.py @@ -1,11 +1,12 @@ import os.path import sys +from typing import Optional, Set from PyQt5.QtCore import QSettings from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import * -from rs3f import connect, disconnect +from rs3f import connect, disconnect, RE_VOLUME class SettingsWindow(QMainWindow): @@ -13,6 +14,7 @@ class SettingsWindow(QMainWindow): 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) @@ -21,10 +23,22 @@ def __init__(self, **kwargs): left_box = QVBoxLayout() left_box.addWidget(QLabel("Volumes")) - left_box.addWidget(QListView()) + self.list_widget = QListWidget() + self.list_widget.setSelectionMode(QListWidget.MultiSelection) + for volume in self.settings.value("volumes", [], type=str): + 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() - add_volume_row.addWidget(QLineEdit()) - add_volume_row.addWidget(QPushButton("Add Volume")) + 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) @@ -37,6 +51,56 @@ def __init__(self, **kwargs): window.setLayout(main_box) + def delete_selected(self, _checked: bool) -> None: + volumes = self.settings.value("volumes", [], type=str) + + 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()) + + # TODO Unmount deleted volumes if already mounted? + + self.settings.setValue("volumes", volumes) + self.parent().reload_volumes() + + def add_volume(self, volume_name: str) -> None: + 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", [], type=str): + 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", [], type=str) + if not volumes: # volumes is a string if empty ??? + volumes = [] + volumes.append(stripped) + self.settings.setValue("volumes", volumes) + self.parent().reload_volumes() + + def new_volume_return_pressed(self): + self.add_volume(self.new_volume_name.text()) + + def add_volume_clicked(self, _checked: bool): + self.add_volume(self.new_volume_name.text()) + class TrayMenu(QMenu): """The tray menu.""" @@ -44,8 +108,7 @@ class TrayMenu(QMenu): def __init__(self, app: QApplication) -> None: super().__init__() - settings = QSettings("corexalys", "rs3ftray", parent=self) - volumes = settings.value("volumes", [], type=str) + self.volume_actions: Set[QAction] = set() ICON_MOUNTED = QIcon( os.path.join(os.path.dirname(__file__), "menu_mounted.png") @@ -56,16 +119,9 @@ def __init__(self, app: QApplication) -> None: settings_window = SettingsWindow(parent=self) self.addSection("Volumes") - if volumes: - for volume in volumes: - action = QAction(volume, parent=self) - action.setCheckable(True) - else: - action = QAction("No volumes configured", parent=self) - action.setEnabled(False) - self.addAction(action) + self.volumes_end = self.addSeparator() + self.reload_volumes() - self.addSeparator() self.addAction("Open all") self.addAction("Close all") settings_action = QAction("Settings", parent=self) @@ -77,6 +133,27 @@ def __init__(self, app: QApplication) -> None: quit_action.triggered.connect(app.quit) self.addAction(quit_action) + def reload_volumes(self): + settings = QSettings("corexalys", "rs3ftray", parent=self) + volumes = settings.value("volumes", [], type=str) + + for action in self.volume_actions: + self.removeAction(action) + + self.volume_actions.clear() + + if volumes: + for volume in volumes: + action = QAction(volume.replace("_", "__"), parent=self) + action.setCheckable(True) + self.insertAction(self.volumes_end, action) + self.volume_actions.add(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.""" From f6b77d66df62d3a3cbb821f638c757b7f92d605f Mon Sep 17 00:00:00 2001 From: kge Date: Mon, 3 Jan 2022 12:17:22 +0100 Subject: [PATCH 5/8] Tray: Remove unneeded icons --- rs3ftray/__init__.py | 6 ------ rs3ftray/menu_mounted.png | Bin 3360 -> 0 bytes rs3ftray/menu_unmounted.png | Bin 827 -> 0 bytes 3 files changed, 6 deletions(-) delete mode 100644 rs3ftray/menu_mounted.png delete mode 100644 rs3ftray/menu_unmounted.png diff --git a/rs3ftray/__init__.py b/rs3ftray/__init__.py index fbd3776..6769b54 100644 --- a/rs3ftray/__init__.py +++ b/rs3ftray/__init__.py @@ -110,12 +110,6 @@ def __init__(self, app: QApplication) -> None: self.volume_actions: Set[QAction] = set() - ICON_MOUNTED = QIcon( - os.path.join(os.path.dirname(__file__), "menu_mounted.png") - ) - ICON_UNMOUNTED = QIcon( - os.path.join(os.path.dirname(__file__), "menu_unmounted.png") - ) settings_window = SettingsWindow(parent=self) self.addSection("Volumes") diff --git a/rs3ftray/menu_mounted.png b/rs3ftray/menu_mounted.png deleted file mode 100644 index f610db21e3139dd36ff6fb5df13fd13e657deefa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3360 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%0A=;>L(=A> z1q|1)N-jB7V!f3QKsvF! z)#mQv%^M?gIMOd30Y`Rqpc|?Ra5Yh13bzvHQz5kBPq)XfsP%;6ygY=#<8+3nG00*3?Qw4~ht0L!Qi7H{`x zBkaU=>KJ?|u;{;O!ble8!{`QS;e9Gw@+@mg#FIhCZO_q0k$A|q8*xPWBCLh&HFN_; zuq%AC2PTCpmYw|u$VTd0coVsO*Z_yw#O`XsMpmZCz{0>?eu|gV@3_cGQsPY-rfP0O zn7|cWE^jbGH&m;-&uP;ip4aQHU_`4a*RGfHNMqD139AXW=cl=&1j5v?9^^NUW9UCQ upmHT}>2pI1+rZl|5KRI(&u--&;~_$#+Z-B>oA$K01g1 diff --git a/rs3ftray/menu_unmounted.png b/rs3ftray/menu_unmounted.png deleted file mode 100644 index 205e39895dea757a88afc30a7d1824734d47332d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 827 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|G$5xb)7d$|)7e>}peR2rGbfdS zL1SX=L~9RbM-kWlz(AFQhZK~=qE}313>5V4brKM93i%MeWA{&ul Date: Mon, 3 Jan 2022 15:57:17 +0100 Subject: [PATCH 6/8] Tray: Add GUI to configure mount path --- rs3ftray/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/rs3ftray/__init__.py b/rs3ftray/__init__.py index 6769b54..5ede172 100644 --- a/rs3ftray/__init__.py +++ b/rs3ftray/__init__.py @@ -43,6 +43,16 @@ def __init__(self, **kwargs): main_box.addLayout(left_box) right_box = QVBoxLayout() + right_box.addWidget(QLabel("Mount path")) + mount_path_row = QHBoxLayout() + mount_path_row.addWidget( + QLineEdit(self.settings.value("mount_path", "~", type=str)) + ) + browse_button = QPushButton("…") + browse_button.setFixedSize(22, 22) + mount_path_row.addWidget(browse_button) + right_box.addLayout(mount_path_row) + right_box.addWidget(QPushButton("Save mount path")) right_box.addWidget(QLabel("Autostart")) right_box.addWidget(QPushButton("Enable Autostart")) right_box.addWidget(QPushButton("Disable Autostart")) From b7faef43b579f647bafaab22db254fe6f221a0bd Mon Sep 17 00:00:00 2001 From: kge Date: Tue, 4 Jan 2022 10:16:01 +0100 Subject: [PATCH 7/8] Tray: Implement volume mount/unmount --- rs3f/__init__.py | 2 +- rs3ftray/__init__.py | 142 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 123 insertions(+), 21 deletions(-) diff --git a/rs3f/__init__.py b/rs3f/__init__.py index 8aef8f8..6782749 100644 --- a/rs3f/__init__.py +++ b/rs3f/__init__.py @@ -168,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/rs3ftray/__init__.py b/rs3ftray/__init__.py index 5ede172..87cecb2 100644 --- a/rs3ftray/__init__.py +++ b/rs3ftray/__init__.py @@ -1,6 +1,7 @@ import os.path +import subprocess import sys -from typing import Optional, Set +from typing import Dict, Optional, Set from PyQt5.QtCore import QSettings from PyQt5.QtGui import QIcon @@ -8,6 +9,9 @@ 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.""" @@ -22,10 +26,10 @@ def __init__(self, **kwargs): main_box = QHBoxLayout(window) left_box = QVBoxLayout() - left_box.addWidget(QLabel("Volumes")) + left_box.addWidget(QLabel("Volumes:")) self.list_widget = QListWidget() self.list_widget.setSelectionMode(QListWidget.MultiSelection) - for volume in self.settings.value("volumes", [], type=str): + for volume in self.settings.value("volumes") or []: self.list_widget.addItem(volume) left_box.addWidget(self.list_widget) @@ -43,33 +47,65 @@ def __init__(self, **kwargs): main_box.addLayout(left_box) right_box = QVBoxLayout() - right_box.addWidget(QLabel("Mount path")) + right_box.addWidget(QLabel("Mount path:")) mount_path_row = QHBoxLayout() - mount_path_row.addWidget( - QLineEdit(self.settings.value("mount_path", "~", type=str)) + 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) - right_box.addWidget(QPushButton("Save mount path")) - right_box.addWidget(QLabel("Autostart")) - right_box.addWidget(QPushButton("Enable Autostart")) - right_box.addWidget(QPushButton("Disable Autostart")) + 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: + directory = QFileDialog.getExistingDirectory(self) + + # Cancelled + if not directory: + return + + self.mount_path_edit.setText(directory) + + def save_mount_path(self, _checked: bool) -> None: + self.settings.setValue("mount_path", self.mount_path_edit.text()) + + def save_default_server(self, _checked: bool) -> None: + 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: - volumes = self.settings.value("volumes", [], type=str) + 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()) - - # TODO Unmount deleted volumes if already mounted? + # 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() @@ -84,7 +120,7 @@ def add_volume(self, volume_name: str) -> None: 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", [], type=str): + 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 @@ -98,9 +134,7 @@ def add_volume(self, volume_name: str) -> None: # Append to the list self.list_widget.addItem(stripped) - volumes = self.settings.value("volumes", [], type=str) - if not volumes: # volumes is a string if empty ??? - volumes = [] + volumes = self.settings.value("volumes") or [] volumes.append(stripped) self.settings.setValue("volumes", volumes) self.parent().reload_volumes() @@ -117,8 +151,10 @@ class TrayMenu(QMenu): 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) @@ -137,9 +173,71 @@ def __init__(self, app: QApplication) -> None: quit_action.triggered.connect(app.quit) self.addAction(quit_action) + def toggle_mount(self, volume: str, mount: bool): + # 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) -> str: + # TODO Fetch using pass or keepass (dbus service for keepass?) + result, ok_pressed = QInputDialog.getText( + self, + f"Password for {volume_name}", + "Password:", + QLineEdit.PasswordEchoOnEdit, + ) + if not ok_pressed: + return "" + return result + def reload_volumes(self): - settings = QSettings("corexalys", "rs3ftray", parent=self) - volumes = settings.value("volumes", [], type=str) + volumes = self.settings.value("volumes") or [] for action in self.volume_actions: self.removeAction(action) @@ -147,11 +245,15 @@ def reload_volumes(self): self.volume_actions.clear() if volumes: - for volume in volumes: + for volume in list(volumes): 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) From 7faa922cd84acc9e21536d412f77bd0b2aac86ef Mon Sep 17 00:00:00 2001 From: kge Date: Tue, 4 Jan 2022 16:03:41 +0100 Subject: [PATCH 8/8] Tray: Implement password fetching using the secrets service --- derivation.nix | 2 +- rs3ftray/__init__.py | 46 ++++++++++++++++++++++++++++++++++++++------ setup.py | 2 +- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/derivation.nix b/derivation.nix index 412caa3..d0bd28e 100644 --- a/derivation.nix +++ b/derivation.nix @@ -9,7 +9,7 @@ buildPythonApplication rec { src = ./.; buildInputs = [pkgs.sshfs pkgs.gettext]; - propagatedBuildInputs = [ pyqt5 ]; + propagatedBuildInputs = [ pyqt5 secretstorage ]; nativeBuildInputs = [ qt5.wrapQtAppsHook ]; preFixup = '' diff --git a/rs3ftray/__init__.py b/rs3ftray/__init__.py index 87cecb2..e4eef0c 100644 --- a/rs3ftray/__init__.py +++ b/rs3ftray/__init__.py @@ -6,6 +6,7 @@ from PyQt5.QtCore import QSettings from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import * +import secretstorage from rs3f import connect, disconnect, RE_VOLUME @@ -81,6 +82,7 @@ def __init__(self, **kwargs): 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 @@ -90,13 +92,16 @@ def browse_mount_path(self, _checked: bool) -> None: 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]: @@ -111,6 +116,7 @@ def delete_selected(self, _checked: bool) -> None: 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 @@ -140,9 +146,11 @@ def add_volume(self, volume_name: str) -> None: 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()) @@ -174,6 +182,7 @@ def __init__(self, app: QApplication) -> None: 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") @@ -224,19 +233,42 @@ def toggle_mount(self, volume: str, mount: bool): else: disconnect(MOUNTED_VOLUMES.pop(volume)) - def fetch_password(self, volume_name: str) -> str: - # TODO Fetch using pass or keepass (dbus service for keepass?) + 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}", - "Password:", - QLineEdit.PasswordEchoOnEdit, + f"Password for {volume_name} not found", + "Please specify the password manually:", + QLineEdit.Password, ) if not ok_pressed: - return "" + 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: @@ -246,6 +278,7 @@ def reload_volumes(self): 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) @@ -274,6 +307,7 @@ def __init__(self, app: QApplication): def main(): + """The main function.""" app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) tray = TrayIcon(app) diff --git a/setup.py b/setup.py index ffd9db9..bdd212c 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,6 @@ package_data = { "rs3ftray": ["*.png"], }, - install_requires=["PyQt5"], + install_requires=["PyQt5", "secretstorage"], entry_points={"console_scripts": ["rs3fc = rs3fc:main", "rs3f = rs3fc:main", "rs3ftray = rs3ftray:main"]}, )