Created
January 12, 2026 21:06
-
-
Save gingerbeardman/a81df96cd0b4c7a397b04711cafeb287 to your computer and use it in GitHub Desktop.
HTTP Server Control as an menubar app using xbar
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # <xbar.title>HTTP Server Control</xbar.title> | |
| # <xbar.version>3.0.0</xbar.version> | |
| # <xbar.author>Matt Sephton</xbar.author> | |
| # <xbar.author.github>gingerbeardman</xbar.author.github> | |
| # <xbar.desc>Start and stop a Python HTTP servers from a specific directory</xbar.desc> | |
| # <xbar.dependencies>python3</xbar.dependencies> | |
| import os | |
| import subprocess | |
| import sys | |
| import signal | |
| import http.server | |
| import time | |
| import json | |
| import hashlib | |
| # Configuration | |
| CONFIG_FILE = os.path.expanduser("~/Library/Application Support/xbar/plugins/.xbar_httpd_config") | |
| PID_FILE = "/tmp/xbar_httpd_pids.json" # Now stores multiple PIDs | |
| LOG_DIR = "/tmp/xbar_httpd_logs" | |
| DEFAULT_SERVER_DIR = os.path.expanduser("~/Sites") | |
| DEFAULT_SERVER_PORT = 8000 | |
| DEFAULT_ENABLE_LOG = True | |
| def get_path_id(path): | |
| """Generate a short unique ID for a path""" | |
| return hashlib.md5(path.encode()).hexdigest()[:8] | |
| def get_all_server_dirs(): | |
| """Get all SERVER_DIR entries from config""" | |
| if not os.path.exists(CONFIG_FILE): | |
| return [] | |
| dirs = [] | |
| with open(CONFIG_FILE, 'r') as f: | |
| for line in f: | |
| line = line.strip() | |
| # All SERVER_DIR lines are treated equally (no active/commented distinction) | |
| if line.startswith('SERVER_DIR='): | |
| path = os.path.expanduser(line.split('=', 1)[1].strip()) | |
| dirs.append(path) | |
| return dirs | |
| def load_config(): | |
| """Load configuration from file or create default""" | |
| if os.path.exists(CONFIG_FILE): | |
| config = {} | |
| with open(CONFIG_FILE, 'r') as f: | |
| for line in f: | |
| line = line.strip() | |
| if line and not line.startswith('#'): | |
| if '=' in line: | |
| key, value = line.split('=', 1) | |
| config[key.strip()] = value.strip() | |
| base_port = int(config.get('SERVER_PORT', DEFAULT_SERVER_PORT)) | |
| enable_log = config.get('ENABLE_LOG', str(DEFAULT_ENABLE_LOG)).lower() in ('true', '1', 'yes') | |
| else: | |
| # Create default config file | |
| base_port = DEFAULT_SERVER_PORT | |
| enable_log = DEFAULT_ENABLE_LOG | |
| with open(CONFIG_FILE, 'w') as f: | |
| f.write(f"# xbar HTTP Server Configuration\n") | |
| f.write(f"# Multiple SERVER_DIR entries - each gets its own server on sequential ports\n") | |
| f.write(f"# First directory uses SERVER_PORT, second uses SERVER_PORT+1, etc.\n") | |
| f.write(f"SERVER_DIR={DEFAULT_SERVER_DIR}\n") | |
| f.write(f"SERVER_PORT={base_port}\n") | |
| f.write(f"ENABLE_LOG={enable_log}\n") | |
| return base_port, enable_log | |
| BASE_PORT, ENABLE_LOG = load_config() | |
| def load_pids(): | |
| """Load PID mapping from JSON file""" | |
| if not os.path.exists(PID_FILE): | |
| return {} | |
| try: | |
| with open(PID_FILE, 'r') as f: | |
| return json.load(f) | |
| except (json.JSONDecodeError, ValueError): | |
| return {} | |
| def save_pids(pids): | |
| """Save PID mapping to JSON file""" | |
| with open(PID_FILE, 'w') as f: | |
| json.dump(pids, f, indent=2) | |
| def get_server_info(path): | |
| """Get server info (PID, port) for a path, return None if not running""" | |
| pids = load_pids() | |
| path_id = get_path_id(path) | |
| if path_id in pids: | |
| pid = pids[path_id]['pid'] | |
| port = pids[path_id]['port'] | |
| # Verify process is still running | |
| try: | |
| os.kill(pid, 0) | |
| return {'pid': pid, 'port': port} | |
| except OSError: | |
| # Process is dead, clean up | |
| del pids[path_id] | |
| save_pids(pids) | |
| return None | |
| def is_server_running_for_path(path): | |
| """Check if server for specific path is running""" | |
| return get_server_info(path) is not None | |
| def get_assigned_port(path, all_dirs): | |
| """Get the assigned port for a directory based on its index""" | |
| try: | |
| index = all_dirs.index(path) | |
| return BASE_PORT + index | |
| except ValueError: | |
| return BASE_PORT | |
| class NoCacheHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): | |
| """Custom HTTP request handler that disables caching""" | |
| def end_headers(self): | |
| self.send_cache_control_headers() | |
| http.server.SimpleHTTPRequestHandler.end_headers(self) | |
| def send_cache_control_headers(self): | |
| """Send headers to disable caching""" | |
| self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") | |
| self.send_header("Pragma", "no-cache") | |
| self.send_header("Expires", "0") | |
| def start_server_for_folder(folder_path, port): | |
| """Start HTTP server for a specific folder on a specific port""" | |
| if not os.path.exists(folder_path): | |
| print(f"Error: Directory {folder_path} does not exist") | |
| return False | |
| # Check if already running | |
| if is_server_running_for_path(folder_path): | |
| print(f"Server already running for {folder_path}") | |
| return False | |
| path_id = get_path_id(folder_path) | |
| # Create a unique server script for this folder | |
| server_script = f"/tmp/xbar_httpd_server_{path_id}.py" | |
| with open(server_script, 'w') as f: | |
| f.write(f'''#!/usr/bin/env python3 | |
| import http.server | |
| import socketserver | |
| import os | |
| class NoCacheHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): | |
| """Custom HTTP request handler that disables caching""" | |
| def end_headers(self): | |
| self.send_cache_control_headers() | |
| http.server.SimpleHTTPRequestHandler.end_headers(self) | |
| def send_cache_control_headers(self): | |
| """Send headers to disable caching""" | |
| self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") | |
| self.send_header("Pragma", "no-cache") | |
| self.send_header("Expires", "0") | |
| os.chdir("{folder_path}") | |
| PORT = {port} | |
| class ReusableTCPServer(socketserver.TCPServer): | |
| allow_reuse_address = True | |
| with ReusableTCPServer(("", PORT), NoCacheHTTPRequestHandler) as httpd: | |
| print(f"Serving at port {{PORT}} with caching disabled") | |
| httpd.serve_forever() | |
| ''') | |
| # Ensure log directory exists | |
| if ENABLE_LOG and not os.path.exists(LOG_DIR): | |
| os.makedirs(LOG_DIR) | |
| # Determine where to redirect output | |
| if ENABLE_LOG: | |
| log_file_path = os.path.join(LOG_DIR, f"server_{path_id}.log") | |
| log_file = open(log_file_path, 'w') | |
| log_file.write(f"{'='*60}\n") | |
| log_file.write(f"Server started at {subprocess.check_output(['date']).decode().strip()}\n") | |
| log_file.write(f"Port: {port}, Directory: {folder_path}\n") | |
| log_file.write(f"Caching: Disabled\n") | |
| log_file.write(f"{'='*60}\n") | |
| log_file.flush() | |
| stdout_dest = log_file | |
| stderr_dest = log_file | |
| else: | |
| stdout_dest = subprocess.DEVNULL | |
| stderr_dest = subprocess.DEVNULL | |
| # Start server in background | |
| process = subprocess.Popen( | |
| [sys.executable, server_script], | |
| stdout=stdout_dest, | |
| stderr=stderr_dest, | |
| start_new_session=True | |
| ) | |
| # Save PID and port to JSON | |
| pids = load_pids() | |
| pids[path_id] = { | |
| 'pid': process.pid, | |
| 'port': port, | |
| 'path': folder_path | |
| } | |
| save_pids(pids) | |
| print(f"Server started for {folder_path} on port {port}") | |
| return True | |
| def stop_server_for_folder(folder_path): | |
| """Stop HTTP server for a specific folder""" | |
| server_info = get_server_info(folder_path) | |
| if not server_info: | |
| print(f"Server is not running for {folder_path}") | |
| return False | |
| try: | |
| pid = server_info['pid'] | |
| os.kill(pid, signal.SIGTERM) | |
| # Remove from PID file | |
| pids = load_pids() | |
| path_id = get_path_id(folder_path) | |
| if path_id in pids: | |
| del pids[path_id] | |
| save_pids(pids) | |
| print(f"Server stopped for {folder_path}") | |
| return True | |
| except OSError as e: | |
| print(f"Error stopping server: {e}") | |
| # Clean up PID file anyway | |
| pids = load_pids() | |
| path_id = get_path_id(folder_path) | |
| if path_id in pids: | |
| del pids[path_id] | |
| save_pids(pids) | |
| return False | |
| def toggle_server(folder_path, port): | |
| """Toggle server on/off for a folder""" | |
| if is_server_running_for_path(folder_path): | |
| stop_server_for_folder(folder_path) | |
| else: | |
| start_server_for_folder(folder_path, port) | |
| def view_log(folder_path=None): | |
| """Open the log file(s) in the default text editor""" | |
| if folder_path: | |
| path_id = get_path_id(folder_path) | |
| log_file_path = os.path.join(LOG_DIR, f"server_{path_id}.log") | |
| if os.path.exists(log_file_path): | |
| subprocess.run(['open', '-t', log_file_path]) | |
| else: | |
| print(f"Log file does not exist for {folder_path}") | |
| else: | |
| # Open all logs | |
| if os.path.exists(LOG_DIR): | |
| subprocess.run(['open', LOG_DIR]) | |
| else: | |
| print("No logs exist") | |
| def clear_logs(): | |
| """Clear all log files""" | |
| if os.path.exists(LOG_DIR): | |
| for filename in os.listdir(LOG_DIR): | |
| file_path = os.path.join(LOG_DIR, filename) | |
| if os.path.isfile(file_path): | |
| os.remove(file_path) | |
| print("All logs cleared successfully") | |
| else: | |
| print("Log directory does not exist") | |
| def add_server_dir(new_path): | |
| """Add a new server directory to the config file""" | |
| new_path = os.path.expanduser(new_path) | |
| if not os.path.exists(new_path): | |
| print(f"Error: Directory {new_path} does not exist") | |
| return | |
| # Check if path already exists in config | |
| all_dirs = get_all_server_dirs() | |
| if new_path in all_dirs: | |
| print(f"Directory already in config: {new_path}") | |
| return | |
| # Add to config file | |
| with open(CONFIG_FILE, 'a') as f: | |
| f.write(f"SERVER_DIR={new_path}\n") | |
| print(f"Added directory: {new_path}") | |
| def main(): | |
| # Handle commands | |
| if len(sys.argv) > 1: | |
| command = sys.argv[1] | |
| if command == "toggle": | |
| if len(sys.argv) > 3: | |
| folder_path = sys.argv[2] | |
| port = int(sys.argv[3]) | |
| toggle_server(folder_path, port) | |
| elif command == "open": | |
| if len(sys.argv) > 2: | |
| port = int(sys.argv[2]) | |
| subprocess.run(['open', f'http://localhost:{port}']) | |
| elif command == "view_log": | |
| if len(sys.argv) > 2: | |
| folder_path = sys.argv[2] | |
| view_log(folder_path) | |
| else: | |
| view_log() | |
| elif command == "clear_logs": | |
| clear_logs() | |
| elif command == "add_dir": | |
| if len(sys.argv) > 2: | |
| new_path = sys.argv[2] | |
| add_server_dir(new_path) | |
| return | |
| # Display menu | |
| all_dirs = get_all_server_dirs() | |
| # Count running servers | |
| running_count = sum(1 for path in all_dirs if is_server_running_for_path(path)) | |
| # Menu bar icon | |
| if running_count > 0: | |
| print(f"● {running_count}") | |
| else: | |
| print("○") | |
| print("---") | |
| # Show each directory with toggle capability at top level | |
| if len(all_dirs) > 0: | |
| for i, path in enumerate(all_dirs): | |
| port = get_assigned_port(path, all_dirs) | |
| server_info = get_server_info(path) | |
| is_running = server_info is not None | |
| # Check if directory exists | |
| dir_exists = os.path.exists(path) | |
| # Get a nice display name (last component of path) | |
| display_name = os.path.basename(path.rstrip('/')) or path | |
| # Status indicator | |
| if not dir_exists: | |
| status_icon = "⚠️ " | |
| elif is_running: | |
| status_icon = "● " | |
| else: | |
| status_icon = "○ " | |
| # Create toggle menu item (top level) - disable if directory doesn't exist | |
| if dir_exists: | |
| print(f"{status_icon} {display_name} | bash='{sys.executable}' param1='{__file__}' param2='toggle' param3='{path}' param4='{port}' terminal=false refresh=true") | |
| else: | |
| print(f"{status_icon} {display_name}") | |
| # Port number | |
| print(f"--:{port}") | |
| if is_running: | |
| print(f"--Open in Browser… | bash='{sys.executable}' param1='{__file__}' param2='open' param3='{port}' terminal=false") | |
| if ENABLE_LOG: | |
| path_id = get_path_id(path) | |
| log_file_path = os.path.join(LOG_DIR, f"server_{path_id}.log") | |
| if os.path.exists(log_file_path): | |
| log_size = os.path.getsize(log_file_path) | |
| log_size_kb = log_size / 1024 | |
| print(f"--View Log ({log_size_kb:.1f} KB) | bash='{sys.executable}' param1='{__file__}' param2='view_log' param3='{path}' terminal=false") | |
| # Visual separator before path | |
| print("-----") | |
| # Full path with existence indicator | |
| if not dir_exists: | |
| print(f"--{path} | size=10 color=red") | |
| else: | |
| print(f"--{path} | size=10") | |
| else: | |
| print("No directories configured") | |
| print("--Add a directory in config file") | |
| # Log options (only if we have servers and logging enabled) | |
| if ENABLE_LOG and os.path.exists(LOG_DIR) and len(all_dirs) > 0: | |
| print("---") | |
| print(f"View All Logs… | bash='{sys.executable}' param1='{__file__}' param2='view_log' terminal=false") | |
| print(f"Clear All Logs | bash='{sys.executable}' param1='{__file__}' param2='clear_logs' terminal=false refresh=true") | |
| print("---") | |
| # print(f"Base Port: {BASE_PORT}") | |
| print(f"Edit Config… | bash='open' param1='{CONFIG_FILE}' terminal=false") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment