486 lines
17 KiB
Python
Raw Normal View History

2025-07-17 00:51:02 +02:00
import gi
gi.require_version('Gtk', '3.0')
webkit_loaded = False
webkit_version = None
for version in ['4.1', '4.0', '3.0']:
try:
gi.require_version('WebKit2', version)
webkit_loaded = True
webkit_version = version
break
except ValueError:
continue
if not webkit_loaded:
print("Error: WebKit2 is not installed or available.")
print("Please install it using:")
print(" Ubuntu/Debian: sudo apt-get install gir1.2-webkit2-4.0")
print(" Fedora: sudo dnf install webkit2gtk3")
print(" Arch: sudo pacman -S webkit2gtk")
exit(1)
from gi.repository import Gtk, WebKit2, GLib, Gdk
print(f"Successfully loaded WebKit2 version {webkit_version}")
import asyncio
import websockets
import json
import threading
import uuid
from typing import Dict, Optional, Callable, Any
import time
import base64
class RemoteBrowser:
"""
A WebKit2GTK browser instance that can be controlled via WebSocket.
Each connection gets its own browser window.
"""
def __init__(self, connection_id: str, websocket, server):
self.connection_id = connection_id
self.websocket = websocket
self.server = server
self.window = None
self.webview = None
self.pending_callbacks = {}
self.is_closing = False
def create_browser(self):
"""Create the browser window in the GTK thread."""
self.window = Gtk.Window(title=f"Remote Browser - {self.connection_id[:8]}")
self.window.set_default_size(1024, 768)
self.window.connect("destroy", self.on_window_destroy)
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.window.add(main_box)
nav_bar = self.create_navigation_bar()
main_box.pack_start(nav_bar, False, False, 0)
self.webview = WebKit2.WebView()
self.setup_webview()
scrolled_window = Gtk.ScrolledWindow()
scrolled_window.add(self.webview)
main_box.pack_start(scrolled_window, True, True, 0)
self.status_bar = Gtk.Statusbar()
self.status_bar.push(0, f"Connected: {self.connection_id[:8]}")
main_box.pack_start(self.status_bar, False, False, 0)
self.window.show_all()
def create_navigation_bar(self):
"""Create a simple navigation bar."""
nav_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
nav_bar.set_margin_top(5)
nav_bar.set_margin_bottom(5)
nav_bar.set_margin_left(5)
nav_bar.set_margin_right(5)
self.url_label = Gtk.Label()
self.url_label.set_ellipsize(True)
self.url_label.set_max_width_chars(50)
nav_bar.pack_start(self.url_label, True, True, 0)
return nav_bar
def setup_webview(self):
"""Configure WebKit view settings."""
settings = self.webview.get_settings()
settings.set_enable_developer_extras(True)
settings.set_enable_javascript(True)
settings.set_allow_file_access_from_file_urls(True)
self.webview.connect("load-changed", self.on_load_changed)
self.webview.connect("notify::uri", self.on_uri_changed)
self.webview.connect("load-failed", self.on_load_failed)
def on_window_destroy(self, widget):
"""Handle window close event."""
if not self.is_closing:
self.is_closing = True
asyncio.create_task(self.close())
def on_load_changed(self, webview, load_event):
"""Handle page load events."""
if load_event == WebKit2.LoadEvent.FINISHED:
asyncio.create_task(self.send_event("load_finished", {
"url": webview.get_uri(),
"title": webview.get_title()
}))
elif load_event == WebKit2.LoadEvent.STARTED:
asyncio.create_task(self.send_event("load_started", {
"url": webview.get_uri()
}))
def on_uri_changed(self, webview, param):
"""Update URL label when URI changes."""
uri = webview.get_uri()
if uri:
self.url_label.set_text(uri)
def on_load_failed(self, webview, load_event, failing_uri, error):
"""Handle load failures."""
asyncio.create_task(self.send_event("load_failed", {
"url": failing_uri,
"error": error.message if error else "Unknown error"
}))
async def send_event(self, event_type: str, data: dict):
"""Send an event to the WebSocket client."""
try:
await self.websocket.send(json.dumps({
"type": "event",
"event": event_type,
"data": data
}))
except:
pass
async def send_response(self, request_id: str, result: Any, error: Optional[str] = None):
"""Send a response to a command."""
try:
await self.websocket.send(json.dumps({
"type": "response",
"request_id": request_id,
"result": result,
"error": error
}))
except:
pass
def execute_javascript(self, script: str, request_id: str):
"""Execute JavaScript and send result back via WebSocket."""
def js_finished(webview, task, user_data):
try:
result = webview.run_javascript_finish(task)
js_result = result.get_js_value()
if js_result.is_string():
value = js_result.to_string()
elif js_result.is_number():
value = js_result.to_double()
elif js_result.is_boolean():
value = js_result.to_boolean()
elif js_result.is_object():
self.webview.run_javascript(
f"JSON.stringify({script})",
None,
lambda wv, t, ud: self._handle_json_result(wv, t, request_id)
)
return
else:
value = None
asyncio.create_task(self.send_response(request_id, value))
except Exception as e:
asyncio.create_task(self.send_response(request_id, None, str(e)))
self.webview.run_javascript(script, None, js_finished, None)
def _handle_json_result(self, webview, task, request_id):
"""Handle JSON stringified JavaScript results."""
try:
result = webview.run_javascript_finish(task)
js_result = result.get_js_value()
json_str = js_result.to_string()
value = json.loads(json_str) if json_str else None
asyncio.create_task(self.send_response(request_id, value))
except Exception as e:
asyncio.create_task(self.send_response(request_id, None, str(e)))
async def handle_command(self, command: dict):
"""Handle commands from WebSocket client."""
cmd_type = command.get("command")
request_id = command.get("request_id", str(uuid.uuid4()))
try:
if cmd_type == "navigate":
url = command.get("url")
GLib.idle_add(self.webview.load_uri, url)
await self.send_response(request_id, {"status": "navigating", "url": url})
elif cmd_type == "execute_js":
script = command.get("script")
GLib.idle_add(self.execute_javascript, script, request_id)
elif cmd_type == "go_back":
GLib.idle_add(self.webview.go_back)
await self.send_response(request_id, {"status": "ok"})
elif cmd_type == "go_forward":
GLib.idle_add(self.webview.go_forward)
await self.send_response(request_id, {"status": "ok"})
elif cmd_type == "reload":
GLib.idle_add(self.webview.reload)
await self.send_response(request_id, {"status": "reloading"})
elif cmd_type == "stop":
GLib.idle_add(self.webview.stop_loading)
await self.send_response(request_id, {"status": "stopped"})
elif cmd_type == "get_info":
def get_browser_info():
info = {
"url": self.webview.get_uri(),
"title": self.webview.get_title(),
"can_go_back": self.webview.can_go_back(),
"can_go_forward": self.webview.can_go_forward(),
"is_loading": self.webview.is_loading()
}
asyncio.create_task(self.send_response(request_id, info))
GLib.idle_add(get_browser_info)
elif cmd_type == "screenshot":
def take_screenshot():
def on_snapshot(webview, task, data):
try:
surface = webview.get_snapshot_finish(task)
import io
import cairo
buf = io.BytesIO()
surface.write_to_png(buf)
buf.seek(0)
screenshot_b64 = base64.b64encode(buf.read()).decode('utf-8')
asyncio.create_task(self.send_response(request_id, {
"screenshot": screenshot_b64,
"format": "png"
}))
except Exception as e:
asyncio.create_task(self.send_response(request_id, None, str(e)))
self.webview.get_snapshot(
WebKit2.SnapshotRegion.FULL_DOCUMENT,
WebKit2.SnapshotOptions.NONE,
None,
on_snapshot,
None
)
GLib.idle_add(take_screenshot)
elif cmd_type == "set_html":
html = command.get("html", "")
base_uri = command.get("base_uri")
GLib.idle_add(self.webview.load_html, html, base_uri)
await self.send_response(request_id, {"status": "html_loaded"})
else:
await self.send_response(request_id, None, f"Unknown command: {cmd_type}")
except Exception as e:
await self.send_response(request_id, None, str(e))
async def close(self):
"""Close the browser window and cleanup."""
if not self.is_closing:
self.is_closing = True
def close_window():
if self.window:
self.window.destroy()
GLib.idle_add(close_window)
if self.connection_id in self.server.connections:
del self.server.connections[self.connection_id]
class BrowserServer:
"""
WebSocket server that manages multiple browser instances.
"""
def __init__(self, host: str = "localhost", port: int = 8765):
self.host = host
self.port = port
self.connections: Dict[str, RemoteBrowser] = {}
self.gtk_thread = None
self.loop = None
def start_gtk_thread(self):
"""Start GTK main loop in a separate thread."""
def gtk_main():
Gtk.main()
self.gtk_thread = threading.Thread(target=gtk_main, daemon=True)
self.gtk_thread.start()
async def handle_connection(self, websocket, path):
"""Handle a new WebSocket connection."""
connection_id = str(uuid.uuid4())
browser = RemoteBrowser(connection_id, websocket, self)
self.connections[connection_id] = browser
GLib.idle_add(browser.create_browser)
await websocket.send(json.dumps({
"type": "connected",
"connection_id": connection_id,
"message": "Browser window created"
}))
try:
async for message in websocket:
try:
command = json.loads(message)
await browser.handle_command(command)
except json.JSONDecodeError:
await websocket.send(json.dumps({
"type": "error",
"error": "Invalid JSON"
}))
except Exception as e:
await websocket.send(json.dumps({
"type": "error",
"error": str(e)
}))
finally:
await browser.close()
async def start_server(self):
"""Start the WebSocket server."""
print(f"Starting WebSocket server on ws://{self.host}:{self.port}")
await websockets.serve(self.handle_connection, self.host, self.port)
await asyncio.Future()
async def run(self):
"""Run the server."""
self.start_gtk_thread()
await self.start_server()
CLIENT_EXAMPLE = '''
import asyncio
import websockets
import json
async def test_browser():
"""Example client that controls the browser."""
uri = "ws://localhost:8765"
async with websockets.connect(uri) as websocket:
# Wait for connection confirmation
response = await websocket.recv()
print(f"Connected: {response}")
# Navigate to a website
await websocket.send(json.dumps({
"command": "navigate",
"url": "https://www.example.com",
"request_id": "nav1"
}))
# Wait for navigation response
response = await websocket.recv()
print(f"Navigation response: {response}")
# Wait a bit for page to load
await asyncio.sleep(2)
# Execute JavaScript
await websocket.send(json.dumps({
"command": "execute_js",
"script": "document.title",
"request_id": "js1"
}))
response = await websocket.recv()
print(f"Page title: {response}")
# Get browser info
await websocket.send(json.dumps({
"command": "get_info",
"request_id": "info1"
}))
response = await websocket.recv()
print(f"Browser info: {response}")
# Take screenshot
await websocket.send(json.dumps({
"command": "screenshot",
"request_id": "screenshot1"
}))
response = await websocket.recv()
result = json.loads(response)
if result.get("result") and result["result"].get("screenshot"):
print("Screenshot captured!")
# You can save the base64 image here
# Load custom HTML
await websocket.send(json.dumps({
"command": "set_html",
"html": "<h1>Hello from WebSocket!</h1><p>This is custom HTML</p>",
"request_id": "html1"
}))
response = await websocket.recv()
print(f"HTML loaded: {response}")
# Keep connection open for events
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=30)
print(f"Event: {message}")
except asyncio.TimeoutError:
break
asyncio.run(test_browser())
'''
if __name__ == "__main__":
server = BrowserServer(host="localhost", port=8765)
print("WebSocket Browser Server")
print("=" * 50)
print(f"Server will run on ws://localhost:8765")
print("\nAvailable commands:")
print("- navigate: Load a URL")
print("- execute_js: Execute JavaScript and get result")
print("- go_back/go_forward: Navigate history")
print("- reload/stop: Reload or stop loading")
print("- get_info: Get browser state info")
print("- screenshot: Take a screenshot (base64)")
print("- set_html: Load custom HTML")
print("\nExample client code:")
print("-" * 50)
print(CLIENT_EXAMPLE)
print("-" * 50)
try:
asyncio.run(server.run())
except KeyboardInterrupt:
print("\nShutting down server...")
Gtk.main_quit()