#!/usr/bin/env python3 """ f-around-firefox (faf) - Get information about Firefox's open tabs. Works with Firefox installed via Nix/Home Manager. Usage: faf [method] [rdp_port] faf content [rdp_port] Methods: session, s - Read from Firefox session files (default) rdp, r - Use Remote Debugging Protocol both, b - Try both methods content, c - Get HTML content from a tab via WebSocket """ import json import sys import os import struct import subprocess import asyncio from pathlib import Path from urllib.request import urlopen from urllib.error import URLError try: import websockets WEBSOCKETS_AVAILABLE = True except ImportError: WEBSOCKETS_AVAILABLE = False # Try to find Nix Python with lz4 if available def find_nix_python(): """Try to find a Nix Python with lz4 library.""" # Common Nix store paths nix_store = Path("/nix/store") if not nix_store.exists(): return None # Look for Python with lz4 in the store # This is a heuristic - we look for python* directories that might have lz4 try: result = subprocess.run( ["which", "python3"], capture_output=True, text=True ) python_path = result.stdout.strip() if python_path and "/nix/store" in python_path: # Check if this Python has lz4 result = subprocess.run( [python_path, "-c", "import lz4.frame; print('ok')"], capture_output=True, text=True ) if result.returncode == 0: return python_path except: pass return None # Colors for output RED = '\033[0;31m' GREEN = '\033[0;32m' YELLOW = '\033[1;33m' BLUE = '\033[0;34m' NC = '\033[0m' # No Color def find_firefox_profile(): """Find the default Firefox profile directory.""" if sys.platform == "darwin": profile_dir = Path.home() / "Library/Application Support/Firefox" elif sys.platform.startswith("linux"): profile_dir = Path.home() / ".mozilla/firefox" else: print(f"{RED}Unsupported OS: {sys.platform}{NC}", file=sys.stderr) return None profiles_ini = profile_dir / "profiles.ini" if not profiles_ini.exists(): print(f"{RED}Firefox profiles.ini not found at: {profiles_ini}{NC}", file=sys.stderr) print(f"{YELLOW}Make sure Firefox has been run at least once.{NC}", file=sys.stderr) return None # Find the default profile # Parse profiles.ini by sections profile_path = None current_section = None current_path = None is_default = False with open(profiles_ini, 'r') as f: for line in f: line = line.strip() if line.startswith('[') and line.endswith(']'): # Save previous section if it was default if is_default and current_path: profile_path = current_path break # Start new section current_section = line current_path = None is_default = False elif line.startswith("Path="): current_path = line.split("=", 1)[1].strip() elif line == "Default=1": is_default = True # Check if last section was default if is_default and current_path and not profile_path: profile_path = current_path # Fallback: get the first profile with Path= if not profile_path: with open(profiles_ini, 'r') as f: for line in f: if line.startswith("Path="): profile_path = line.split("=", 1)[1].strip() break if not profile_path: return None if Path(profile_path).is_absolute(): return Path(profile_path) else: return profile_dir / profile_path def decompress_lz4(file_path): """Decompress a Mozilla lz4 file and return the JSON content. Firefox uses a custom format: - 8 bytes: "mozLz40\0" header - 4 bytes: uncompressed size (little-endian uint32) - rest: lz4 frame compressed data """ # Try Python lz4 library first (supports Mozilla format) # First try with current Python try: import lz4.block import struct with open(file_path, 'rb') as f: # Skip the 8-byte Mozilla header header = f.read(8) if header[:7] == b'mozLz40': # Read the 4-byte uncompressed size (little-endian uint32) size_bytes = f.read(4) if len(size_bytes) == 4: uncompressed_size = struct.unpack('{timeout}s)") except json.JSONDecodeError as e: raise Exception(f"Invalid JSON response: {e}") async def get_tabs_via_websocket(port=6000): """Try to get tab list via WebSocket when HTTP endpoint fails.""" if not WEBSOCKETS_AVAILABLE: return None # Try connecting to browser WebSocket endpoint # Firefox typically uses ws://localhost:PORT/devtools/browser/... try: browser_ws_url = f"ws://localhost:{port}/devtools/browser" async with websockets.connect(browser_ws_url, timeout=5) as ws: # Send a request to list targets request = {"id": 1, "method": "Target.getTargets"} await ws.send(json.dumps(request)) response_str = await asyncio.wait_for(ws.recv(), timeout=5) response = json.loads(response_str) if "result" in response and "targetInfos" in response["result"]: # Convert to format similar to /json/list tabs = [] for target in response["result"]["targetInfos"]: if target.get("type") == "page": tabs.append({ "id": target.get("targetId", ""), "title": target.get("title", ""), "url": target.get("url", ""), "webSocketDebuggerUrl": f"ws://localhost:{port}/devtools/page/{target.get('targetId', '')}" }) return tabs except Exception as e: # WebSocket approach failed, return None to fall back to HTTP return None return None async def get_tab_html_async(tab_id, port=6000): """Connect to a tab via WebSocket and retrieve its HTML content.""" if not WEBSOCKETS_AVAILABLE: raise Exception("websockets library not available. Install it with: pip install websockets") # Get the WebSocket URL for this tab websocket_url = get_tab_websocket_url(tab_id, port) # If HTTP endpoint failed, try WebSocket approach if not websocket_url: try: tabs = await get_tabs_via_websocket(port) if tabs: tab_id_str = str(tab_id) for tab in tabs: if str(tab.get('id', '')) == tab_id_str: websocket_url = tab.get('webSocketDebuggerUrl') break except: pass if not websocket_url: # Try to get tabs to provide better error message tabs = None try: tabs = get_tabs_via_rdp(port) except: pass if tabs: tab_id_str = str(tab_id) for tab in tabs: if str(tab.get('id', '')) == tab_id_str: # Tab exists but has no WebSocket URL (not debuggable) raise Exception(f"Tab {tab_id} exists but is not debuggable (e.g., about: pages)") # Tab ID not found in list available_ids = [str(tab.get('id', 'N/A')) for tab in tabs] raise Exception(f"Tab {tab_id} not found. Available tab IDs: {', '.join(available_ids)}") else: # Can't get tab list - HTTP endpoint not working raise Exception(f"Tab {tab_id} not found. Cannot connect to Firefox RDP HTTP endpoint on port {port}. Make sure Firefox is running with remote debugging enabled and the HTTP endpoint is accessible.") try: # Connect to the WebSocket async with websockets.connect(websocket_url, timeout=5) as websocket: # Execute JavaScript to get the HTML html = await execute_javascript(websocket, "document.documentElement.outerHTML") return html except websockets.exceptions.InvalidURI: raise Exception(f"Invalid WebSocket URL: {websocket_url}") except websockets.exceptions.ConnectionClosed: raise Exception(f"WebSocket connection closed unexpectedly") except asyncio.TimeoutError: raise Exception(f"Timeout connecting to tab {tab_id}") except Exception as e: raise Exception(f"Error retrieving HTML from tab {tab_id}: {e}") def get_tab_html(tab_id, port=6000): """Synchronous wrapper for get_tab_html_async.""" try: # Try to get the running event loop loop = asyncio.get_running_loop() # If loop is running, we need to use a different approach # Create a new event loop in a thread import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as executor: future = executor.submit(asyncio.run, get_tab_html_async(tab_id, port)) return future.result() except RuntimeError: # No running loop, create a new one return asyncio.run(get_tab_html_async(tab_id, port)) def print_tabs(tabs_info, method="session"): """Print tab information in a readable format.""" if not tabs_info: return if method == "rdp": for tab in tabs_info: tab_id = tab.get('id', 'N/A') title = tab.get('title', 'N/A') url = tab.get('url', 'N/A') print(f"Tab {tab_id}: {title}") print(f" URL: {url}") else: for tab in tabs_info: window = tab.get('window', '?') tab_num = tab.get('tab', '?') title = tab.get('title', 'N/A') url = tab.get('url', 'N/A') print(f"Window {window}, Tab {tab_num}: {title}") print(f" URL: {url}") def main(): if len(sys.argv) < 2: method = "session" rdp_port = 6000 else: method = sys.argv[1] # Handle content command if method in ["content", "c"]: if len(sys.argv) < 3: print(f"{RED}Error: tab-id required for content command{NC}", file=sys.stderr) print("Usage: faf content [rdp_port]", file=sys.stderr) print("Example: faf content 123", file=sys.stderr) print("Example: faf content 123 9222", file=sys.stderr) sys.exit(1) tab_id = sys.argv[2] rdp_port = int(sys.argv[3]) if len(sys.argv) > 3 else 6000 try: html = get_tab_html(tab_id, rdp_port) # Output raw HTML to stdout (for piping to other tools) print(html) except Exception as e: print(f"{RED}Error: {e}{NC}", file=sys.stderr) sys.exit(1) return # Handle other commands rdp_port = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[1] in ["rdp", "r", "both", "b"] else 6000 if method in ["session", "s"]: print(f"{GREEN}Getting tabs from session files...{NC}") profile_dir = find_firefox_profile() if profile_dir: tabs_info = get_tabs_from_session(profile_dir) if tabs_info: print_tabs(tabs_info, "session") else: sys.exit(1) elif method in ["rdp", "r"]: print(f"{GREEN}Fetching tabs via Remote Debugging Protocol...{NC}") tabs_info = get_tabs_via_rdp(rdp_port) if tabs_info: print_tabs(tabs_info, "rdp") elif method in ["both", "b"]: print(f"{GREEN}=== Session Files Method ==={NC}") profile_dir = find_firefox_profile() if profile_dir: tabs_info = get_tabs_from_session(profile_dir) if tabs_info: print_tabs(tabs_info, "session") print(f"\n{GREEN}=== Remote Debugging Protocol Method ==={NC}") tabs_info = get_tabs_via_rdp(rdp_port) if tabs_info: print_tabs(tabs_info, "rdp") else: print("Usage: faf [method] [rdp_port]") print(" faf content [rdp_port]") print("") print("Methods:") print(" session, s - Read from Firefox session files (default)") print(" rdp, r - Use Remote Debugging Protocol") print(" both, b - Try both methods") print(" content, c - Get HTML content from a tab") print("") print("Examples:") print(" faf # Use session files (default)") print(" faf session # Use session files") print(" faf rdp # Use RDP on default port 6000") print(" faf rdp 9222 # Use RDP on port 9222") print(" faf both # Try both methods") print(" faf content 123 # Get HTML from tab 123 (port 6000)") print(" faf content 123 9222 # Get HTML from tab 123 (port 9222)") sys.exit(1) if __name__ == "__main__": main()