Created
January 8, 2025 11:22
-
-
Save azazar/ccccdedd997599a391316a2e984bad10 to your computer and use it in GitHub Desktop.
A script that provides remote access to a single window using VNC over LAN.
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 | |
""" | |
vnc_single_window_wx_xlib_dbus.py | |
A Python script that: | |
- Uses python3-xlib (Xlib) to list available windows (plus a "root" option). | |
- Displays a wxPython GUI (ListBox) of windows. | |
- On selection, we run x11vnc on that window (passwordless + accept popup). | |
- Publishes the chosen window name via Avahi (Zeroconf) using python3-dbus instead of avahi-publish-service. | |
Dependencies (Debian/Ubuntu): | |
sudo apt-get install python3-wxgtk4.0 python3-xlib python3-dbus x11vnc | |
Optional: | |
python3-avahi, dbus-x11, etc. | |
NOTE: | |
- This script won't see windows on pure Wayland unless they're running under XWayland. | |
- We still rely on x11vnc for actual VNC service. | |
""" | |
import sys | |
import time | |
import signal | |
import subprocess | |
import wx | |
import dbus | |
from dbus.mainloop.glib import DBusGMainLoop | |
from Xlib import display, X, Xutil | |
# Default VNC port: | |
VNC_PORT = 5900 | |
############################################################################## | |
# WINDOW ENUMERATION (Xlib) # | |
############################################################################## | |
def list_windows_xlib(): | |
""" | |
Use python3-xlib to list all normal windows (ID + title) plus 'root'. | |
Filters out unmapped/icon/no-name windows (similar to xwininfo approach). | |
Return: list of (wid_str, title) | |
wid_str is like 'root' or '0x1234abcd' (hex) | |
""" | |
results = [] | |
# Always offer 'root' first | |
results.append(("root", "Entire_Desktop")) | |
try: | |
d = display.Display() | |
except Exception as e: | |
print(f"Warning: Unable to open X display: {e}", file=sys.stderr) | |
# We only have "root" | |
return results | |
root = d.screen().root | |
# We'll do a recursive walk to find all children | |
def recurse(window): | |
# Query children | |
try: | |
children = window.query_tree().children | |
except: | |
# If query_tree fails for any reason, skip | |
return | |
for w in children: | |
# Try to read the window's attributes | |
try: | |
attrs = w.get_attributes() | |
except: | |
continue | |
# We only want windows that are viewable or mapped | |
# Checking MapState might be used, or we can skip by other heuristics | |
if attrs.map_state != X.IsViewable: | |
# It's unmapped or icon, skip | |
continue | |
# Try to get the window's name | |
title = get_window_name(d, w) | |
if title is not None and title.strip(): | |
# Build a hex string for the window ID | |
wid_hex = f"0x{w.id:x}" | |
results.append((wid_hex, title)) | |
# Recurse children | |
recurse(w) | |
# Start recursion from root | |
recurse(root) | |
d.close() | |
return results | |
def get_window_name(d, w): | |
""" | |
Retrieve a window's name/title using a few properties: | |
- _NET_WM_NAME (UTF-8) | |
- WM_NAME (fallback) | |
Return a string or None if not found. | |
""" | |
# Xlib constants for atoms | |
NET_WM_NAME = d.intern_atom('_NET_WM_NAME') | |
UTF8_STRING = d.intern_atom('UTF8_STRING') | |
WM_NAME = d.intern_atom('WM_NAME') | |
# Try _NET_WM_NAME | |
try: | |
prop = w.get_full_property(NET_WM_NAME, UTF8_STRING) | |
if (prop is not None) and (prop.value): | |
return prop.value.decode('utf-8', errors='replace') | |
except: | |
pass | |
# Fallback to regular WM_NAME | |
try: | |
prop = w.get_full_property(WM_NAME, X.AnyPropertyType) | |
if (prop is not None) and (prop.value): | |
# Might be bytes or string | |
return prop.value.decode('utf-8', errors='replace') | |
except: | |
pass | |
# No name | |
return None | |
############################################################################## | |
# AVAHI PUBLISH (DBus) # | |
############################################################################## | |
class AvahiPublisher: | |
""" | |
Uses DBus to talk to Avahi, publishing an RFB (VNC) service. | |
Replaces the 'avahi-publish-service' command-line approach. | |
You can call .publish(service_name, port) to publish, | |
and .unpublish() to remove the service. | |
""" | |
def __init__(self): | |
# Use a main loop for DBus | |
DBusGMainLoop(set_as_default=True) | |
# Connect to Avahi on the system bus | |
self.bus = dbus.SystemBus() | |
self.server = dbus.Interface( | |
self.bus.get_object('org.freedesktop.Avahi', '/'), | |
'org.freedesktop.Avahi.Server' | |
) | |
# We'll create an EntryGroup for our service | |
path = self.server.EntryGroupNew() | |
self.entry_group = dbus.Interface( | |
self.bus.get_object('org.freedesktop.Avahi', path), | |
'org.freedesktop.Avahi.EntryGroup' | |
) | |
self.published = False | |
def publish(self, service_name, port): | |
""" | |
Publish the RFB (VNC) service at the given port, named 'service_name'. | |
""" | |
if self.published: | |
return | |
interface = -1 # AVAHI_IF_UNSPEC | |
protocol = -1 # AVAHI_PROTO_UNSPEC | |
flags = 0 # No special flags | |
# type is '_rfb._tcp' for VNC | |
# domain and host default to empty => use Avahi defaults | |
# txt records => empty array for now | |
self.entry_group.AddService( | |
interface, protocol, flags, | |
service_name, '_rfb._tcp', '', '', port, | |
dbus.Array([], signature='ay') | |
) | |
self.entry_group.Commit() | |
self.published = True | |
print(f"Avahi: Published service '{service_name}' on port {port}.") | |
def unpublish(self): | |
""" | |
Unpublish the service (if published). | |
""" | |
if self.published: | |
self.entry_group.Reset() | |
self.published = False | |
print("Avahi: Service unpublished.") | |
############################################################################## | |
# WX GUI FOR WINDOW SELECTION # | |
############################################################################## | |
class MainFrame(wx.Frame): | |
""" | |
wx Frame to display the list of windows in a ListBox, plus 'root' for entire desktop. | |
""" | |
def __init__(self, parent, windows, title="Select a Window for x11vnc"): | |
super(MainFrame, self).__init__(parent, title=title, size=(600, 400)) | |
panel = wx.Panel(self) | |
sizer = wx.BoxSizer(wx.VERTICAL) | |
instr_label = wx.StaticText(panel, label="Select a window (double-click or select + OK):") | |
sizer.Add(instr_label, 0, wx.ALL, 5) | |
self.windows = windows # list of (wid_str, wtitle) | |
self.listbox = wx.ListBox(panel, style=wx.LB_SINGLE) | |
for wid, wtitle in self.windows: | |
label = f"[{wid}] {wtitle}" | |
self.listbox.Append(label) | |
sizer.Add(self.listbox, 1, wx.EXPAND | wx.ALL, 5) | |
# Buttons | |
btn_sizer = wx.BoxSizer(wx.HORIZONTAL) | |
ok_button = wx.Button(panel, label="OK") | |
cancel_button = wx.Button(panel, label="Cancel") | |
btn_sizer.Add(ok_button, 0, wx.ALL, 5) | |
btn_sizer.Add(cancel_button, 0, wx.ALL, 5) | |
sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER) | |
# Bind events | |
ok_button.Bind(wx.EVT_BUTTON, self.on_ok) | |
cancel_button.Bind(wx.EVT_BUTTON, self.on_cancel) | |
self.listbox.Bind(wx.EVT_LISTBOX_DCLICK, self.on_ok) | |
panel.SetSizer(sizer) | |
self.Centre() | |
self.selected = None | |
def on_ok(self, event): | |
sel = self.listbox.GetSelection() | |
if sel == wx.NOT_FOUND: | |
wx.MessageBox("No window selected. Please pick one.", "Warning", wx.OK | wx.ICON_WARNING) | |
return | |
self.selected = self.windows[sel] | |
self.Close() | |
def on_cancel(self, event): | |
self.selected = None | |
self.Close() | |
def get_selection(self): | |
return self.selected | |
class App(wx.App): | |
def __init__(self, windows): | |
self.windows = windows | |
super().__init__() | |
def OnInit(self): | |
self.frame = MainFrame(None, self.windows) | |
self.frame.Show() | |
return True | |
def get_selection(self): | |
return self.frame.get_selection() | |
############################################################################## | |
# RUN x11vnc # | |
############################################################################## | |
def run_x11vnc(window_id, port): | |
""" | |
Launch x11vnc on `window_id` (string like 'root' or '0x1234abcd'), | |
in passwordless mode, requiring manual acceptance for each connection. | |
Blocks until x11vnc exits. | |
""" | |
print(f"Starting x11vnc on window '{window_id}', port {port}, passwordless + accept popup.") | |
if window_id == "root": | |
cmd = [ | |
"x11vnc", | |
"-id", "root", | |
"-rfbport", str(port), | |
"-shared", | |
"-forever", | |
"-nopw", | |
"-accept", "popup" | |
] | |
else: | |
cmd = [ | |
"x11vnc", | |
"-id", window_id, | |
"-rfbport", str(port), | |
"-shared", | |
"-forever", | |
"-nopw", | |
"-accept", "popup" | |
] | |
subprocess.run(cmd) | |
############################################################################## | |
# MAIN # | |
############################################################################## | |
def main(): | |
# 1) Gather windows via python-xlib | |
windows = list_windows_xlib() | |
if not windows: | |
print("No windows found. Are you in an X session? Exiting.") | |
sys.exit(1) | |
# 2) If user passed an argument, skip GUI | |
if len(sys.argv) > 1: | |
arg = sys.argv[1] | |
# Attempt partial match or direct ID | |
chosen = None | |
for (wid, wtitle) in windows: | |
if wid.lower() == arg.lower(): | |
chosen = (wid, wtitle) | |
break | |
if arg.lower() in wtitle.lower(): | |
chosen = (wid, wtitle) | |
break | |
if not chosen: | |
print(f"No window matches '{arg}'. Exiting.") | |
sys.exit(1) | |
window_id, window_title = chosen | |
else: | |
# 3) Otherwise, show the wxPython GUI | |
app = App(windows) | |
app.MainLoop() | |
selected = app.get_selection() | |
if not selected: | |
print("No window selected (canceled). Exiting.") | |
sys.exit(1) | |
window_id, window_title = selected | |
# Avahi-friendly name | |
if window_id == "root": | |
service_name = "Desktop (root)" | |
else: | |
service_name = window_title | |
service_name = service_name[:60] # truncate | |
# 4) Publish via Avahi using DBus (no avahi-publish-service) | |
try: | |
avahi = AvahiPublisher() | |
avahi.publish(service_name, VNC_PORT) | |
except Exception as e: | |
print(f"Note: Could not publish Avahi service: {e}") | |
# 5) Run x11vnc | |
try: | |
run_x11vnc(window_id, VNC_PORT) | |
finally: | |
# 6) Unpublish Avahi | |
if 'avahi' in locals(): | |
avahi.unpublish() | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment