diff --git a/SUPERCOPY.py b/SUPERCOPY.py index 1ddc516..a8f29b1 100644 --- a/SUPERCOPY.py +++ b/SUPERCOPY.py @@ -1,6 +1,7 @@ files = [ "core/app_launcher.py", "core/discord_presence.py", + "core/dukto.py", "core/file_search.py", "core/headers.py", "core/updater.py", diff --git a/core/dukto.py b/core/dukto.py new file mode 100644 index 0000000..37d24eb --- /dev/null +++ b/core/dukto.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 + +import socket +import struct +import threading +import os +import platform +import getpass +from pathlib import Path +from typing import List, Optional, Callable, Dict + +DEFAULT_UDP_PORT = 4644 +DEFAULT_TCP_PORT = 4644 + + +class Peer: + def __init__(self, address: str, signature: str, port: int = DEFAULT_UDP_PORT): + self.address = address + self.signature = signature + self.port = port + + def __repr__(self): + return f"Peer({self.address}, {self.signature}, port={self.port})" + + +class DuktoProtocol: + def __init__(self): + self.local_udp_port = DEFAULT_UDP_PORT + self.local_tcp_port = DEFAULT_TCP_PORT + + self.udp_socket: Optional[socket.socket] = None + self.tcp_server: Optional[socket.socket] = None + + self.peers: Dict[str, Peer] = {} + self.is_sending = False + self.is_receiving = False + + self.running = False + + # Callbacks + self.on_peer_added: Optional[Callable[[Peer], None]] = None + self.on_peer_removed: Optional[Callable[[Peer], None]] = None + self.on_receive_start: Optional[Callable[[str], None]] = None + self.on_receive_complete: Optional[Callable[[List[str], int], None]] = None + self.on_receive_text: Optional[Callable[[str, int], None]] = None + self.on_send_complete: Optional[Callable[[List[str]], None]] = None + self.on_transfer_progress: Optional[Callable[[int, int], None]] = None + self.on_error: Optional[Callable[[str], None]] = None + + def set_ports(self, udp_port: int, tcp_port: int): + self.local_udp_port = udp_port + self.local_tcp_port = tcp_port + + def get_system_signature(self) -> str: + username = getpass.getuser() + hostname = socket.gethostname() + system = platform.system() + return f"{username} at {hostname} ({system})" + + def initialize(self): + # UDP Socket for peer discovery + self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + self.udp_socket.bind(('', self.local_udp_port)) + + # TCP Server for receiving files + self.tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.tcp_server.bind(('', self.local_tcp_port)) + self.tcp_server.listen(5) + + self.running = True + + # Start listener threads + threading.Thread(target=self._udp_listener, daemon=True).start() + threading.Thread(target=self._tcp_listener, daemon=True).start() + + def say_hello(self, dest: str = '', port: int = None): + if port is None: + port = self.local_udp_port + + # Prepare packet + if port == DEFAULT_UDP_PORT and self.local_udp_port == DEFAULT_UDP_PORT: + if dest == '': + msg_type = b'\x01' # HELLO MESSAGE (broadcast) + else: + msg_type = b'\x02' # HELLO MESSAGE (unicast) + packet = msg_type + self.get_system_signature().encode('utf-8') + else: + if dest == '': + msg_type = b'\x04' # HELLO MESSAGE (broadcast) with PORT + else: + msg_type = b'\x05' # HELLO MESSAGE (unicast) with PORT + packet = msg_type + struct.pack('': + self._send_to_all_broadcast(packet, port) + if port != DEFAULT_UDP_PORT: + self._send_to_all_broadcast(packet, DEFAULT_UDP_PORT) + else: + self.udp_socket.sendto(packet, (dest, port)) + + def say_goodbye(self): + packet = b'\x03' + b'Bye Bye' + + # Collect all discovered ports + ports = {self.local_udp_port} + if self.local_udp_port != DEFAULT_UDP_PORT: + ports.add(DEFAULT_UDP_PORT) + for peer in self.peers.values(): + ports.add(peer.port) + + # Send to all ports + for port in ports: + self._send_to_all_broadcast(packet, port) + + def send_file(self, ip_dest: str, files: List[str], port: int = 0): + if port == 0: + port = DEFAULT_TCP_PORT + + if self.is_receiving or self.is_sending: + if self.on_error: + self.on_error("Already busy with another transfer") + return + + self.is_sending = True + threading.Thread(target=self._send_file_thread, + args=(ip_dest, port, files), daemon=True).start() + + def send_text(self, ip_dest: str, text: str, port: int = 0): + if port == 0: + port = DEFAULT_TCP_PORT + + if self.is_receiving or self.is_sending: + if self.on_error: + self.on_error("Already busy with another transfer") + return + + self.is_sending = True + threading.Thread(target=self._send_text_thread, + args=(ip_dest, port, text), daemon=True).start() + + def _udp_listener(self): + while self.running: + try: + data, addr = self.udp_socket.recvfrom(4096) + self._handle_message(data, addr[0]) + except Exception as e: + if self.running: + print(f"UDP listener error: {e}") + + def _handle_message(self, data: bytes, sender: str): + if len(data) == 0: + return + + msg_type = data[0] + + if msg_type in (0x01, 0x02): # HELLO (broadcast/unicast) + signature = data[1:].decode('utf-8', errors='ignore') + if signature != self.get_system_signature(): + self.peers[sender] = Peer(sender, signature, DEFAULT_UDP_PORT) + if msg_type == 0x01: # Reply to broadcast + self.say_hello(sender, DEFAULT_UDP_PORT) + if self.on_peer_added: + self.on_peer_added(self.peers[sender]) + + elif msg_type == 0x03: # GOODBYE + if sender in self.peers: + peer = self.peers[sender] + if self.on_peer_removed: + self.on_peer_removed(peer) + del self.peers[sender] + + elif msg_type in (0x04, 0x05): # HELLO with PORT + port = struct.unpack(' List[str]: + expanded = [] + + for filepath in files: + path = Path(filepath) + if path.is_dir(): + self._add_recursive(expanded, path) + else: + expanded.append(str(path)) + + return expanded + + def _add_recursive(self, expanded: List[str], path: Path): + expanded.append(str(path)) + + if path.is_dir(): + for entry in path.iterdir(): + self._add_recursive(expanded, entry) + + def _compute_total_size(self, files: List[str]) -> int: + total = 0 + for filepath in files: + path = Path(filepath) + if path.is_file(): + total += path.stat().st_size + return total + + def _send_to_all_broadcast(self, packet: bytes, port: int): + # Try common broadcast + try: + self.udp_socket.sendto(packet, ('255.255.255.255', port)) + except: + pass + + def shutdown(self): + self.running = False + self.say_goodbye() + + if self.udp_socket: + self.udp_socket.close() + if self.tcp_server: + self.tcp_server.close() \ No newline at end of file diff --git a/main.py b/main.py index 6234f30..feeec6c 100644 --- a/main.py +++ b/main.py @@ -9,9 +9,144 @@ from core.web_search import MullvadLetaWrapper from core.discord_presence import presence from core.app_launcher import list_apps, launch from core.updater import update_repository, is_update_available +from core.dukto import DuktoProtocol, Peer ASSET = Path(__file__).parent / "assets" / "2ktan.png" +class DuktoDialog(QtWidgets.QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("LAN Transfer (Dukto)") + self.setMinimumSize(600, 400) + + # Dukto Protocol Backend + self.protocol = DuktoProtocol() + self.setup_callbacks() + + # UI Elements + self.peer_list_widget = QtWidgets.QListWidget() + self.send_file_button = QtWidgets.QPushButton("Send File(s)") + self.send_text_button = QtWidgets.QPushButton("Send Text") + self.refresh_button = QtWidgets.QPushButton("Refresh") + self.progress_bar = QtWidgets.QProgressBar() + + # Layout + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel("Discovered Peers:")) + layout.addWidget(self.peer_list_widget) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.addWidget(self.refresh_button) + button_layout.addStretch() + button_layout.addWidget(self.send_text_button) + button_layout.addWidget(self.send_file_button) + layout.addLayout(button_layout) + layout.addWidget(self.progress_bar) + + self.progress_bar.hide() + + # Connect signals + self.refresh_button.clicked.connect(self.refresh_peers) + self.send_file_button.clicked.connect(self.send_files) + self.send_text_button.clicked.connect(self.send_text) + + # Initialize Dukto + self.protocol.initialize() + self.refresh_peers() + + def setup_callbacks(self): + self.protocol.on_peer_added = self.add_peer + self.protocol.on_peer_removed = self.remove_peer + self.protocol.on_receive_start = lambda ip: self.show_progress() + self.protocol.on_transfer_progress = self.update_progress + self.protocol.on_receive_text = self.handle_received_text + self.protocol.on_receive_complete = self.handle_receive_complete + self.protocol.on_send_complete = lambda files: self.progress_bar.hide() + self.protocol.on_error = self.handle_error + + @QtCore.Slot(Peer) + def add_peer(self, peer: Peer): + # Check if peer already exists + for i in range(self.peer_list_widget.count()): + item = self.peer_list_widget.item(i) + if item.data(QtCore.Qt.UserRole).address == peer.address: # type: ignore + return + + item = QtWidgets.QListWidgetItem(f"{peer.signature} ({peer.address})") + item.setData(QtCore.Qt.UserRole, peer) # type: ignore + self.peer_list_widget.addItem(item) + + @QtCore.Slot(Peer) + def remove_peer(self, peer: Peer): + for i in range(self.peer_list_widget.count()): + item = self.peer_list_widget.item(i) + if item and item.data(QtCore.Qt.UserRole).address == peer.address: # type: ignore + self.peer_list_widget.takeItem(i) + break + + def refresh_peers(self): + self.peer_list_widget.clear() + self.protocol.peers.clear() + self.protocol.say_hello() + + def get_selected_peer(self) -> Peer | None: + selected_items = self.peer_list_widget.selectedItems() + if not selected_items: + QtWidgets.QMessageBox.warning(self, "No Peer Selected", "Please select a peer from the list.") + return None + return selected_items[0].data(QtCore.Qt.UserRole) # type: ignore + + def send_files(self): + peer = self.get_selected_peer() + if not peer: + return + + files, _ = QtWidgets.QFileDialog.getOpenFileNames(self, "Select Files to Send") + if files: + self.show_progress() + self.protocol.send_file(peer.address, files, peer.port) + + def send_text(self): + peer = self.get_selected_peer() + if not peer: + return + + text, ok = QtWidgets.QInputDialog.getMultiLineText(self, "Send Text", "Enter text to send:") + if ok and text: + self.show_progress() + self.protocol.send_text(peer.address, text, peer.port) + + def show_progress(self): + self.progress_bar.setValue(0) + self.progress_bar.show() + + @QtCore.Slot(int, int) + def update_progress(self, total_size, transferred): + if total_size > 0: + percentage = int((transferred / total_size) * 100) + self.progress_bar.setValue(percentage) + + @QtCore.Slot(str, int) + def handle_received_text(self, text, size): + self.progress_bar.hide() + QtWidgets.QMessageBox.information(self, "Text Received", text) + + @QtCore.Slot(list, int) + def handle_receive_complete(self, files, size): + self.progress_bar.hide() + msg = f"Received {len(files)} file(s) successfully.\nThey are located in the application's directory." + QtWidgets.QMessageBox.information(self, "Transfer Complete", msg) + + @QtCore.Slot(str) + def handle_error(self, error_message): + self.progress_bar.hide() + QtWidgets.QMessageBox.critical(self, "Transfer Error", error_message) + + def closeEvent(self, event): + self.protocol.shutdown() + super().closeEvent(event) + + class AppLauncherDialog(QtWidgets.QDialog): def __init__(self, parent=None): super().__init__(parent) @@ -353,6 +488,7 @@ class MainWindow(QtWidgets.QMainWindow): right_menu.addAction("Launch App", self.start_app_launcher) right_menu.addAction("Search Files", self.start_file_search) right_menu.addAction("Search Web", self.start_web_search) + right_menu.addAction("LAN Transfer (Dukto)", self.start_dukto) right_menu.addSeparator() right_menu.addAction("Check for updates", self.update_git) if restart: @@ -370,6 +506,7 @@ class MainWindow(QtWidgets.QMainWindow): self.left_menu.addAction("Launch App", self.start_app_launcher) self.left_menu.addAction("Search Files", self.start_file_search) self.left_menu.addAction("Search Web", self.start_web_search) + self.left_menu.addAction("LAN Transfer (Dukto)", self.start_dukto) # always on top timer self.stay_on_top_timer = QtCore.QTimer(self) @@ -423,6 +560,11 @@ class MainWindow(QtWidgets.QMainWindow): self.app_launcher_dialog.move(QtGui.QCursor.pos()) self.app_launcher_dialog.show() + def start_dukto(self): + self.dukto_dialog = DuktoDialog(self) + self.dukto_dialog.move(QtGui.QCursor.pos()) + self.dukto_dialog.show() + def start_file_search(self): dialog = QtWidgets.QInputDialog(self) dialog.setWindowTitle("File Search")