diff --git a/README.md b/README.md index 93472aa..6752ec1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ An uppercase I looks like a 1, 3 from i3, and 8 because the song [We Are 138](ht - remind: [optional]For reminder notifications, Requires notify-daemon and notify-send for automatic reminders. - sox: for sounds. - transfersh: [optional] for file sharing GUI +- magic-wormhole: [optional] for file sharing with magic-wormhole GUI - udiskie: [optional] for automatically mounting removable storage - x11bell: [optional] Bell support if you do not have a PC speaker. Available from https://github.com/jovanlanik/x11bell - xclip: Clipboard support diff --git a/i38.sh b/i38.sh index ceb217e..38dff81 100755 --- a/i38.sh +++ b/i38.sh @@ -588,6 +588,9 @@ mode "panel" { # Weather information bound to w bindsym w exec --no-startup-id ${i3Path}/scripts/weather.sh, mode "default" + # Magic wormhole bound to shift+W + bindsym Shift+w exec --no-startup-id ${i3Path}/scripts/wormhole.py, mode "default" + # System information bound to s bindsym s exec --no-startup-id ${i3Path}/scripts/sysinfo.sh, mode "default" diff --git a/scripts/wormhole.py b/scripts/wormhole.py new file mode 100755 index 0000000..b775819 --- /dev/null +++ b/scripts/wormhole.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 + +# This file is part of I38. + +# I38 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. + +# I38 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 I38. If not, see . + + +import gi +import os +import subprocess +import threading + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GLib, Pango + +DEFAULT_DOWNLOAD_DIR = os.path.expanduser("~/Downloads") + +class WormholeGUI(Gtk.Window): + def __init__(self): + super().__init__(title="Magic Wormhole GUI") + self.set_border_width(10) + self.set_default_size(500, 400) + + self.download_dir = DEFAULT_DOWNLOAD_DIR + self.notebook = Gtk.Notebook() + self.add(self.notebook) + + self.init_main_tab() + self.init_settings_tab() + + # Escape key closes app + self.connect("key-press-event", self.on_key_press) + + def init_main_tab(self): + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self.notebook.append_page(main_box, Gtk.Label(label="Main")) + + button_box = Gtk.Box(spacing=10) + self.send_button = Gtk.Button(label="Send") + self.send_button.connect("clicked", self.on_send_clicked) + button_box.pack_start(self.send_button, True, True, 0) + + self.receive_button = Gtk.Button(label="Receive") + self.receive_button.connect("clicked", self.on_receive_clicked) + button_box.pack_start(self.receive_button, True, True, 0) + + main_box.pack_start(button_box, False, False, 0) + + # Add a frame for the code display + code_frame = Gtk.Frame(label="Wormhole Code") + main_box.pack_start(code_frame, False, False, 5) + + code_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + code_box.set_border_width(5) + code_frame.add(code_box) + + self.code_display = Gtk.Entry() + self.code_display.set_editable(False) + code_box.pack_start(self.code_display, False, False, 0) + + # Add a frame for progress output + progress_frame = Gtk.Frame(label="Transfer Progress") + main_box.pack_start(progress_frame, True, True, 5) + + # Add a scrolled window for the progress text + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + progress_frame.add(scrolled_window) + + # Add a text view for progress output + self.progress_text = Gtk.TextView() + self.progress_text.set_editable(False) + self.progress_text.set_cursor_visible(False) + self.progress_text.set_wrap_mode(Gtk.WrapMode.WORD) + self.progress_text.override_font(Pango.FontDescription("Monospace 10")) + self.progress_buffer = self.progress_text.get_buffer() + scrolled_window.add(self.progress_text) + + # Add action buttons + action_box = Gtk.Box(spacing=10) + self.copy_button = Gtk.Button(label="Copy Code") + self.copy_button.connect("clicked", self.copy_code) + action_box.pack_start(self.copy_button, True, True, 0) + + self.cancel_button = Gtk.Button(label="Cancel") + self.cancel_button.connect("clicked", self.cancel_transfer) + action_box.pack_start(self.cancel_button, True, True, 0) + + main_box.pack_start(action_box, False, False, 0) + + def init_settings_tab(self): + settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + settings_box.set_border_width(10) + self.notebook.append_page(settings_box, Gtk.Label(label="Settings")) + + # Create a frame for download settings + download_frame = Gtk.Frame(label="Download Location") + settings_box.pack_start(download_frame, False, False, 0) + + # Add a container for the frame content + download_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + download_box.set_border_width(10) + download_frame.add(download_box) + + # Add a description label + description = Gtk.Label(label="Files will be saved to this directory:") + description.set_xalign(0) # Align to left + download_box.pack_start(description, False, False, 0) + + # Add a directory selector + dir_box = Gtk.Box(spacing=5) + download_box.pack_start(dir_box, False, False, 5) + + # Add an entry to show the current path + self.dir_entry = Gtk.Entry() + self.dir_entry.set_text(self.download_dir) + dir_box.pack_start(self.dir_entry, True, True, 0) + + # Add a browse button + browse_button = Gtk.Button(label="Browse...") + browse_button.connect("clicked", self.on_browse_clicked) + dir_box.pack_start(browse_button, False, False, 0) + + # Add a save button + save_button = Gtk.Button(label="Save Settings") + save_button.connect("clicked", self.on_save_settings) + download_box.pack_start(save_button, False, False, 5) + + def on_key_press(self, widget, event): + if event.keyval == Gdk.KEY_Escape: + # Check if a transfer is currently in progress + if hasattr(self, 'current_process') and self.current_process and self.current_process.poll() is None: + # Show a dialog indicating transfer is in progress + dialog = Gtk.MessageDialog( + parent=self, + flags=0, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.OK, + text="Transfer in Progress" + ) + dialog.format_secondary_text("Please wait for the transfer to complete or cancel it before closing.") + dialog.run() + dialog.destroy() + return True + else: + # No transfer in progress, confirm quit + dialog = Gtk.MessageDialog( + parent=self, + flags=0, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text="Quit Application" + ) + dialog.format_secondary_text("Are you sure you want to quit?") + response = dialog.run() + dialog.destroy() + + if response == Gtk.ResponseType.YES: + Gtk.main_quit() + + return True + return False + + def on_browse_clicked(self, button): + """Browse for a directory""" + dialog = Gtk.FileChooserDialog( + title="Select Download Directory", + parent=self, + action=Gtk.FileChooserAction.SELECT_FOLDER, + buttons=( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, Gtk.ResponseType.OK + ) + ) + + # Set the current folder to the current download directory + if os.path.exists(self.download_dir): + dialog.set_current_folder(self.download_dir) + + if dialog.run() == Gtk.ResponseType.OK: + self.dir_entry.set_text(dialog.get_filename()) + + dialog.destroy() + + def on_save_settings(self, button): + """Save the settings""" + new_dir = self.dir_entry.get_text().strip() + + # Handle ~ in path + if new_dir.startswith("~"): + new_dir = os.path.expanduser(new_dir) + + # Validate the directory + if not os.path.isdir(new_dir): + try: + os.makedirs(new_dir, exist_ok=True) + except Exception as e: + self.show_error(f"Could not create directory: {e}") + return + + # Update the directory + self.download_dir = new_dir + + # Show confirmation + self.show_info("Settings saved successfully.") + + def on_download_dir_changed(self, widget): + self.download_dir = widget.get_filename() + + def on_send_clicked(self, widget): + chooser = Gtk.FileChooserDialog( + title="Select File or Folder", parent=self, + action=Gtk.FileChooserAction.OPEN, + buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, Gtk.ResponseType.OK) + ) + chooser.set_select_multiple(False) + chooser.set_local_only(True) + chooser.set_modal(True) + chooser.set_property("show-hidden", False) + chooser.connect("key-press-event", self.on_key_press) + + if chooser.run() == Gtk.ResponseType.OK: + path = chooser.get_filename() + chooser.destroy() + self.send_file(path) + else: + chooser.destroy() + + def send_file(self, path): + self.code_display.set_text("Sending...") + self.clear_progress() + self.update_progress(f"Starting to send: {os.path.basename(path)}\n") + + # Initialize current_process attribute + self.current_process = None + + def send(): + self.current_process = subprocess.Popen( + ["wormhole", "send", path], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + text=True + ) + for line in self.current_process.stdout: + print("SEND OUTPUT:", line.strip()) # Debug info + + # Update the progress display + GLib.idle_add(self.update_progress, line) + + if "Wormhole code is:" in line: + code = line.strip().split(":", 1)[-1].strip() + GLib.idle_add(self.code_display.set_text, code) + self.current_process.stdout.close() + self.current_process.wait() + + # Clear the current_process when done + GLib.idle_add(self.clear_current_process) + + threading.Thread(target=send, daemon=True).start() + + def on_receive_clicked(self, widget): + dialog = Gtk.Dialog( + title="Enter Wormhole Code", + parent=self, + flags=0 + ) + dialog.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OK, Gtk.ResponseType.OK + ) + dialog.connect("key-press-event", self.on_key_press) + + # Make OK button the default + ok_button = dialog.get_widget_for_response(Gtk.ResponseType.OK) + ok_button.set_can_default(True) + ok_button.grab_default() + + entry = Gtk.Entry() + entry.set_activates_default(True) + entry.grab_focus() + + box = dialog.get_content_area() + box.set_border_width(10) + box.set_spacing(10) + box.add(Gtk.Label(label="Enter the wormhole code:")) + box.add(entry) + box.show_all() + + response = dialog.run() + if response == Gtk.ResponseType.OK: + code = entry.get_text() + dialog.destroy() + self.receive_file(code) + else: + dialog.destroy() + + def receive_file(self, code): + self.code_display.set_text("Receiving...") + self.clear_progress() + self.update_progress(f"Starting to receive with code: {code}\n") + + # Initialize current_process attribute + self.current_process = None + + def receive(): + # Save current directory + original_dir = os.getcwd() + + try: + # Create download directory if it doesn't exist + os.makedirs(self.download_dir, exist_ok=True) + + # Change to download directory before starting the process + os.chdir(self.download_dir) + + self.update_progress(f"Downloading to: {self.download_dir}\n") + + # Start the wormhole receive process + self.current_process = subprocess.Popen( + ["wormhole", "receive", "--accept-file"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True + ) + + # Send the code to the process + self.current_process.stdin.write(code + "\n") + self.current_process.stdin.flush() + self.current_process.stdin.close() + + for line in self.current_process.stdout: + print("RECEIVE OUTPUT:", line.strip()) # Debug info + + # Update the progress display + GLib.idle_add(self.update_progress, line) + + # Handle questions about accepting the file + if "ok? (y/N):" in line: + # Auto-accept the file + self.current_process.stdin = open("/dev/stdin", "w") + self.current_process.stdin.write("y\n") + self.current_process.stdin.flush() + self.current_process.stdin.close() + + if "Received file" in line or "File received" in line: + GLib.idle_add(self.code_display.set_text, "File received.") + + self.current_process.stdout.close() + self.current_process.wait() + + # Add final status message + GLib.idle_add(self.update_progress, f"\nFile saved to: {self.download_dir}\n") + + except Exception as e: + GLib.idle_add(self.update_progress, f"Error: {e}\n") + + finally: + # Change back to original directory + os.chdir(original_dir) + + # Clear the current_process when done + GLib.idle_add(self.clear_current_process) + + threading.Thread(target=receive, daemon=True).start() + + def copy_code(self, widget): + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(self.code_display.get_text(), -1) + self.update_progress("Code copied to clipboard.\n") + + def cancel_transfer(self, widget): + if hasattr(self, 'current_process') and self.current_process and self.current_process.poll() is None: + try: + self.current_process.terminate() + self.update_progress("Transfer process terminated.\n") + except Exception as e: + self.update_progress(f"Error canceling transfer: {e}\n") + + self.code_display.set_text("Transfer canceled.") + self.update_progress("Transfer canceled by user.\n") + self.clear_current_process() + + def update_progress(self, text): + """Update the progress text view""" + end = self.progress_buffer.get_end_iter() + self.progress_buffer.insert(end, text) + + # Scroll to the end + self.progress_text.scroll_to_iter(end, 0.0, False, 0.0, 0.0) + + return False # Required for GLib.idle_add + + def clear_progress(self): + """Clear the progress text view""" + self.progress_buffer.set_text("") + + def show_error(self, message): + """Show an error dialog""" + dialog = Gtk.MessageDialog( + parent=self, + flags=0, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text="Error" + ) + dialog.format_secondary_text(message) + dialog.run() + dialog.destroy() + + def show_info(self, message): + """Show an info dialog""" + dialog = Gtk.MessageDialog( + parent=self, + flags=0, + message_type=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.OK, + text="Information" + ) + dialog.format_secondary_text(message) + dialog.run() + dialog.destroy() + + def clear_current_process(self): + """Clear the current process reference""" + self.current_process = None + return False # Required for GLib.idle_add + +def main(): + app = WormholeGUI() + app.connect("destroy", Gtk.main_quit) + app.show_all() + Gtk.main() + +if __name__ == "__main__": + main()