@@ -0,0 +1,244 @@ |
| 1 | + |
| 2 | +import os |
| 3 | +import sys |
| 4 | +import threading |
| 5 | + |
| 6 | +import paramiko |
| 7 | +import qtawesome as qta |
| 8 | +from dotenv import load_dotenv |
| 9 | + |
| 10 | +from PyQt6.QtCore import Qt, QSize |
| 11 | +from PyQt6.QtWidgets import ( |
| 12 | + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, |
| 13 | + QLineEdit, QToolButton, QPushButton, QListWidget, QFileDialog, QMessageBox, QFormLayout, |
| 14 | +) |
| 15 | + |
| 16 | + |
| 17 | +# fetch default host/port from .env |
| 18 | +load_dotenv() |
| 19 | +DEFAULT_HOST = os.getenv('WULFTP_HOST', '') |
| 20 | +DEFAULT_PORT = int(os.getenv('WULFTP_PORT', '22')) |
| 21 | + |
| 22 | + |
| 23 | +class WulFTPClient(QMainWindow): |
| 24 | + def __init__(self): |
| 25 | + super().__init__() |
| 26 | + self.ssh = None |
| 27 | + self.sftp = None |
| 28 | + self.init_ui() |
| 29 | + |
| 30 | + def init_ui(self): |
| 31 | + """build and arrange ui elements.""" |
| 32 | + self.setWindowTitle('wulFTP POC') |
| 33 | + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint) |
| 34 | + self.setAcceptDrops(False) |
| 35 | + central = QWidget() |
| 36 | + self.setCentralWidget(central) |
| 37 | + |
| 38 | + main_layout = QVBoxLayout() |
| 39 | + main_layout.setContentsMargins(8, 8, 8, 8) |
| 40 | + main_layout.setSpacing(8) |
| 41 | + |
| 42 | + # form for connection fields |
| 43 | + form_layout = QFormLayout() |
| 44 | + form_layout.setHorizontalSpacing(8) |
| 45 | + form_layout.setVerticalSpacing(4) |
| 46 | + |
| 47 | + # connection fields with defaults from env |
| 48 | + self.host_input = QLineEdit(DEFAULT_HOST) |
| 49 | + self.port_input = QLineEdit(str(DEFAULT_PORT)) |
| 50 | + self.user_input = QLineEdit(os.getenv('WULFTP_USER', 'username')) |
| 51 | + self.key_input = QLineEdit(os.getenv('WULFTP_KEY', os.path.expanduser('~/.ssh/id_rsa'))) |
| 52 | + |
| 53 | + # all just for this ico button |
| 54 | + key_browse_btn = QToolButton() |
| 55 | + icon = qta.icon('fa6s.folder-open', color='#ADC8FF') |
| 56 | + key_browse_btn.setIcon(icon) |
| 57 | + key_browse_btn.setIconSize(QSize(16, 16)) |
| 58 | + key_browse_btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) |
| 59 | + key_browse_btn.setAutoRaise(False) |
| 60 | + key_browse_btn.setFixedSize(24, 24) |
| 61 | + key_browse_btn.clicked.connect(self.browse_key) |
| 62 | + |
| 63 | + # for opt passphrase |
| 64 | + self.passphrase_input = QLineEdit() |
| 65 | + self.passphrase_input.setEchoMode(QLineEdit.EchoMode.Password) |
| 66 | + |
| 67 | + connect_btn = QPushButton('connect') |
| 68 | + connect_btn.clicked.connect(self.connect_sftp) |
| 69 | + |
| 70 | + # form layout for connection fields; needs work |
| 71 | + form_layout.addRow('Host:', self.host_input) |
| 72 | + form_layout.addRow('Port:', self.port_input) |
| 73 | + form_layout.addRow('User:', self.user_input) |
| 74 | + |
| 75 | + # right group as a FormLayout |
| 76 | + right_form = QFormLayout() |
| 77 | + right_form.setHorizontalSpacing(8) |
| 78 | + right_form.setVerticalSpacing(4) |
| 79 | + right_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) |
| 80 | + |
| 81 | + # key input with browse ico btn |
| 82 | + key_row = QWidget() |
| 83 | + key_layout = QHBoxLayout(key_row) |
| 84 | + key_layout.setContentsMargins(0, 0, 0, 0) |
| 85 | + key_layout.setSpacing(4) |
| 86 | + key_layout.addWidget(self.key_input) |
| 87 | + key_layout.addWidget(key_browse_btn) |
| 88 | + right_form.addRow('Key:', key_row) |
| 89 | + |
| 90 | + # passphrase row |
| 91 | + pass_widget = QWidget() |
| 92 | + pass_layout = QHBoxLayout(pass_widget) |
| 93 | + pass_layout.setContentsMargins(0, 0, 0, 0) |
| 94 | + pass_layout.addWidget(self.passphrase_input) |
| 95 | + right_form.addRow('Passphrase:', pass_widget) |
| 96 | + |
| 97 | + self.status_icon = QLabel() |
| 98 | + self.status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) |
| 99 | + |
| 100 | + # row for `connect` button/status ico |
| 101 | + connect_widget = QWidget() |
| 102 | + conn_layout = QHBoxLayout(connect_widget) |
| 103 | + conn_layout.setAlignment(Qt.AlignmentFlag.AlignBaseline) |
| 104 | + conn_layout.addWidget(connect_btn) |
| 105 | + conn_layout.addWidget(self.status_icon) |
| 106 | + right_form.addRow('', connect_widget) |
| 107 | + |
| 108 | + # combine connection groups/forms side-by-side |
| 109 | + groups_layout = QHBoxLayout() |
| 110 | + groups_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) |
| 111 | + groups_layout.setSpacing(12) |
| 112 | + groups_layout.addLayout(form_layout, 1) |
| 113 | + groups_layout.addLayout(right_form, 1) |
| 114 | + main_layout.addLayout(groups_layout) |
| 115 | + |
| 116 | + # for the remote preview etc |
| 117 | + remote_layout = QVBoxLayout() |
| 118 | + |
| 119 | + self.browse_file_btn = QPushButton('browse…') |
| 120 | + self.browse_file_btn.clicked.connect(self.browse_and_upload) |
| 121 | + self.browse_file_btn.setEnabled(False) |
| 122 | + remote_layout.addWidget(self.browse_file_btn) |
| 123 | + |
| 124 | + # this needs rethinking? |
| 125 | + self.remote_list = QListWidget() |
| 126 | + self.remote_list.setEnabled(False) |
| 127 | + remote_layout.addWidget(self.remote_list) |
| 128 | + main_layout.addLayout(remote_layout) |
| 129 | + |
| 130 | + central.setLayout(main_layout) |
| 131 | + |
| 132 | + self.update_status_icon(False) |
| 133 | + |
| 134 | + def update_status_icon(self, connected: bool): |
| 135 | + """display the connected/disconnected icon based on state.""" |
| 136 | + if connected: |
| 137 | + icon = qta.icon('fa6s.link', color='#62FF8B') |
| 138 | + else: |
| 139 | + icon = qta.icon('fa6s.link-slash', color='#FF595B') |
| 140 | + |
| 141 | + self.status_icon.setPixmap(icon.pixmap(24, 24)) |
| 142 | + |
| 143 | + def dragEnterEvent(self, event): |
| 144 | + """allow drag-and-drop upload if the event carries file URLs.""" |
| 145 | + if event.mimeData().hasUrls(): |
| 146 | + event.acceptProposedAction() |
| 147 | + |
| 148 | + def dropEvent(self, event): |
| 149 | + """handle dropped files by uploading each to the remote server.""" |
| 150 | + for url in event.mimeData().urls(): |
| 151 | + local_path = url.toLocalFile() |
| 152 | + filename = os.path.basename(local_path) |
| 153 | + remote_file = os.path.join(self.remote_path, filename) |
| 154 | + |
| 155 | + def task(lp=local_path, rf=remote_file): |
| 156 | + try: |
| 157 | + self.sftp.put(lp, rf) |
| 158 | + self.refresh_remote() |
| 159 | + except Exception as e: |
| 160 | + self.show_error(str(e)) |
| 161 | + |
| 162 | + threading.Thread(target=task, daemon=True).start() |
| 163 | + |
| 164 | + event.acceptProposedAction() |
| 165 | + |
| 166 | + def browse_key(self): |
| 167 | + """open a file dialog to select the SSH private key path.""" |
| 168 | + path, _ = QFileDialog.getOpenFileName(self, 'Select Private Key') |
| 169 | + if not path: |
| 170 | + return |
| 171 | + self.key_input.setText(path) |
| 172 | + |
| 173 | + def show_error(self, msg): |
| 174 | + """show an error dialog with the given message.""" |
| 175 | + QMessageBox.critical(self, 'Error', msg) |
| 176 | + |
| 177 | + def connect_sftp(self): |
| 178 | + """connect to the SFTP server using current form values and update UI.""" |
| 179 | + host = self.host_input.text() |
| 180 | + port = int(self.port_input.text()) |
| 181 | + user = self.user_input.text() |
| 182 | + key_path = self.key_input.text() |
| 183 | + passphrase = self.passphrase_input.text() or None |
| 184 | + |
| 185 | + try: |
| 186 | + self.ssh = paramiko.SSHClient() |
| 187 | + self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) |
| 188 | + if passphrase: |
| 189 | + pkey = paramiko.RSAKey.from_private_key_file(key_path, password=passphrase) |
| 190 | + self.ssh.connect(hostname=host, port=port, username=user, pkey=pkey) |
| 191 | + else: |
| 192 | + self.ssh.connect(hostname=host, port=port, username=user, key_filename=key_path) |
| 193 | + self.sftp = self.ssh.open_sftp() |
| 194 | + |
| 195 | + self.update_status_icon(True) |
| 196 | + self.browse_file_btn.setEnabled(True) |
| 197 | + self.remote_list.setEnabled(True) |
| 198 | + self.setAcceptDrops(True) |
| 199 | + self.refresh_remote() |
| 200 | + except Exception as e: |
| 201 | + self.show_error(str(e)) |
| 202 | + |
| 203 | + def refresh_remote(self): |
| 204 | + """refresh the remote file list for the current remote directory.""" |
| 205 | + if self.sftp is None: |
| 206 | + return |
| 207 | + |
| 208 | + try: |
| 209 | + self.remote_path = '.' |
| 210 | + self.remote_list.clear() |
| 211 | + for f in self.sftp.listdir(self.remote_path): |
| 212 | + self.remote_list.addItem(f) |
| 213 | + except Exception as e: |
| 214 | + self.show_error(str(e)) |
| 215 | + |
| 216 | + def browse_and_upload(self): |
| 217 | + """open file dialog and upload the selected file to remote.""" |
| 218 | + |
| 219 | + path, _ = QFileDialog.getOpenFileName(self, 'select file to upload') |
| 220 | + if not path: |
| 221 | + return |
| 222 | + filename = os.path.basename(path) |
| 223 | + remote_file = os.path.join(self.remote_path, filename) |
| 224 | + |
| 225 | + def task(): |
| 226 | + try: |
| 227 | + self.sftp.put(path, remote_file) |
| 228 | + self.refresh_remote() |
| 229 | + except Exception as e: |
| 230 | + self.show_error(str(e)) |
| 231 | + |
| 232 | + threading.Thread(target=task, daemon=True).start() |
| 233 | + |
| 234 | + |
| 235 | +def main(): |
| 236 | + """driver for wulftp.""" |
| 237 | + app = QApplication(sys.argv) |
| 238 | + w = WulFTPClient() |
| 239 | + w.show() |
| 240 | + sys.exit(app.exec()) |
| 241 | + |
| 242 | + |
| 243 | +if __name__ == '__main__': |
| 244 | + main() |