diff --git a/clients/nautilus/fcast_nautilus.py b/clients/nautilus/fcast_nautilus.py new file mode 100644 index 0000000..1cc6cd1 --- /dev/null +++ b/clients/nautilus/fcast_nautilus.py @@ -0,0 +1,302 @@ +import os +import gi +import json +import mimetypes +import socket +import logging +from netifaces import interfaces, ifaddresses, AF_INET +from contextlib import closing +import http.server +import subprocess +import sys +from gi.repository import GLib + +#log_file = os.path.join(os.path.expanduser('~'), 'fcast.log') +#logging.basicConfig(filename=log_file, level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') +logging.info("Logging system initialized.") + +gi.require_version('Nautilus', '3.0') +gi.require_version('Soup', '3.0') +from gi.repository import Nautilus, GObject, Gtk, Gio, Soup + +class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, file_path, *args, **kwargs): + self.file_path = file_path + super().__init__(*args, **kwargs) + + def do_GET(self): + self.send_file(True) + + def do_HEAD(self): + self.send_file(False) + + def send_file(self, send_body): + logging.debug(f"Preparing to send file: {self.file_path}.") + + try: + self.file_size = os.stat(self.file_path).st_size + + # Check for partial download headers. + range_header = self.headers.get('Range', None) + if not range_header: + self.send_response(200) + start_byte = 0 + end_byte = self.file_size - 1 + else: + start_byte, end_byte = self.parse_range_header(range_header) + self.send_response(206) + + # Dynamically determine the media type of the file + media_type, _ = mimetypes.guess_type(self.file_path) + if not media_type: + # If the media type couldn't be determined, default to 'application/octet-stream' + media_type = 'application/octet-stream' + + self.send_header('Content-Type', media_type) + self.send_header('Content-Disposition', f'attachment; filename="{os.path.basename(self.file_path)}"') + self.send_header('Content-Range', f"bytes {start_byte}-{end_byte}/{self.file_size}") + self.send_header('Content-Length', str(end_byte - start_byte + 1)) + self.end_headers() + + if send_body: + # Stream the file + with open(self.file_path, 'rb') as f: + f.seek(start_byte) + self.copy_file_range(f, self.wfile, start_byte, end_byte) + + except Exception as e: + logging.error(f"Error while sending file: {str(e)}") + self.send_error(500, str(e)) + + def parse_range_header(self, range_header): + logging.info(f"Parsing range header: {range_header}.") + + # Expects a HTTP range header string and returns the two numbers as tuple + start_byte, end_byte = range_header.split('=')[1].split('-') + start_byte = int(start_byte.strip()) + end_byte = int(end_byte.strip()) if end_byte else self.file_size - 1 + return start_byte, end_byte + + def copy_file_range(self, input_file, output_file, start=0, end=None): + logging.info(f"Copying file range from {start} to {end}.") + + bytes_to_send = end - start + 1 + buffer_size = 8192 # 8KB buffer + bytes_sent = 0 + while bytes_sent < bytes_to_send: + buffer = input_file.read(min(buffer_size, bytes_to_send - bytes_sent)) + if not buffer: + break # EOF reached + output_file.write(buffer) + bytes_sent += len(buffer) + +def run_http_server(ip, port, path): + try: + # Set the working directory to the directory of the file + os.chdir(os.path.dirname(path)) + + # Start the server + handler = lambda *args, **kwargs: CustomHTTPRequestHandler(path, *args, **kwargs) + httpd = http.server.HTTPServer((ip, port), handler) + logging.info(f"Web server started on port '{ip}:{port}'.") + + # Get the local IP address + logging.info(f"Local IP {ip}.") + + httpd.serve_forever() + except Exception as e: + logging.error(f"Error in HTTP server: {e}") + + logging.info("Stopped HTTP server.") + +class FCastMenuProvider(GObject.GObject, Nautilus.MenuProvider): + def __init__(self): + self.selected_file_path = None + logging.info("Initialized FCastMenuProvider.") + + def get_file_items(self, window, files): + if len(files) != 1: + return + + self.selected_file_path = files[0].get_location().get_path() + item = Nautilus.MenuItem(name="NautilusPython::fcast_item", + label="Cast To", + tip="Cast selected file") + submenu = Nautilus.Menu() + item.set_submenu(submenu) + + hosts = self.load_hosts() + for host in hosts: + host_item = Nautilus.MenuItem(name=f"NautilusPython::fcast_sub_item_{host}", + label=host, + tip=f"Cast to {host}") + host_item.connect('activate', self.cast_to_host, host) + submenu.append_item(host_item) + + add_item = Nautilus.MenuItem(name="NautilusPython::fcast_add_host", + label="Add New Host", + tip="Add a new host") + add_item.connect('activate', self.add_host) + submenu.append_item(add_item) + + return [item] + + def load_hosts(self): + config_path = os.path.join(os.path.expanduser('~'), '.fcast_hosts.json') + logging.info(f"Loading hosts from {config_path}.") + if not os.path.exists(config_path): + logging.warning(f"Config file {config_path} does not exist.") + return [] + + with open(config_path, 'r') as f: + hosts = json.load(f) + + if not isinstance(hosts, list): + logging.error(f"Hosts data is not a list: {hosts}.") + return [] + + logging.info(f"Loaded hosts: {hosts}.") + return hosts + + def save_host(self, host): + logging.debug(f"Saving host: {host}.") + + hosts = self.load_hosts() + if host not in hosts: + hosts.append(host) + config_path = os.path.join(os.path.expanduser('~'), '.fcast_hosts.json') + with open(config_path, 'w') as f: + json.dump(hosts, f) + + def add_host(self, menu_item): + logging.info(f"Adding host.") + + dialog = Gtk.Dialog(title="Add Host", parent=None, flags=0) + host_entry = Gtk.Entry(placeholder_text="Enter Host IP e.g. 192.168.1.1") + port_entry = Gtk.Entry(placeholder_text="Enter Port e.g. 46899") + + box = dialog.get_content_area() + box.add(host_entry) + box.add(port_entry) + + dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK) + dialog.connect('response', self.on_add_host_response, host_entry, port_entry) + dialog.show_all() + + def on_add_host_response(self, dialog, response, host_entry, port_entry): + logging.debug("Received response from add host dialog.") + + if response == Gtk.ResponseType.OK: + host = host_entry.get_text() + port = port_entry.get_text() + if host and port: + self.save_host(f"{host}:{port}") + dialog.destroy() + + def cast_to_host(self, menu_item, host_data): + logging.info(f"Attempting to cast to {host_data} with file {self.selected_file_path}.") + + host, port = host_data.split(":") + mimetype, _ = mimetypes.guess_type(self.selected_file_path) + if not mimetype: + logging.error(f"Could not determine MIME type for file: {self.selected_file_path}") + return + + local_url = self.start_web_server(self.selected_file_path, host) + logging.info(f"Started web server with local URL: {local_url}.") + + def callback(): + self.notify_fcast_server(host, port, local_url, mimetype) + + GLib.timeout_add_seconds(1, callback) + + def get_local_ip_address(self, target_ip): + logging.info(f"Getting local IP address suitable for target IP {target_ip}.") + + # Extract the subnet from the target IP (assuming a typical subnet mask like 255.255.255.0) + subnet = ".".join(target_ip.split('.')[:-1]) + + # Get all the network interfaces and their associated IP addresses + ip_list = [] + for ifaceName in interfaces(): + addresses = [i['addr'] for i in ifaddresses(ifaceName).setdefault(AF_INET, [{'addr':'No IP addr'}])] + ip_list.extend(addresses) + + # Filter IPs based on the subnet + ip_list = [ip for ip in ip_list if ip.startswith(subnet)] + + if len(ip_list) == 1: + return ip_list[0] + elif len(ip_list) > 1: + # If there are multiple IPs, ask the user to choose one + dialog = Gtk.Dialog(title="Select IP Address", parent=None, flags=0) + combo = Gtk.ComboBoxText() + for ip in ip_list: + combo.append_text(ip) + combo.set_active(0) + box = dialog.get_content_area() + box.add(combo) + dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK) + dialog.show_all() + response = dialog.run() + selected_ip = combo.get_active_text() + dialog.destroy() + if response == Gtk.ResponseType.OK: + return selected_ip + else: + logging.error("No valid IP address found!") + return None + + def find_free_port(self): + logging.debug("Finding a free port.") + + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind(('', 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return s.getsockname()[1] + + def start_web_server(self, file_path, target_ip): + logging.info(f"Starting the web server for file {file_path}.") + + local_port = self.find_free_port() + logging.info(f"Found free port {local_port}.") + + local_ip = self.get_local_ip_address(target_ip) + + # Use subprocess to spawn a new process running the server + script_directory = os.path.dirname(os.path.abspath(__file__)) + command = [sys.executable, '-c', 'import sys; sys.path.append("{}"); from fcast_nautilus import run_http_server; run_http_server("{}", {}, "{}")'.format(script_directory, local_ip, local_port, file_path)] + subprocess.Popen(command, start_new_session=True, cwd=script_directory) + + if local_ip: + return f"http://{local_ip}:{local_port}/" + else: + logging.error("Unable to determine local IP address.") + return None + + def notify_fcast_server(self, host, port, url, mimetype): + logging.info(f"Attempting to notify the fcast server at {host}:{port} with URL {url}.") + + # Create the message + message = { + "container": mimetype, + "url": url + } + json_data = json.dumps(message).encode('utf-8') + + # Print JSON + logging.info(f"Send JSON to ({host}:${port}): {json_data}.") + + # Create the header + opcode = 1 # Play opcode + length = len(json_data) + 1 # 1 for opcode + length_bytes = length.to_bytes(4, 'little') + opcode_byte = opcode.to_bytes(1, 'little') + + # Construct the packet + packet = length_bytes + opcode_byte + json_data + + # Send the packet using a TCP socket + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((host, int(port))) + s.sendall(packet) \ No newline at end of file diff --git a/clients/nautilus/install.sh b/clients/nautilus/install.sh new file mode 100644 index 0000000..b444fd1 --- /dev/null +++ b/clients/nautilus/install.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# Define the directory for the Nautilus extension +EXT_DIR="${HOME}/.local/share/nautilus-python/extensions" + +# Create the directory if it doesn't exist +mkdir -p "${EXT_DIR}" + +# Copy the fcast_nautilus.py to the extensions directory +cp fcast_nautilus.py "${EXT_DIR}/fcast_nautilus.py" + +# Restart nautilus +nautilus -q + +echo "Installation complete!" diff --git a/clients/nautilus/reinstall.sh b/clients/nautilus/reinstall.sh new file mode 100644 index 0000000..4b08105 --- /dev/null +++ b/clients/nautilus/reinstall.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +# Call uninstall and then install scripts +sh uninstall.sh +sh install.sh + +echo "Reinstallation complete!" diff --git a/clients/nautilus/uninstall.sh b/clients/nautilus/uninstall.sh new file mode 100644 index 0000000..defe229 --- /dev/null +++ b/clients/nautilus/uninstall.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Define the directory for the Nautilus extension +EXT_DIR="${HOME}/.local/share/nautilus-python/extensions" + +# Remove the fcast_nautilus.py from the extensions directory +rm -f "${EXT_DIR}/fcast_nautilus.py" + +# Restart nautilus +nautilus -q + +echo "Uninstallation complete!"