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/discord_presence.py b/core/discord_presence.py index 0b201c7..bde3d7c 100644 --- a/core/discord_presence.py +++ b/core/discord_presence.py @@ -75,7 +75,7 @@ class DiscordPresence: print("Stopping Discord presence...") self._stop_thread.set() if self._thread and self._thread.is_alive(): - self._thread.join(timeout=5) + self._thread.join(timeout=1) self.running = False self.presence = None diff --git a/core/dukto.py b/core/dukto.py new file mode 100644 index 0000000..6812bd4 --- /dev/null +++ b/core/dukto.py @@ -0,0 +1,507 @@ +#!/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.is_awaiting_approval = False + + self.running = False + + # Confirmation for receiving + self._transfer_decision = threading.Event() + self._transfer_approved = 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_request: 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_start: Optional[Callable[[str], 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: + name = getpass.getuser() + "'s CLARA" + hostname = socket.gethostname() + system = platform.system() + return f"{name} 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 approve_transfer(self): + self._transfer_approved = True + self._transfer_decision.set() + + def reject_transfer(self): + self._transfer_approved = False + self._transfer_decision.set() + + 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..d54acc3 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ 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" @@ -316,10 +317,55 @@ class WebSearchResults(QtWidgets.QDialog): QtWidgets.QApplication.restoreOverrideCursor() +class TextViewerDialog(QtWidgets.QDialog): + def __init__(self, text, parent=None): + super().__init__(parent) + self.setWindowTitle("Text Received") + self.setMinimumSize(400, 300) + + self.text_to_copy = text + layout = QtWidgets.QVBoxLayout(self) + + self.text_edit = QtWidgets.QTextEdit() + self.text_edit.setPlainText(text) + self.text_edit.setReadOnly(True) + layout.addWidget(self.text_edit) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.addStretch() + + copy_button = QtWidgets.QPushButton("Copy to Clipboard") + copy_button.clicked.connect(self.copy_text) + button_layout.addWidget(copy_button) + + close_button = QtWidgets.QPushButton("Close") + close_button.clicked.connect(self.accept) + button_layout.addWidget(close_button) + + layout.addLayout(button_layout) + + def copy_text(self): + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self.text_to_copy) + + class MainWindow(QtWidgets.QMainWindow): show_menu_signal = QtCore.Signal() + + # Dukto signals + peer_added_signal = QtCore.Signal(Peer) + peer_removed_signal = QtCore.Signal(Peer) + receive_request_signal = QtCore.Signal(str) + progress_update_signal = QtCore.Signal(int, int) + receive_start_signal = QtCore.Signal(str) + receive_complete_signal = QtCore.Signal(list, int) + receive_text_signal = QtCore.Signal(str, int) + send_start_signal = QtCore.Signal(str) + send_complete_signal = QtCore.Signal(list) + dukto_error_signal = QtCore.Signal(str) - def __init__(self, restart=False, no_quit=False, super_menu=True): + + def __init__(self, dukto_handler, restart=False, no_quit=False, super_menu=True): super().__init__() flags = ( @@ -344,6 +390,32 @@ class MainWindow(QtWidgets.QMainWindow): self.setMask(mask) self.super_menu = super_menu + self.dukto_handler = dukto_handler + self.progress_dialog = None + + # Connect Dukto callbacks to emit signals + self.dukto_handler.on_peer_added = lambda peer: self.peer_added_signal.emit(peer) + self.dukto_handler.on_peer_removed = lambda peer: self.peer_removed_signal.emit(peer) + self.dukto_handler.on_receive_request = lambda ip: self.receive_request_signal.emit(ip) + self.dukto_handler.on_transfer_progress = lambda total, rec: self.progress_update_signal.emit(total, rec) + self.dukto_handler.on_receive_start = lambda ip: self.receive_start_signal.emit(ip) + self.dukto_handler.on_receive_complete = lambda files, size: self.receive_complete_signal.emit(files, size) + self.dukto_handler.on_receive_text = lambda text, size: self.receive_text_signal.emit(text, size) + self.dukto_handler.on_send_start = lambda ip: self.send_start_signal.emit(ip) + self.dukto_handler.on_send_complete = lambda files: self.send_complete_signal.emit(files) + self.dukto_handler.on_error = lambda msg: self.dukto_error_signal.emit(msg) + + # Connect signals to GUI slots + self.peer_added_signal.connect(self.update_peer_menus) + self.peer_removed_signal.connect(self.update_peer_menus) + self.receive_request_signal.connect(self.show_receive_confirmation) + self.progress_update_signal.connect(self.update_progress_dialog) + self.receive_start_signal.connect(self.handle_receive_start) + self.receive_complete_signal.connect(self.handle_receive_complete) + self.receive_text_signal.connect(self.handle_receive_text) + self.send_start_signal.connect(self.handle_send_start) + self.send_complete_signal.connect(self.handle_send_complete) + self.dukto_error_signal.connect(self.handle_dukto_error) self.tray = QtWidgets.QSystemTrayIcon(self) self.tray.setIcon(QtGui.QIcon(str(ASSET))) @@ -354,6 +426,10 @@ class MainWindow(QtWidgets.QMainWindow): right_menu.addAction("Search Files", self.start_file_search) right_menu.addAction("Search Web", self.start_web_search) right_menu.addSeparator() + send_menu_right = right_menu.addMenu("Send") + self.send_files_submenu_right = send_menu_right.addMenu("Send File(s)") + self.send_text_submenu_right = send_menu_right.addMenu("Send Text") + right_menu.addSeparator() right_menu.addAction("Check for updates", self.update_git) if restart: right_menu.addAction("Restart", self.restart_application) @@ -370,6 +446,12 @@ 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.addSeparator() + send_menu_left = self.left_menu.addMenu("Send") + self.send_files_submenu_left = send_menu_left.addMenu("Send File(s)") + self.send_text_submenu_left = send_menu_left.addMenu("Send Text") + + self.update_peer_menus() # always on top timer self.stay_on_top_timer = QtCore.QTimer(self) @@ -418,6 +500,141 @@ class MainWindow(QtWidgets.QMainWindow): def toggle_visible(self): self.setVisible(not self.isVisible()) + def update_peer_menus(self): + self.send_files_submenu_left.clear() + self.send_text_submenu_left.clear() + self.send_files_submenu_right.clear() + self.send_text_submenu_right.clear() + + peers = list(self.dukto_handler.peers.values()) + + if not peers: + no_peers_action_left_files = self.send_files_submenu_left.addAction("No peers found") + no_peers_action_left_files.setEnabled(False) + no_peers_action_left_text = self.send_text_submenu_left.addAction("No peers found") + no_peers_action_left_text.setEnabled(False) + + no_peers_action_right_files = self.send_files_submenu_right.addAction("No peers found") + no_peers_action_right_files.setEnabled(False) + no_peers_action_right_text = self.send_text_submenu_right.addAction("No peers found") + no_peers_action_right_text.setEnabled(False) + return + + for peer in sorted(peers, key=lambda p: p.signature): + file_action_left = self.send_files_submenu_left.addAction(peer.signature) + file_action_right = self.send_files_submenu_right.addAction(peer.signature) + + text_action_left = self.send_text_submenu_left.addAction(peer.signature) + text_action_right = self.send_text_submenu_right.addAction(peer.signature) + + file_action_left.triggered.connect(lambda checked=False, p=peer: self.start_file_send(p)) + file_action_right.triggered.connect(lambda checked=False, p=peer: self.start_file_send(p)) + + text_action_left.triggered.connect(lambda checked=False, p=peer: self.start_text_send(p)) + text_action_right.triggered.connect(lambda checked=False, p=peer: self.start_text_send(p)) + + def start_file_send(self, peer: Peer): + file_paths, _ = QtWidgets.QFileDialog.getOpenFileNames( + self, + f"Select files to send to {peer.signature}", + str(Path.home()), + ) + if file_paths: + self.dukto_handler.send_file(peer.address, file_paths, peer.port) + + def start_text_send(self, peer: Peer): + text, ok = QtWidgets.QInputDialog.getMultiLineText( + self, + f"Send Text to {peer.signature}", + "Enter text to send:" + ) + if ok and text: + self.dukto_handler.send_text(peer.address, text, peer.port) + + def show_receive_confirmation(self, sender_ip: str): + reply = QtWidgets.QMessageBox.question( + self, + "Incoming Transfer", + f"You have an incoming transfer from {sender_ip}.\nDo you want to accept it?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No + ) + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + self.dukto_handler.approve_transfer() + else: + self.dukto_handler.reject_transfer() + + @QtCore.Slot(str) + def handle_receive_start(self, sender_ip: str): + self.progress_dialog = QtWidgets.QProgressDialog("Receiving data...", "Cancel", 0, 100, self) + self.progress_dialog.setWindowTitle(f"Receiving from {sender_ip}") + self.progress_dialog.setWindowModality(QtCore.Qt.WindowModal) # type: ignore + self.progress_dialog.show() + + @QtCore.Slot(str) + def handle_send_start(self, dest_ip: str): + self.progress_dialog = QtWidgets.QProgressDialog("Sending data...", "Cancel", 0, 100, self) + self.progress_dialog.setWindowTitle(f"Sending to {dest_ip}") + self.progress_dialog.setWindowModality(QtCore.Qt.WindowModal) # type: ignore + self.progress_dialog.show() + + @QtCore.Slot(int, int) + def update_progress_dialog(self, total_size: int, received: int): + if self.progress_dialog: + self.progress_dialog.setMaximum(total_size) + self.progress_dialog.setValue(received) + + @QtCore.Slot(list, int) + def handle_receive_complete(self, received_files: list, total_size: int): + if self.progress_dialog: + self.progress_dialog.setValue(total_size) + self.progress_dialog.close() + self.progress_dialog = None + + QtWidgets.QMessageBox.information(self, "Transfer Complete", f"Successfully received {len(received_files)} items to ~/Received.") + + reply = QtWidgets.QMessageBox.question( + self, + "Open Directory", + "Do you want to open the folder now?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.Yes + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + receive_dir = str(Path.home() / "Received") + url = QtCore.QUrl.fromLocalFile(receive_dir) + QtGui.QDesktopServices.openUrl(url) + + @QtCore.Slot(list) + def handle_send_complete(self, sent_files: list): + if self.progress_dialog: + if self.progress_dialog.maximum() > 0: + self.progress_dialog.setValue(self.progress_dialog.maximum()) + self.progress_dialog.close() + self.progress_dialog = None + + if sent_files and sent_files[0] == "___DUKTO___TEXT___": + QtWidgets.QMessageBox.information(self, "Transfer Complete", "Text sent successfully.") + else: + QtWidgets.QMessageBox.information(self, "Transfer Complete", f"Successfully sent {len(sent_files)} items.") + + @QtCore.Slot(str, int) + def handle_receive_text(self, text: str, total_size: int): + if self.progress_dialog: + self.progress_dialog.close() + self.progress_dialog = None + + dialog = TextViewerDialog(text, self) + dialog.exec() + + @QtCore.Slot(str) + def handle_dukto_error(self, error_msg: str): + if self.progress_dialog: + self.progress_dialog.close() + self.progress_dialog = None + QtWidgets.QMessageBox.critical(self, "Transfer Error", error_msg) + def start_app_launcher(self): self.app_launcher_dialog = AppLauncherDialog(self) self.app_launcher_dialog.move(QtGui.QCursor.pos()) @@ -519,6 +736,7 @@ class MainWindow(QtWidgets.QMainWindow): def restart_application(self): presence.end() + self.dukto_handler.shutdown() args = [sys.executable] + sys.argv @@ -534,9 +752,16 @@ def main(): restart = "--restart" in sys.argv no_quit = "--no-quit" in sys.argv super_menu = not "--no-super" in sys.argv - pet = MainWindow(restart=restart, no_quit=no_quit, super_menu=super_menu) + + dukto_handler = DuktoProtocol() + + pet = MainWindow(dukto_handler=dukto_handler, restart=restart, no_quit=no_quit, super_menu=super_menu) + presence.start() + dukto_handler.initialize() + dukto_handler.say_hello() + # bottom right corner screen_geometry = app.primaryScreen().availableGeometry() pet_geometry = pet.frameGeometry() @@ -547,6 +772,7 @@ def main(): pet.show() app.aboutToQuit.connect(presence.end) + app.aboutToQuit.connect(dukto_handler.shutdown) sys.exit(app.exec())