tenseleyflow/wulftp / b38af7d

Browse files

proof of concept

Authored by espadonne
SHA
b38af7dc0fa8302d9f4d5a60f79fe87babf51dd1
Parents
c520a74
Tree
690df8c

1 changed file

StatusFile+-
A wulftp/wulftp.py 244 0
wulftp/wulftp.pyadded
@@ -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()