@bitefoundry/retrieviewer 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESKTOP-PLATFORMS.md +70 -0
- package/INSTALLING.md +8 -5
- package/README.md +12 -9
- package/RELEASING.md +11 -9
- package/integrations/desktop/platform.mjs +27 -0
- package/integrations/linux-overlay/README.md +83 -0
- package/integrations/linux-overlay/overlay.py +1094 -0
- package/integrations/macos-overlay/README.md +83 -0
- package/integrations/macos-overlay/overlay.jxa +637 -0
- package/integrations/windows-overlay/desktop.mjs +40 -8
- package/integrations/windows-overlay/worker.mjs +13 -7
- package/package.json +9 -3
- package/retrieviewer-cli.mjs +10 -3
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Native Linux X11 painter for the existing Braille engine. Python 3.6+ standard
|
|
3
|
+
# library only: ctypes binds the system libX11.so.6, libXrender.so.1 and
|
|
4
|
+
# libXext.so.6 already shipped by the desktop, so there is no compiler, PyPI or
|
|
5
|
+
# npm native ABI (works on AlmaLinux 8 / GLIBC 2.28 platform Python).
|
|
6
|
+
# No input injection, keyboard/pointer grabs, hotkeys, screen capture, network,
|
|
7
|
+
# root access, package installation or target-window modification. Pure Wayland
|
|
8
|
+
# sessions are refused: they cannot safely follow another application's window.
|
|
9
|
+
import ctypes
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import select
|
|
13
|
+
import socket
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from collections import deque
|
|
18
|
+
|
|
19
|
+
BRAILLE = 0x2800
|
|
20
|
+
CELL_WIDTHS = (6, 8, 10, 12, 16)
|
|
21
|
+
PROTOCOL_MAX_CELLS = 125000
|
|
22
|
+
DEFAULT_MAX_CELLS = 20000
|
|
23
|
+
MAX_LINE = 800000
|
|
24
|
+
MAX_QUEUED = 4
|
|
25
|
+
INK = (248, 242, 232)
|
|
26
|
+
INK_ALPHA = 245
|
|
27
|
+
# Braille bit -> (column, row) inside the 2x4 dot cell (same as Overlay.cs).
|
|
28
|
+
DOTS = ((0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (0, 3), (1, 3))
|
|
29
|
+
MIN_TARGET = (160, 120)
|
|
30
|
+
TRACK_INTERVAL = 1 / 30.0
|
|
31
|
+
RAISE_INTERVAL = 0.25
|
|
32
|
+
TERMINALS = ('terminal', 'konsole', 'xterm', 'urxvt', 'rxvt', 'alacritty', 'kitty', 'wezterm', 'tilix',
|
|
33
|
+
'terminator', 'terminology', 'sakura', 'guake', 'tilda', 'yakuake', 'st', 'foot', 'ghostty')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OverlayError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------- pure helpers
|
|
41
|
+
def session_problem(env, platform=None):
|
|
42
|
+
"""Return an actionable refusal message, or None when an X11 session is usable."""
|
|
43
|
+
platform = sys.platform if platform is None else platform
|
|
44
|
+
if not platform.startswith('linux'):
|
|
45
|
+
return 'The Linux companion helper runs only on Linux X11 desktops'
|
|
46
|
+
if env.get('WAYLAND_DISPLAY') or env.get('XDG_SESSION_TYPE', '').strip().lower() == 'wayland':
|
|
47
|
+
return ('Wayland session detected. Wayland does not let an app safely follow or stack above another '
|
|
48
|
+
"app's window, so the companion refuses to start. Log out and choose an Xorg session "
|
|
49
|
+
"(for example 'GNOME on Xorg' / 'Standard (X11 display server)' on the login screen), then retry.")
|
|
50
|
+
if not env.get('DISPLAY'):
|
|
51
|
+
return 'DISPLAY is not set. Start the companion from a graphical Xorg desktop session.'
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def strict_int(message, key):
|
|
56
|
+
value = message.get(key)
|
|
57
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
58
|
+
raise OverlayError('Invalid overlay field: ' + key)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def choose_grid(width, height, cell, max_cells=DEFAULT_MAX_CELLS):
|
|
63
|
+
"""Columns/rows for a client area; capped so a huge monitor never exceeds max_cells."""
|
|
64
|
+
if cell not in CELL_WIDTHS:
|
|
65
|
+
raise OverlayError('Unsupported cell width')
|
|
66
|
+
max_cells = max(1, min(int(max_cells), PROTOCOL_MAX_CELLS))
|
|
67
|
+
columns, rows = max(0, width // cell), max(0, height // (cell * 2))
|
|
68
|
+
if columns * rows > max_cells:
|
|
69
|
+
scale = (max_cells / float(columns * rows)) ** 0.5
|
|
70
|
+
columns, rows = max(1, int(columns * scale)), max(1, int(rows * scale))
|
|
71
|
+
while columns * rows > max_cells:
|
|
72
|
+
if columns >= rows:
|
|
73
|
+
columns -= 1
|
|
74
|
+
else:
|
|
75
|
+
rows -= 1
|
|
76
|
+
return columns, rows
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_frame(message, cell, columns, rows):
|
|
80
|
+
"""Validate a cropped frame against the current viewport grid.
|
|
81
|
+
|
|
82
|
+
Returns None for the empty frame (hide), else (x, y, columns, rows, lines)."""
|
|
83
|
+
cx, cy = strict_int(message, 'x'), strict_int(message, 'y')
|
|
84
|
+
cols, nrows = strict_int(message, 'columns'), strict_int(message, 'rows')
|
|
85
|
+
text = message.get('text')
|
|
86
|
+
if not isinstance(text, str):
|
|
87
|
+
raise OverlayError('Invalid overlay field: text')
|
|
88
|
+
if cols == 0 and nrows == 0 and text == '':
|
|
89
|
+
return None
|
|
90
|
+
if cols < 1 or nrows < 1 or cols * nrows > PROTOCOL_MAX_CELLS or cx < 0 or cy < 0 \
|
|
91
|
+
or cx + cols > columns or cy + nrows > rows:
|
|
92
|
+
raise OverlayError('Out-of-bounds overlay frame')
|
|
93
|
+
lines = text.split('\n')
|
|
94
|
+
if len(lines) != nrows:
|
|
95
|
+
raise OverlayError('Invalid overlay row count')
|
|
96
|
+
for line in lines:
|
|
97
|
+
if len(line) != cols:
|
|
98
|
+
raise OverlayError('Invalid overlay column count')
|
|
99
|
+
for ch in line:
|
|
100
|
+
if not BRAILLE <= ord(ch) <= BRAILLE + 0xff:
|
|
101
|
+
raise OverlayError('Only Braille cells may cross the overlay bridge')
|
|
102
|
+
return cx, cy, cols, nrows, lines
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DotTiles(object):
|
|
106
|
+
"""Premultiplied ARGB32 (little-endian B,G,R,A bytes) tiles for each Braille mask."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, cell, ink=INK, alpha=INK_ALPHA, samples=4):
|
|
109
|
+
self.cell, self.width, self.height = cell, cell, cell * 2
|
|
110
|
+
step = cell / 2.0
|
|
111
|
+
radius = max(1.1, step * 0.48) / 2.0
|
|
112
|
+
self.dots = []
|
|
113
|
+
for dx, dy in DOTS:
|
|
114
|
+
cx, cy, cover = (dx + 0.5) * step, (dy + 0.5) * step, {}
|
|
115
|
+
for py in range(max(0, int(cy - radius) - 1), min(self.height, int(cy + radius) + 2)):
|
|
116
|
+
for px in range(max(0, int(cx - radius) - 1), min(self.width, int(cx + radius) + 2)):
|
|
117
|
+
hits = 0
|
|
118
|
+
for sy in range(samples):
|
|
119
|
+
for sx in range(samples):
|
|
120
|
+
ox, oy = px + (sx + 0.5) / samples - cx, py + (sy + 0.5) / samples - cy
|
|
121
|
+
if ox * ox + oy * oy <= radius * radius:
|
|
122
|
+
hits += 1
|
|
123
|
+
if hits:
|
|
124
|
+
cover[(px, py)] = hits / float(samples * samples)
|
|
125
|
+
self.dots.append(cover)
|
|
126
|
+
self.ink, self.alpha, self.cache = ink, alpha, {}
|
|
127
|
+
|
|
128
|
+
def tile(self, mask):
|
|
129
|
+
rows = self.cache.get(mask)
|
|
130
|
+
if rows is None:
|
|
131
|
+
pixels = bytearray(self.width * self.height * 4)
|
|
132
|
+
cover = {}
|
|
133
|
+
for bit in range(8):
|
|
134
|
+
if mask & (1 << bit):
|
|
135
|
+
for key, value in self.dots[bit].items():
|
|
136
|
+
cover[key] = min(1.0, cover.get(key, 0.0) + value)
|
|
137
|
+
for (px, py), value in cover.items():
|
|
138
|
+
a = int(round(self.alpha * value))
|
|
139
|
+
i = (py * self.width + px) * 4
|
|
140
|
+
pixels[i:i + 4] = bytes((self.ink[2] * a // 255, self.ink[1] * a // 255, self.ink[0] * a // 255, a))
|
|
141
|
+
stride = self.width * 4
|
|
142
|
+
rows = tuple(bytes(pixels[y * stride:(y + 1) * stride]) for y in range(self.height))
|
|
143
|
+
self.cache[mask] = rows
|
|
144
|
+
return rows
|
|
145
|
+
|
|
146
|
+
def render(self, lines):
|
|
147
|
+
"""Rasterize only the cropped Braille box. Fully covers it, so no stale trails."""
|
|
148
|
+
out = []
|
|
149
|
+
for line in lines:
|
|
150
|
+
tiles = [self.tile(ord(ch) - BRAILLE) for ch in line]
|
|
151
|
+
for y in range(self.height):
|
|
152
|
+
out.append(b''.join(t[y] for t in tiles))
|
|
153
|
+
return b''.join(out)
|
|
154
|
+
|
|
155
|
+
def dot_count(self, lines):
|
|
156
|
+
return sum(bin(ord(ch) - BRAILLE).count('1') for line in lines for ch in line)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def encode(message):
|
|
160
|
+
return json.dumps(message, separators=(',', ':'), ensure_ascii=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Companion(object):
|
|
164
|
+
"""Protocol state machine. The UI backend owns every native resource."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, ui, emit, cell=16, max_cells=DEFAULT_MAX_CELLS, test_mode=False):
|
|
167
|
+
if cell not in CELL_WIDTHS:
|
|
168
|
+
raise OverlayError('Unsupported cell width')
|
|
169
|
+
self.ui, self.emit, self.cell, self.max_cells, self.test_mode = ui, emit, cell, max_cells, test_mode
|
|
170
|
+
self.revision, self.visible, self.paused, self.loaded = 0, False, False, False
|
|
171
|
+
self.picking, self.quitting, self.announced = False, False, False
|
|
172
|
+
self.canvas, self.grid, self.status = (0, 0, 0, 0), (0, 0), 'starting'
|
|
173
|
+
self.tiles, self.painted_dots = DotTiles(cell), 0
|
|
174
|
+
|
|
175
|
+
# -- input from the Node engine
|
|
176
|
+
def handle(self, message):
|
|
177
|
+
if not isinstance(message, dict):
|
|
178
|
+
raise OverlayError('Invalid overlay message')
|
|
179
|
+
kind = message.get('type')
|
|
180
|
+
if kind == 'quit':
|
|
181
|
+
self.quit()
|
|
182
|
+
elif kind == 'loaded':
|
|
183
|
+
self.loaded = True
|
|
184
|
+
self.set_status('playing')
|
|
185
|
+
if self.test_mode:
|
|
186
|
+
self.press('ball')
|
|
187
|
+
elif kind == 'interaction':
|
|
188
|
+
self.set_status('good!' if message.get('accepted') is True else 'busy, wait')
|
|
189
|
+
elif kind == 'error':
|
|
190
|
+
self.set_status('engine error')
|
|
191
|
+
self.quit()
|
|
192
|
+
elif kind == 'frame':
|
|
193
|
+
# Always acknowledge discarded frames too, otherwise resizing can deadlock the sender.
|
|
194
|
+
painted = self.visible and not self.paused and strict_int(message, 'revision') == self.revision
|
|
195
|
+
if painted:
|
|
196
|
+
self.paint(message)
|
|
197
|
+
self.emit({'type': 'ack', 'painted': painted})
|
|
198
|
+
else:
|
|
199
|
+
raise OverlayError('Unsupported overlay message')
|
|
200
|
+
|
|
201
|
+
def paint(self, message):
|
|
202
|
+
frame = parse_frame(message, self.cell, self.grid[0], self.grid[1])
|
|
203
|
+
if frame is None:
|
|
204
|
+
self.ui.hide_pet()
|
|
205
|
+
return
|
|
206
|
+
cx, cy, cols, rows, lines = frame
|
|
207
|
+
data = self.tiles.render(lines)
|
|
208
|
+
self.painted_dots = self.tiles.dot_count(lines)
|
|
209
|
+
self.ui.paint_pet(self.canvas[0] + cx * self.cell, self.canvas[1] + cy * self.cell * 2,
|
|
210
|
+
cols * self.cell, rows * self.cell * 2, data)
|
|
211
|
+
|
|
212
|
+
# -- native control strip / picker
|
|
213
|
+
def press(self, name):
|
|
214
|
+
if self.quitting:
|
|
215
|
+
return
|
|
216
|
+
if name in ('ball', 'treat'):
|
|
217
|
+
if self.loaded and not self.paused:
|
|
218
|
+
self.emit({'type': 'interact', 'kind': name})
|
|
219
|
+
elif name == 'pause':
|
|
220
|
+
self.paused = not self.paused
|
|
221
|
+
self.emit({'type': 'pause', 'paused': self.paused})
|
|
222
|
+
self.track(True)
|
|
223
|
+
elif name == 'window':
|
|
224
|
+
self.choose_target()
|
|
225
|
+
elif name == 'quit':
|
|
226
|
+
self.quit()
|
|
227
|
+
|
|
228
|
+
def choose_target(self):
|
|
229
|
+
if self.picking or self.quitting:
|
|
230
|
+
return
|
|
231
|
+
self.picking = True
|
|
232
|
+
self.track(True)
|
|
233
|
+
self.ui.open_picker()
|
|
234
|
+
|
|
235
|
+
def picker_shown(self, visible):
|
|
236
|
+
self.emit({'type': 'picker', 'visible': bool(visible)})
|
|
237
|
+
|
|
238
|
+
def picker_closed(self, attached):
|
|
239
|
+
self.picking = False
|
|
240
|
+
if attached:
|
|
241
|
+
self.paused = False
|
|
242
|
+
self.emit({'type': 'pause', 'paused': False})
|
|
243
|
+
self.emit({'type': 'picker', 'visible': False})
|
|
244
|
+
if not self.quitting:
|
|
245
|
+
if not self.ui.has_target():
|
|
246
|
+
self.set_status('choose a window')
|
|
247
|
+
self.track(True)
|
|
248
|
+
|
|
249
|
+
def set_status(self, text):
|
|
250
|
+
self.status = text
|
|
251
|
+
self.ui.status_changed(text)
|
|
252
|
+
|
|
253
|
+
# -- geometry/visibility tracking
|
|
254
|
+
def start(self):
|
|
255
|
+
if not self.test_mode:
|
|
256
|
+
self.choose_target()
|
|
257
|
+
self.ui.wait_for_picker()
|
|
258
|
+
self.announced = True
|
|
259
|
+
self.emit({'type': 'ready', 'pickerVisible': bool(self.ui.picker_visible())})
|
|
260
|
+
self.track()
|
|
261
|
+
|
|
262
|
+
def track(self, force=False):
|
|
263
|
+
if self.quitting:
|
|
264
|
+
return
|
|
265
|
+
state = self.ui.target_state() # None, or (x, y, width, height, active)
|
|
266
|
+
show, canvas = False, (0, 0, 0, 0)
|
|
267
|
+
if state is not None:
|
|
268
|
+
canvas = tuple(state[:4])
|
|
269
|
+
show = (not self.picking and (self.test_mode or state[4])
|
|
270
|
+
and canvas[2] >= MIN_TARGET[0] and canvas[3] >= MIN_TARGET[1])
|
|
271
|
+
grid = choose_grid(canvas[2], canvas[3], self.cell, self.max_cells)
|
|
272
|
+
if force or show != self.visible or canvas != self.canvas or grid != self.grid:
|
|
273
|
+
self.visible, self.canvas, self.grid = show, canvas, grid
|
|
274
|
+
self.revision += 1
|
|
275
|
+
self.ui.hide_pet()
|
|
276
|
+
self.emit({'type': 'viewport', 'revision': self.revision, 'visible': show and not self.paused,
|
|
277
|
+
'selected': state is not None, 'columns': grid[0], 'rows': grid[1],
|
|
278
|
+
'cellWidth': self.cell, 'cellHeight': self.cell * 2})
|
|
279
|
+
if self.visible:
|
|
280
|
+
self.ui.show_bar(canvas, self.paused)
|
|
281
|
+
if self.paused:
|
|
282
|
+
self.ui.hide_pet()
|
|
283
|
+
else:
|
|
284
|
+
self.ui.hide_pet()
|
|
285
|
+
self.ui.hide_bar()
|
|
286
|
+
|
|
287
|
+
def quit(self):
|
|
288
|
+
if self.quitting:
|
|
289
|
+
return
|
|
290
|
+
self.quitting = True
|
|
291
|
+
try:
|
|
292
|
+
self.ui.close()
|
|
293
|
+
finally:
|
|
294
|
+
self.emit({'type': 'quit'})
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------- Xlib ABI
|
|
298
|
+
c_ulong, c_long, c_int, c_uint, c_void_p = ctypes.c_ulong, ctypes.c_long, ctypes.c_int, ctypes.c_uint, ctypes.c_void_p
|
|
299
|
+
XID = c_ulong # Window, Atom, Colormap, Pixmap, VisualID, Time are all unsigned long XIDs.
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class XVisualInfo(ctypes.Structure):
|
|
303
|
+
_fields_ = [('visual', c_void_p), ('visualid', c_ulong), ('screen', c_int), ('depth', c_int),
|
|
304
|
+
('c_class', c_int), ('red_mask', c_ulong), ('green_mask', c_ulong), ('blue_mask', c_ulong),
|
|
305
|
+
('colormap_size', c_int), ('bits_per_rgb', c_int)]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class XSetWindowAttributes(ctypes.Structure):
|
|
309
|
+
_fields_ = [('background_pixmap', XID), ('background_pixel', c_ulong), ('border_pixmap', XID),
|
|
310
|
+
('border_pixel', c_ulong), ('bit_gravity', c_int), ('win_gravity', c_int),
|
|
311
|
+
('backing_store', c_int), ('backing_planes', c_ulong), ('backing_pixel', c_ulong),
|
|
312
|
+
('save_under', c_int), ('event_mask', c_long), ('do_not_propagate_mask', c_long),
|
|
313
|
+
('override_redirect', c_int), ('colormap', XID), ('cursor', XID)]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class XWindowAttributes(ctypes.Structure):
|
|
317
|
+
_fields_ = [('x', c_int), ('y', c_int), ('width', c_int), ('height', c_int), ('border_width', c_int),
|
|
318
|
+
('depth', c_int), ('visual', c_void_p), ('root', XID), ('c_class', c_int),
|
|
319
|
+
('bit_gravity', c_int), ('win_gravity', c_int), ('backing_store', c_int),
|
|
320
|
+
('backing_planes', c_ulong), ('backing_pixel', c_ulong), ('save_under', c_int),
|
|
321
|
+
('colormap', XID), ('map_installed', c_int), ('map_state', c_int),
|
|
322
|
+
('all_event_masks', c_long), ('your_event_mask', c_long), ('do_not_propagate_mask', c_long),
|
|
323
|
+
('override_redirect', c_int), ('screen', c_void_p)]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class XWMHints(ctypes.Structure):
|
|
327
|
+
_fields_ = [('flags', c_long), ('input', c_int), ('initial_state', c_int), ('icon_pixmap', XID),
|
|
328
|
+
('icon_window', XID), ('icon_x', c_int), ('icon_y', c_int), ('icon_mask', XID),
|
|
329
|
+
('window_group', XID)]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class XImageFuncs(ctypes.Structure):
|
|
333
|
+
_fields_ = [(name, c_void_p) for name in
|
|
334
|
+
('create_image', 'destroy_image', 'get_pixel', 'put_pixel', 'sub_image', 'add_pixel')]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class XImage(ctypes.Structure):
|
|
338
|
+
_fields_ = [('width', c_int), ('height', c_int), ('xoffset', c_int), ('format', c_int),
|
|
339
|
+
('data', c_void_p), ('byte_order', c_int), ('bitmap_unit', c_int),
|
|
340
|
+
('bitmap_bit_order', c_int), ('bitmap_pad', c_int), ('depth', c_int),
|
|
341
|
+
('bytes_per_line', c_int), ('bits_per_pixel', c_int), ('red_mask', c_ulong),
|
|
342
|
+
('green_mask', c_ulong), ('blue_mask', c_ulong), ('obdata', c_void_p), ('f', XImageFuncs)]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class XRectangle(ctypes.Structure):
|
|
346
|
+
_fields_ = [('x', ctypes.c_short), ('y', ctypes.c_short), ('width', ctypes.c_ushort),
|
|
347
|
+
('height', ctypes.c_ushort)]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class XRenderDirectFormat(ctypes.Structure):
|
|
351
|
+
_fields_ = [(name, ctypes.c_short) for name in
|
|
352
|
+
('red', 'redMask', 'green', 'greenMask', 'blue', 'blueMask', 'alpha', 'alphaMask')]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class XRenderPictFormat(ctypes.Structure):
|
|
356
|
+
_fields_ = [('id', XID), ('type', c_int), ('depth', c_int), ('direct', XRenderDirectFormat),
|
|
357
|
+
('colormap', XID)]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class XErrorEvent(ctypes.Structure):
|
|
361
|
+
_fields_ = [('type', c_int), ('display', c_void_p), ('resourceid', XID), ('serial', c_ulong),
|
|
362
|
+
('error_code', ctypes.c_ubyte), ('request_code', ctypes.c_ubyte), ('minor_code', ctypes.c_ubyte)]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class XAnyEvent(ctypes.Structure):
|
|
366
|
+
_fields_ = [('type', c_int), ('serial', c_ulong), ('send_event', c_int), ('display', c_void_p),
|
|
367
|
+
('window', XID)]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class XButtonEvent(ctypes.Structure):
|
|
371
|
+
_fields_ = XAnyEvent._fields_ + [('root', XID), ('subwindow', XID), ('time', c_ulong), ('x', c_int),
|
|
372
|
+
('y', c_int), ('x_root', c_int), ('y_root', c_int), ('state', c_uint),
|
|
373
|
+
('button', c_uint), ('same_screen', c_int)]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class XExposeEvent(ctypes.Structure):
|
|
377
|
+
_fields_ = XAnyEvent._fields_ + [('x', c_int), ('y', c_int), ('width', c_int), ('height', c_int),
|
|
378
|
+
('count', c_int)]
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class XClientMessageEvent(ctypes.Structure):
|
|
382
|
+
_fields_ = XAnyEvent._fields_ + [('message_type', XID), ('format', c_int), ('l', c_long * 5)]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class XEvent(ctypes.Union):
|
|
386
|
+
_fields_ = [('type', c_int), ('xany', XAnyEvent), ('xbutton', XButtonEvent), ('xexpose', XExposeEvent),
|
|
387
|
+
('xclient', XClientMessageEvent), ('pad', c_long * 24)]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
ERROR_HANDLER = ctypes.CFUNCTYPE(c_int, c_void_p, ctypes.POINTER(XErrorEvent))
|
|
391
|
+
IO_ERROR_HANDLER = ctypes.CFUNCTYPE(c_int, c_void_p)
|
|
392
|
+
|
|
393
|
+
# X.h / Xutil.h / shapeconst.h / render.h constants
|
|
394
|
+
TrueColor, InputOutput, AllocNone, ZPixmap, LSBFirst = 4, 1, 0, 2, 0
|
|
395
|
+
CWBackPixel, CWBorderPixel, CWOverrideRedirect, CWEventMask, CWColormap = 1 << 1, 1 << 3, 1 << 9, 1 << 11, 1 << 13
|
|
396
|
+
ButtonPressMask, ExposureMask, StructureNotifyMask = 1 << 2, 1 << 15, 1 << 17
|
|
397
|
+
ButtonPress, Expose, MapNotify, ClientMessage = 4, 12, 19, 33
|
|
398
|
+
IsViewable, Success, PropModeReplace, InputHint = 2, 0, 0, 1
|
|
399
|
+
XA_ATOM, XA_CARDINAL, XA_STRING, XA_WINDOW = 4, 6, 31, 33
|
|
400
|
+
BadWindow, BadDrawable = 3, 9
|
|
401
|
+
ShapeBounding, ShapeInput, ShapeSet, Unsorted = 0, 2, 0, 0
|
|
402
|
+
PictTypeDirect = 1
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _bind(lib, name, restype, argtypes):
|
|
406
|
+
fn = getattr(lib, name)
|
|
407
|
+
fn.restype, fn.argtypes = restype, argtypes
|
|
408
|
+
return fn
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def load_libraries(loader=ctypes.CDLL):
|
|
412
|
+
"""Bind exact prototypes of the system X client libraries (never downloaded)."""
|
|
413
|
+
try:
|
|
414
|
+
x11, xrender, xext = loader('libX11.so.6'), loader('libXrender.so.1'), loader('libXext.so.6')
|
|
415
|
+
except OSError as error:
|
|
416
|
+
raise OverlayError('Missing X11 client library (' + str(error) + '). Install the desktop session '
|
|
417
|
+
'packages libX11, libXrender and libXext with your system package manager.')
|
|
418
|
+
P, I, U, UL, L = c_void_p, c_int, c_uint, c_ulong, c_long
|
|
419
|
+
PI, PUL = ctypes.POINTER(c_int), ctypes.POINTER(c_ulong)
|
|
420
|
+
x = type('Xlib', (), {})()
|
|
421
|
+
for name, res, args in (
|
|
422
|
+
('XOpenDisplay', P, [ctypes.c_char_p]), ('XCloseDisplay', I, [P]), ('XDefaultScreen', I, [P]),
|
|
423
|
+
('XRootWindow', UL, [P, I]), ('XConnectionNumber', I, [P]), ('XFlush', I, [P]), ('XSync', I, [P, I]),
|
|
424
|
+
('XPending', I, [P]), ('XNextEvent', I, [P, ctypes.POINTER(XEvent)]),
|
|
425
|
+
('XInternAtom', UL, [P, ctypes.c_char_p, I]), ('XFree', I, [P]),
|
|
426
|
+
('XGetWindowProperty', I, [P, UL, UL, L, L, I, UL, PUL, PI, PUL, PUL,
|
|
427
|
+
ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))]),
|
|
428
|
+
('XChangeProperty', I, [P, UL, UL, UL, I, I, P, I]),
|
|
429
|
+
('XGetSelectionOwner', UL, [P, UL]),
|
|
430
|
+
('XMatchVisualInfo', I, [P, I, I, I, ctypes.POINTER(XVisualInfo)]),
|
|
431
|
+
('XCreateColormap', UL, [P, UL, P, I]), ('XFreeColormap', I, [P, UL]),
|
|
432
|
+
('XCreateWindow', UL, [P, UL, I, I, U, U, U, I, U, P, UL, ctypes.POINTER(XSetWindowAttributes)]),
|
|
433
|
+
('XDestroyWindow', I, [P, UL]), ('XMapRaised', I, [P, UL]), ('XUnmapWindow', I, [P, UL]),
|
|
434
|
+
('XRaiseWindow', I, [P, UL]), ('XMoveResizeWindow', I, [P, UL, I, I, U, U]),
|
|
435
|
+
('XStoreName', I, [P, UL, ctypes.c_char_p]), ('XSetWMHints', I, [P, UL, ctypes.POINTER(XWMHints)]),
|
|
436
|
+
('XSetWMProtocols', I, [P, UL, PUL, I]),
|
|
437
|
+
('XGetWindowAttributes', I, [P, UL, ctypes.POINTER(XWindowAttributes)]),
|
|
438
|
+
('XTranslateCoordinates', I, [P, UL, UL, I, I, PI, PI, PUL]),
|
|
439
|
+
('XGetInputFocus', I, [P, PUL, PI]),
|
|
440
|
+
('XCreateGC', P, [P, UL, UL, P]), ('XFreeGC', I, [P, P]), ('XSetForeground', I, [P, P, UL]),
|
|
441
|
+
('XFillRectangle', I, [P, UL, P, I, I, U, U]), ('XDrawString', I, [P, UL, P, I, I, ctypes.c_char_p, I]),
|
|
442
|
+
('XInitImage', I, [ctypes.POINTER(XImage)]),
|
|
443
|
+
('XPutImage', I, [P, UL, P, ctypes.POINTER(XImage), I, I, I, I, U, U]),
|
|
444
|
+
('XSetErrorHandler', P, [ERROR_HANDLER]), ('XSetIOErrorHandler', P, [IO_ERROR_HANDLER])):
|
|
445
|
+
setattr(x, name, _bind(x11, name, res, args))
|
|
446
|
+
x.XRenderQueryExtension = _bind(xrender, 'XRenderQueryExtension', I, [P, PI, PI])
|
|
447
|
+
x.XRenderFindVisualFormat = _bind(xrender, 'XRenderFindVisualFormat', ctypes.POINTER(XRenderPictFormat), [P, P])
|
|
448
|
+
x.XShapeQueryExtension = _bind(xext, 'XShapeQueryExtension', I, [P, PI, PI])
|
|
449
|
+
x.XShapeQueryVersion = _bind(xext, 'XShapeQueryVersion', I, [P, PI, PI])
|
|
450
|
+
x.XShapeCombineRectangles = _bind(xext, 'XShapeCombineRectangles', None,
|
|
451
|
+
[P, UL, I, I, I, ctypes.POINTER(XRectangle), I, I, I])
|
|
452
|
+
x.XShapeGetRectangles = _bind(xext, 'XShapeGetRectangles', ctypes.POINTER(XRectangle), [P, UL, I, PI, PI])
|
|
453
|
+
return x
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def ascii_label(text, limit):
|
|
457
|
+
text = ''.join(ch if ' ' <= ch <= '~' else '?' for ch in text)
|
|
458
|
+
return text if len(text) <= limit else text[:limit - 3] + '...'
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class XDisplay(object):
|
|
462
|
+
"""Owned X11 resources: one ARGB pet window, one control strip, one picker."""
|
|
463
|
+
BAR_BUTTONS = (('ball', 'Ball'), ('treat', 'Treat'), ('window', 'Window'), ('pause', 'Pause'), ('quit', 'Quit'))
|
|
464
|
+
|
|
465
|
+
def __init__(self, display_name=None, require_compositor=True, x=None):
|
|
466
|
+
self.x = x or load_libraries()
|
|
467
|
+
self.errors, self.companion, self.dpy = [], None, None
|
|
468
|
+
self._error_cb = ERROR_HANDLER(self._on_error)
|
|
469
|
+
self._io_cb = IO_ERROR_HANDLER(self._on_io_error)
|
|
470
|
+
self.x.XSetErrorHandler(self._error_cb)
|
|
471
|
+
self.x.XSetIOErrorHandler(self._io_cb)
|
|
472
|
+
self.dpy = self.x.XOpenDisplay(display_name.encode() if display_name else None)
|
|
473
|
+
if not self.dpy:
|
|
474
|
+
raise OverlayError('Cannot open X display ' + repr(display_name or os.environ.get('DISPLAY', '')))
|
|
475
|
+
self.windows, self.gcs, self.colormap = [], [], 0
|
|
476
|
+
self.target = self.target_pid = None
|
|
477
|
+
self.pet = self.bar = self.picker = 0
|
|
478
|
+
self.pet_mapped = self.bar_mapped = self.picker_mapped = False
|
|
479
|
+
self.last_image = None
|
|
480
|
+
self.bar_canvas, self.bar_paused, self.status = None, False, 'starting'
|
|
481
|
+
self.picker_items, self.picker_index, self.last_raise, self.owned_target = [], 0, 0.0, False
|
|
482
|
+
self.screen = self.x.XDefaultScreen(self.dpy)
|
|
483
|
+
self.root = self.x.XRootWindow(self.dpy, self.screen)
|
|
484
|
+
self.atoms = {}
|
|
485
|
+
try:
|
|
486
|
+
self._check_extensions(require_compositor)
|
|
487
|
+
except Exception:
|
|
488
|
+
self.close()
|
|
489
|
+
raise
|
|
490
|
+
|
|
491
|
+
# -- capability checks: clear errors instead of an opaque black window
|
|
492
|
+
def atom(self, name):
|
|
493
|
+
if name not in self.atoms:
|
|
494
|
+
self.atoms[name] = self.x.XInternAtom(self.dpy, name.encode(), 0)
|
|
495
|
+
return self.atoms[name]
|
|
496
|
+
|
|
497
|
+
def _check_extensions(self, require_compositor):
|
|
498
|
+
a, b = c_int(), c_int()
|
|
499
|
+
if not self.x.XShapeQueryExtension(self.dpy, ctypes.byref(a), ctypes.byref(b)):
|
|
500
|
+
raise OverlayError('The X server lacks the SHAPE extension needed for a click-through overlay')
|
|
501
|
+
if not self.x.XShapeQueryVersion(self.dpy, ctypes.byref(a), ctypes.byref(b)) or (a.value, b.value) < (1, 1):
|
|
502
|
+
raise OverlayError('The X server SHAPE extension is older than 1.1 (no input shapes)')
|
|
503
|
+
if not self.x.XRenderQueryExtension(self.dpy, ctypes.byref(a), ctypes.byref(b)):
|
|
504
|
+
raise OverlayError('The X server lacks the RENDER extension needed for transparency')
|
|
505
|
+
info = XVisualInfo()
|
|
506
|
+
if not self.x.XMatchVisualInfo(self.dpy, self.screen, 32, TrueColor, ctypes.byref(info)):
|
|
507
|
+
raise OverlayError('No 32-bit ARGB visual is available on this X screen')
|
|
508
|
+
fmt = self.x.XRenderFindVisualFormat(self.dpy, info.visual)
|
|
509
|
+
if not fmt or fmt.contents.type != PictTypeDirect or fmt.contents.direct.alphaMask != 0xff \
|
|
510
|
+
or fmt.contents.direct.alpha != 24 or (info.red_mask, info.green_mask, info.blue_mask) != (0xff0000, 0xff00, 0xff):
|
|
511
|
+
raise OverlayError('The 32-bit visual is not XRender ARGB32')
|
|
512
|
+
self.visual = info.visual
|
|
513
|
+
self.compositor = bool(self.x.XGetSelectionOwner(self.dpy, self.atom('_NET_WM_CM_S%d' % self.screen)))
|
|
514
|
+
if require_compositor and not self.compositor:
|
|
515
|
+
raise OverlayError('No compositing manager is running, so a transparent overlay would paint an opaque '
|
|
516
|
+
'black box. Enable desktop compositing (most GNOME/KDE/Xfce sessions do) and retry.')
|
|
517
|
+
supported = self.prop_values(self.root, '_NET_SUPPORTED', XA_ATOM)
|
|
518
|
+
if require_compositor and not {self.atom('_NET_CLIENT_LIST'), self.atom('_NET_ACTIVE_WINDOW')} <= set(supported):
|
|
519
|
+
raise OverlayError('The window manager does not publish EWMH _NET_CLIENT_LIST/_NET_ACTIVE_WINDOW, '
|
|
520
|
+
'so a target window cannot be followed safely')
|
|
521
|
+
self.colormap = self.x.XCreateColormap(self.dpy, self.root, self.visual, AllocNone)
|
|
522
|
+
|
|
523
|
+
# -- error handling: a target window may disappear at any time
|
|
524
|
+
def _on_error(self, display, event):
|
|
525
|
+
e = event.contents
|
|
526
|
+
self.errors.append((e.error_code, e.request_code, e.resourceid))
|
|
527
|
+
return 0
|
|
528
|
+
|
|
529
|
+
def _on_io_error(self, display):
|
|
530
|
+
try:
|
|
531
|
+
sys.stdout.write(encode({'type': 'error', 'message': 'X display connection lost'}) + '\n')
|
|
532
|
+
sys.stdout.flush()
|
|
533
|
+
finally:
|
|
534
|
+
os._exit(1)
|
|
535
|
+
return 0
|
|
536
|
+
|
|
537
|
+
def checked(self, resource):
|
|
538
|
+
"""Sync and consume async errors; True when the given foreign resource vanished."""
|
|
539
|
+
self.x.XSync(self.dpy, 0)
|
|
540
|
+
errors, self.errors = self.errors, []
|
|
541
|
+
gone = False
|
|
542
|
+
for code, request, rid in errors:
|
|
543
|
+
if rid == resource and code in (BadWindow, BadDrawable):
|
|
544
|
+
gone = True
|
|
545
|
+
else:
|
|
546
|
+
raise OverlayError('X protocol error %d (request %d)' % (code, request))
|
|
547
|
+
return gone
|
|
548
|
+
|
|
549
|
+
def prop_values(self, window, name, kind, limit=4096):
|
|
550
|
+
actual, fmt, count, after = c_ulong(), c_int(), c_ulong(), c_ulong()
|
|
551
|
+
data = ctypes.POINTER(ctypes.c_ubyte)()
|
|
552
|
+
status = self.x.XGetWindowProperty(self.dpy, window, self.atom(name), 0, limit, 0, kind,
|
|
553
|
+
ctypes.byref(actual), ctypes.byref(fmt), ctypes.byref(count),
|
|
554
|
+
ctypes.byref(after), ctypes.byref(data))
|
|
555
|
+
if status != Success or not data:
|
|
556
|
+
return []
|
|
557
|
+
try:
|
|
558
|
+
if fmt.value == 32: # format-32 properties arrive as C longs, even on 64-bit
|
|
559
|
+
values = ctypes.cast(data, ctypes.POINTER(c_ulong))
|
|
560
|
+
return [values[i] for i in range(count.value)]
|
|
561
|
+
if fmt.value == 8:
|
|
562
|
+
return ctypes.string_at(data, count.value)
|
|
563
|
+
return []
|
|
564
|
+
finally:
|
|
565
|
+
self.x.XFree(data)
|
|
566
|
+
|
|
567
|
+
# -- window creation
|
|
568
|
+
def _window(self, x, y, w, h, override, mask):
|
|
569
|
+
attrs = XSetWindowAttributes()
|
|
570
|
+
attrs.background_pixel, attrs.border_pixel = 0, 0
|
|
571
|
+
attrs.override_redirect, attrs.colormap, attrs.event_mask = 1 if override else 0, self.colormap, mask
|
|
572
|
+
win = self.x.XCreateWindow(self.dpy, self.root, x, y, max(1, w), max(1, h), 0, 32, InputOutput,
|
|
573
|
+
self.visual, CWBackPixel | CWBorderPixel | CWOverrideRedirect | CWEventMask
|
|
574
|
+
| CWColormap, ctypes.byref(attrs))
|
|
575
|
+
if not win:
|
|
576
|
+
raise OverlayError('Cannot create overlay window')
|
|
577
|
+
self.windows.append(win)
|
|
578
|
+
pid = c_ulong(os.getpid())
|
|
579
|
+
self.x.XChangeProperty(self.dpy, win, self.atom('_NET_WM_PID'), XA_CARDINAL, 32, PropModeReplace,
|
|
580
|
+
ctypes.cast(ctypes.byref(pid), c_void_p), 1)
|
|
581
|
+
return win
|
|
582
|
+
|
|
583
|
+
def _gc(self, win):
|
|
584
|
+
gc = self.x.XCreateGC(self.dpy, win, 0, None)
|
|
585
|
+
self.gcs.append(gc)
|
|
586
|
+
return gc
|
|
587
|
+
|
|
588
|
+
def create(self):
|
|
589
|
+
self.pet = self._window(0, 0, 1, 1, True, ExposureMask)
|
|
590
|
+
kind = c_ulong(self.atom('_NET_WM_WINDOW_TYPE_DND')) # compositors draw no shadow/frame for DND
|
|
591
|
+
self.x.XChangeProperty(self.dpy, self.pet, self.atom('_NET_WM_WINDOW_TYPE'), XA_ATOM, 32, PropModeReplace,
|
|
592
|
+
ctypes.cast(ctypes.byref(kind), c_void_p), 1)
|
|
593
|
+
# Empty input shape: every pointer event passes through to the target.
|
|
594
|
+
self.x.XShapeCombineRectangles(self.dpy, self.pet, ShapeInput, 0, 0, None, 0, ShapeSet, Unsorted)
|
|
595
|
+
self.pet_gc = self._gc(self.pet)
|
|
596
|
+
self.bar = self._window(0, 0, 360, 34, True, ExposureMask | ButtonPressMask)
|
|
597
|
+
self.bar_gc = self._gc(self.bar)
|
|
598
|
+
if self.checked(0):
|
|
599
|
+
raise OverlayError('Cannot create overlay windows')
|
|
600
|
+
|
|
601
|
+
def input_shape_rectangles(self, win):
|
|
602
|
+
count, order = c_int(), c_int()
|
|
603
|
+
rects = self.x.XShapeGetRectangles(self.dpy, win, ShapeInput, ctypes.byref(count), ctypes.byref(order))
|
|
604
|
+
if rects:
|
|
605
|
+
self.x.XFree(rects)
|
|
606
|
+
return count.value
|
|
607
|
+
|
|
608
|
+
# -- pet painting
|
|
609
|
+
def paint_pet(self, x, y, w, h, data):
|
|
610
|
+
self.x.XMoveResizeWindow(self.dpy, self.pet, x, y, w, h)
|
|
611
|
+
if not self.pet_mapped:
|
|
612
|
+
self.x.XMapRaised(self.dpy, self.pet)
|
|
613
|
+
self.pet_mapped = True
|
|
614
|
+
self.last_image = (w, h, data)
|
|
615
|
+
self._put(self.pet, self.pet_gc, w, h, data)
|
|
616
|
+
|
|
617
|
+
def _put(self, win, gc, w, h, data):
|
|
618
|
+
image = XImage()
|
|
619
|
+
image.width, image.height, image.format, image.depth = w, h, ZPixmap, 32
|
|
620
|
+
image.byte_order, image.bitmap_unit, image.bitmap_bit_order, image.bitmap_pad = LSBFirst, 32, LSBFirst, 32
|
|
621
|
+
image.bytes_per_line, image.bits_per_pixel = w * 4, 32
|
|
622
|
+
image.red_mask, image.green_mask, image.blue_mask = 0xff0000, 0xff00, 0xff
|
|
623
|
+
buffer = ctypes.c_char_p(data) # borrows the bytes; XPutImage copies them synchronously
|
|
624
|
+
image.data = ctypes.cast(buffer, c_void_p)
|
|
625
|
+
if not self.x.XInitImage(ctypes.byref(image)):
|
|
626
|
+
raise OverlayError('XInitImage rejected the ARGB32 image')
|
|
627
|
+
self.x.XPutImage(self.dpy, win, gc, ctypes.byref(image), 0, 0, 0, 0, w, h)
|
|
628
|
+
self.x.XFlush(self.dpy)
|
|
629
|
+
|
|
630
|
+
def hide_pet(self):
|
|
631
|
+
self.last_image = None
|
|
632
|
+
if self.pet_mapped:
|
|
633
|
+
self.x.XUnmapWindow(self.dpy, self.pet)
|
|
634
|
+
self.pet_mapped = False
|
|
635
|
+
|
|
636
|
+
# -- control strip
|
|
637
|
+
def show_bar(self, canvas, paused):
|
|
638
|
+
width, height = 6 + 58 * len(self.BAR_BUTTONS) + 120, 34
|
|
639
|
+
x = max(canvas[0], canvas[0] + canvas[2] - width - 10)
|
|
640
|
+
geometry = (x, canvas[1] + 10, paused)
|
|
641
|
+
if geometry != self.bar_canvas:
|
|
642
|
+
self.bar_canvas, self.bar_paused = geometry, paused
|
|
643
|
+
self.x.XMoveResizeWindow(self.dpy, self.bar, x, canvas[1] + 10, width, height)
|
|
644
|
+
self.draw_bar()
|
|
645
|
+
if not self.bar_mapped:
|
|
646
|
+
self.x.XMapRaised(self.dpy, self.bar)
|
|
647
|
+
self.bar_mapped = True
|
|
648
|
+
now = time.time()
|
|
649
|
+
if now - self.last_raise >= RAISE_INTERVAL: # the WM may restack the active target over us
|
|
650
|
+
self.last_raise = now
|
|
651
|
+
if self.pet_mapped:
|
|
652
|
+
self.x.XRaiseWindow(self.dpy, self.pet)
|
|
653
|
+
self.x.XRaiseWindow(self.dpy, self.bar)
|
|
654
|
+
|
|
655
|
+
def hide_bar(self):
|
|
656
|
+
if self.bar_mapped:
|
|
657
|
+
self.x.XUnmapWindow(self.dpy, self.bar)
|
|
658
|
+
self.bar_mapped, self.bar_canvas = False, None
|
|
659
|
+
|
|
660
|
+
def status_changed(self, text):
|
|
661
|
+
self.status = text
|
|
662
|
+
if self.bar_mapped:
|
|
663
|
+
self.draw_bar()
|
|
664
|
+
|
|
665
|
+
def draw_bar(self):
|
|
666
|
+
gc, width = self.bar_gc, 6 + 58 * len(self.BAR_BUTTONS) + 120
|
|
667
|
+
self.x.XSetForeground(self.dpy, gc, 0xff1c1e22)
|
|
668
|
+
self.x.XFillRectangle(self.dpy, self.bar, gc, 0, 0, width, 34)
|
|
669
|
+
for i, (name, label) in enumerate(self.BAR_BUTTONS):
|
|
670
|
+
if name == 'pause' and self.bar_paused:
|
|
671
|
+
label = 'Resume'
|
|
672
|
+
self.x.XSetForeground(self.dpy, gc, 0xff30333a)
|
|
673
|
+
self.x.XFillRectangle(self.dpy, self.bar, gc, 6 + 58 * i, 4, 54, 26)
|
|
674
|
+
self.x.XSetForeground(self.dpy, gc, 0xffffffff)
|
|
675
|
+
self.x.XDrawString(self.dpy, self.bar, gc, 12 + 58 * i, 21, label.encode(), len(label))
|
|
676
|
+
status = ascii_label(self.status, 18)
|
|
677
|
+
self.x.XDrawString(self.dpy, self.bar, gc, 8 + 58 * len(self.BAR_BUTTONS), 21, status.encode(), len(status))
|
|
678
|
+
|
|
679
|
+
def bar_hit(self, x, y):
|
|
680
|
+
if 4 <= y <= 30 and x >= 6:
|
|
681
|
+
index, offset = divmod(x - 6, 58)
|
|
682
|
+
if index < len(self.BAR_BUTTONS) and offset < 54:
|
|
683
|
+
return self.BAR_BUTTONS[index][0]
|
|
684
|
+
return None
|
|
685
|
+
|
|
686
|
+
# -- explicit window picker (EWMH client list; titles/process names only)
|
|
687
|
+
def client_windows(self):
|
|
688
|
+
mine, result = os.getpid(), []
|
|
689
|
+
for win in self.prop_values(self.root, '_NET_CLIENT_LIST', XA_WINDOW):
|
|
690
|
+
if win in self.windows:
|
|
691
|
+
continue
|
|
692
|
+
pids = self.prop_values(win, '_NET_WM_PID', XA_CARDINAL)
|
|
693
|
+
if self.checked(win):
|
|
694
|
+
continue
|
|
695
|
+
pid = pids[0] if pids else None
|
|
696
|
+
if pid == mine:
|
|
697
|
+
continue
|
|
698
|
+
name = self.prop_values(win, '_NET_WM_NAME', self.atom('UTF8_STRING'))
|
|
699
|
+
if isinstance(name, bytes) and name:
|
|
700
|
+
title = name.decode('utf-8', 'replace')
|
|
701
|
+
else:
|
|
702
|
+
name = self.prop_values(win, 'WM_NAME', XA_STRING)
|
|
703
|
+
title = name.decode('latin-1') if isinstance(name, bytes) else ''
|
|
704
|
+
if self.checked(win) or not title:
|
|
705
|
+
continue
|
|
706
|
+
process = ''
|
|
707
|
+
if pid:
|
|
708
|
+
try:
|
|
709
|
+
with open('/proc/%d/comm' % pid) as handle:
|
|
710
|
+
process = handle.read(64).strip()
|
|
711
|
+
except (IOError, OSError):
|
|
712
|
+
pass
|
|
713
|
+
terminal = any(word == process.lower() or (len(word) > 2 and word in process.lower()) for word in TERMINALS)
|
|
714
|
+
result.append({'window': win, 'pid': pid, 'terminal': terminal,
|
|
715
|
+
'label': ('[terminal] ' if terminal else '') + title + (' - ' + process if process else '')})
|
|
716
|
+
result.sort(key=lambda item: (not item['terminal'], item['label'].lower()))
|
|
717
|
+
return result
|
|
718
|
+
|
|
719
|
+
PICKER_W, PICKER_ROWS = 600, 14
|
|
720
|
+
|
|
721
|
+
def open_picker(self):
|
|
722
|
+
if self.picker:
|
|
723
|
+
return
|
|
724
|
+
self.picker_items, self.picker_index = self.client_windows(), 0
|
|
725
|
+
height = 72 + 20 * self.PICKER_ROWS + 44
|
|
726
|
+
self.picker = self._window(0, 0, self.PICKER_W, height, False, ExposureMask | ButtonPressMask | StructureNotifyMask)
|
|
727
|
+
self.picker_gc = self._gc(self.picker)
|
|
728
|
+
self.x.XStoreName(self.dpy, self.picker, b'Retrieviewer - choose a window')
|
|
729
|
+
hints = XWMHints()
|
|
730
|
+
hints.flags, hints.input = InputHint, 0 # ask the WM not to move keyboard focus to the picker
|
|
731
|
+
self.x.XSetWMHints(self.dpy, self.picker, ctypes.byref(hints))
|
|
732
|
+
protocol = c_ulong(self.atom('WM_DELETE_WINDOW'))
|
|
733
|
+
self.x.XSetWMProtocols(self.dpy, self.picker, ctypes.byref(protocol), 1)
|
|
734
|
+
self.x.XMapRaised(self.dpy, self.picker)
|
|
735
|
+
self.x.XFlush(self.dpy)
|
|
736
|
+
|
|
737
|
+
def picker_visible(self):
|
|
738
|
+
return bool(self.picker and self.picker_mapped)
|
|
739
|
+
|
|
740
|
+
def wait_for_picker(self, timeout=1.0):
|
|
741
|
+
end = time.time() + timeout
|
|
742
|
+
while self.picker and not self.picker_mapped and time.time() < end:
|
|
743
|
+
self.pump()
|
|
744
|
+
time.sleep(0.01)
|
|
745
|
+
|
|
746
|
+
def close_picker(self, attach):
|
|
747
|
+
if not self.picker:
|
|
748
|
+
return
|
|
749
|
+
if attach and self.picker_items:
|
|
750
|
+
item = self.picker_items[self.picker_index]
|
|
751
|
+
self.target, self.target_pid = item['window'], item['pid']
|
|
752
|
+
self._destroy(self.picker)
|
|
753
|
+
self.gcs.remove(self.picker_gc)
|
|
754
|
+
self.x.XFreeGC(self.dpy, self.picker_gc)
|
|
755
|
+
self.picker, self.picker_mapped = 0, False
|
|
756
|
+
self.companion.picker_closed(bool(attach and self.picker_items))
|
|
757
|
+
|
|
758
|
+
def draw_picker(self):
|
|
759
|
+
gc, d, w = self.picker_gc, self.dpy, self.picker
|
|
760
|
+
self.x.XSetForeground(d, gc, 0xff202227)
|
|
761
|
+
self.x.XFillRectangle(d, w, gc, 0, 0, self.PICKER_W, 72 + 20 * self.PICKER_ROWS + 44)
|
|
762
|
+
self.x.XSetForeground(d, gc, 0xffffffff)
|
|
763
|
+
for i, line in enumerate(('Choose the window Retrieviewer should keep company.',
|
|
764
|
+
'Only its position/size is followed; contents are never read.')):
|
|
765
|
+
self.x.XDrawString(d, w, gc, 14, 22 + 18 * i, line.encode(), len(line))
|
|
766
|
+
if not self.picker_items:
|
|
767
|
+
text = '(no windows found - press Refresh)'
|
|
768
|
+
self.x.XDrawString(d, w, gc, 14, 90, text.encode(), len(text))
|
|
769
|
+
for i, item in enumerate(self.picker_items[:self.PICKER_ROWS]):
|
|
770
|
+
top = 64 + 20 * i
|
|
771
|
+
if i == self.picker_index:
|
|
772
|
+
self.x.XSetForeground(d, gc, 0xff3b5bdb)
|
|
773
|
+
self.x.XFillRectangle(d, w, gc, 8, top, self.PICKER_W - 16, 20)
|
|
774
|
+
self.x.XSetForeground(d, gc, 0xffffffff)
|
|
775
|
+
label = ascii_label(item['label'], 92)
|
|
776
|
+
self.x.XDrawString(d, w, gc, 14, top + 15, label.encode(), len(label))
|
|
777
|
+
for label, left in self._picker_buttons():
|
|
778
|
+
self.x.XSetForeground(d, gc, 0xff3a3d45)
|
|
779
|
+
self.x.XFillRectangle(d, w, gc, left, self._button_top(), 120, 30)
|
|
780
|
+
self.x.XSetForeground(d, gc, 0xffffffff)
|
|
781
|
+
self.x.XDrawString(d, w, gc, left + 12, self._button_top() + 20, label.encode(), len(label))
|
|
782
|
+
|
|
783
|
+
def _button_top(self):
|
|
784
|
+
return 72 + 20 * self.PICKER_ROWS + 6
|
|
785
|
+
|
|
786
|
+
def _picker_buttons(self):
|
|
787
|
+
return (('Refresh', 14), ('Attach', self.PICKER_W - 268), ('Cancel', self.PICKER_W - 134))
|
|
788
|
+
|
|
789
|
+
def picker_click(self, x, y):
|
|
790
|
+
if 64 <= y < 64 + 20 * self.PICKER_ROWS:
|
|
791
|
+
index = (y - 64) // 20
|
|
792
|
+
if index < min(len(self.picker_items), self.PICKER_ROWS):
|
|
793
|
+
self.picker_index = index
|
|
794
|
+
self.draw_picker()
|
|
795
|
+
return
|
|
796
|
+
top = self._button_top()
|
|
797
|
+
for label, left in self._picker_buttons():
|
|
798
|
+
if left <= x < left + 120 and top <= y < top + 30:
|
|
799
|
+
if label == 'Refresh':
|
|
800
|
+
self.picker_items, self.picker_index = self.client_windows(), 0
|
|
801
|
+
self.draw_picker()
|
|
802
|
+
else:
|
|
803
|
+
self.close_picker(label == 'Attach')
|
|
804
|
+
return
|
|
805
|
+
|
|
806
|
+
# -- target tracking (read-only queries; never configures the target)
|
|
807
|
+
def has_target(self):
|
|
808
|
+
return self.target is not None
|
|
809
|
+
|
|
810
|
+
def target_state(self):
|
|
811
|
+
if self.target is None:
|
|
812
|
+
return None
|
|
813
|
+
win = self.target
|
|
814
|
+
clients = self.prop_values(self.root, '_NET_CLIENT_LIST', XA_WINDOW)
|
|
815
|
+
pids = self.prop_values(win, '_NET_WM_PID', XA_CARDINAL)
|
|
816
|
+
attrs = XWindowAttributes()
|
|
817
|
+
ok = self.x.XGetWindowAttributes(self.dpy, win, ctypes.byref(attrs))
|
|
818
|
+
dx, dy, child = c_int(), c_int(), c_ulong()
|
|
819
|
+
moved = self.x.XTranslateCoordinates(self.dpy, win, self.root, 0, 0, ctypes.byref(dx), ctypes.byref(dy),
|
|
820
|
+
ctypes.byref(child))
|
|
821
|
+
states = self.prop_values(win, '_NET_WM_STATE', XA_ATOM)
|
|
822
|
+
gone = self.checked(win)
|
|
823
|
+
if gone or not ok or not moved or (win not in clients and not self.owned_target) \
|
|
824
|
+
or (self.target_pid is not None and (pids[:1] or [None])[0] != self.target_pid):
|
|
825
|
+
self.target = self.target_pid = None
|
|
826
|
+
return None
|
|
827
|
+
if attrs.map_state != IsViewable or self.atom('_NET_WM_STATE_HIDDEN') in states:
|
|
828
|
+
return (dx.value, dy.value, 0, 0, False)
|
|
829
|
+
active = self.prop_values(self.root, '_NET_ACTIVE_WINDOW', XA_WINDOW)
|
|
830
|
+
return (dx.value, dy.value, attrs.width, attrs.height, bool(active) and active[0] == win)
|
|
831
|
+
|
|
832
|
+
# -- events
|
|
833
|
+
def pump(self):
|
|
834
|
+
event = XEvent()
|
|
835
|
+
while self.dpy and self.x.XPending(self.dpy):
|
|
836
|
+
self.x.XNextEvent(self.dpy, ctypes.byref(event))
|
|
837
|
+
win, kind = event.xany.window, event.type
|
|
838
|
+
if kind == Expose and event.xexpose.count == 0:
|
|
839
|
+
if win == self.bar:
|
|
840
|
+
self.draw_bar()
|
|
841
|
+
elif win == self.picker:
|
|
842
|
+
self.draw_picker()
|
|
843
|
+
elif win == self.pet and self.last_image:
|
|
844
|
+
self._put(self.pet, self.pet_gc, *self.last_image)
|
|
845
|
+
elif kind == ButtonPress and event.xbutton.button == 1:
|
|
846
|
+
if win == self.bar and self.companion:
|
|
847
|
+
name = self.bar_hit(event.xbutton.x, event.xbutton.y)
|
|
848
|
+
if name:
|
|
849
|
+
self.companion.press(name)
|
|
850
|
+
elif win == self.picker:
|
|
851
|
+
self.picker_click(event.xbutton.x, event.xbutton.y)
|
|
852
|
+
elif kind == MapNotify and win == self.picker and not self.picker_mapped:
|
|
853
|
+
self.picker_mapped = True
|
|
854
|
+
if self.companion and self.companion.announced:
|
|
855
|
+
self.companion.picker_shown(True)
|
|
856
|
+
elif kind == ClientMessage and win == self.picker and event.xclient.l[0] == self.atom('WM_DELETE_WINDOW'):
|
|
857
|
+
self.close_picker(False)
|
|
858
|
+
if not self.dpy:
|
|
859
|
+
return
|
|
860
|
+
if self.errors and self.dpy:
|
|
861
|
+
self.checked(0)
|
|
862
|
+
|
|
863
|
+
def fileno(self):
|
|
864
|
+
return self.x.XConnectionNumber(self.dpy)
|
|
865
|
+
|
|
866
|
+
def _destroy(self, win):
|
|
867
|
+
if win in self.windows:
|
|
868
|
+
self.windows.remove(win)
|
|
869
|
+
self.x.XDestroyWindow(self.dpy, win)
|
|
870
|
+
|
|
871
|
+
def close(self):
|
|
872
|
+
"""Release every owned X resource. Never touches the target window."""
|
|
873
|
+
if not self.dpy:
|
|
874
|
+
return
|
|
875
|
+
for gc in self.gcs:
|
|
876
|
+
self.x.XFreeGC(self.dpy, gc)
|
|
877
|
+
for win in list(self.windows):
|
|
878
|
+
self._destroy(win)
|
|
879
|
+
if self.colormap:
|
|
880
|
+
self.x.XFreeColormap(self.dpy, self.colormap)
|
|
881
|
+
self.x.XSync(self.dpy, 1)
|
|
882
|
+
self.x.XCloseDisplay(self.dpy)
|
|
883
|
+
self.dpy, self.gcs, self.windows, self.colormap = None, [], [], 0
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
# ---------------------------------------------------------------- process glue
|
|
887
|
+
_output_lock = threading.Lock()
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def emit(message):
|
|
891
|
+
with _output_lock:
|
|
892
|
+
try:
|
|
893
|
+
sys.stdout.write(encode(message) + '\n')
|
|
894
|
+
sys.stdout.flush()
|
|
895
|
+
except (IOError, OSError, ValueError):
|
|
896
|
+
os._exit(0)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def read_input(inbox, wake):
|
|
900
|
+
"""Stdin reader thread. Xlib is only ever used from the main thread."""
|
|
901
|
+
stream = sys.stdin.buffer
|
|
902
|
+
try:
|
|
903
|
+
while True:
|
|
904
|
+
line = stream.readline(MAX_LINE + 2)
|
|
905
|
+
if not line:
|
|
906
|
+
break
|
|
907
|
+
if len(line) > MAX_LINE:
|
|
908
|
+
inbox.append(OverlayError('Oversized overlay message'))
|
|
909
|
+
break
|
|
910
|
+
try:
|
|
911
|
+
inbox.append(json.loads(line.decode('utf-8')))
|
|
912
|
+
except ValueError:
|
|
913
|
+
inbox.append(OverlayError('Malformed overlay message'))
|
|
914
|
+
break
|
|
915
|
+
os.write(wake, b'.')
|
|
916
|
+
except (IOError, OSError):
|
|
917
|
+
pass
|
|
918
|
+
inbox.append(None) # stdin EOF: exit and release resources
|
|
919
|
+
try:
|
|
920
|
+
os.write(wake, b'.')
|
|
921
|
+
except OSError:
|
|
922
|
+
pass
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def single_instance(test):
|
|
926
|
+
"""Abstract unix socket lock: released by the kernel even after a crash; no files."""
|
|
927
|
+
lock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
928
|
+
name = '\0bitefoundry-retrieviewer-overlay-%d' % os.getuid() + ('-diag-%d' % os.getpid() if test else '')
|
|
929
|
+
try:
|
|
930
|
+
lock.bind(name)
|
|
931
|
+
except OSError:
|
|
932
|
+
lock.close()
|
|
933
|
+
raise OverlayError('A Retrieviewer companion is already running')
|
|
934
|
+
return lock
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def run(argv):
|
|
938
|
+
test = argv == ['--ipc-test']
|
|
939
|
+
cell = 16
|
|
940
|
+
if argv[:1] == ['--cell'] and len(argv) == 2 and argv[1].isdigit():
|
|
941
|
+
cell = int(argv[1])
|
|
942
|
+
elif argv and not test:
|
|
943
|
+
raise OverlayError('Unsupported native painter argument')
|
|
944
|
+
problem = session_problem(os.environ)
|
|
945
|
+
if problem:
|
|
946
|
+
raise OverlayError(problem)
|
|
947
|
+
lock, ui = single_instance(test), None
|
|
948
|
+
try:
|
|
949
|
+
ui = XDisplay()
|
|
950
|
+
ui.create()
|
|
951
|
+
companion = Companion(ui, emit, cell=cell, test_mode=test)
|
|
952
|
+
ui.companion = companion
|
|
953
|
+
if test: # owned off-screen stand-in target; no user window is touched
|
|
954
|
+
ui.target = ui._window(-12000, -12000, 1280, 800, True, 0)
|
|
955
|
+
ui.owned_target = True
|
|
956
|
+
ui.x.XMapRaised(ui.dpy, ui.target)
|
|
957
|
+
inbox, (wake_r, wake_w) = deque(), os.pipe()
|
|
958
|
+
reader = threading.Thread(target=read_input, args=(inbox, wake_w))
|
|
959
|
+
reader.daemon = True
|
|
960
|
+
reader.start()
|
|
961
|
+
companion.start()
|
|
962
|
+
last_track = 0.0
|
|
963
|
+
while not companion.quitting:
|
|
964
|
+
ui.pump()
|
|
965
|
+
if len(inbox) > MAX_QUEUED + 1:
|
|
966
|
+
raise OverlayError('Overlay input queue overflow')
|
|
967
|
+
while inbox and not companion.quitting:
|
|
968
|
+
message = inbox.popleft()
|
|
969
|
+
if message is None:
|
|
970
|
+
companion.quit()
|
|
971
|
+
elif isinstance(message, Exception):
|
|
972
|
+
raise message
|
|
973
|
+
else:
|
|
974
|
+
companion.handle(message)
|
|
975
|
+
if companion.quitting:
|
|
976
|
+
break
|
|
977
|
+
now = time.time()
|
|
978
|
+
if now - last_track >= TRACK_INTERVAL:
|
|
979
|
+
last_track = now
|
|
980
|
+
companion.track()
|
|
981
|
+
ui.x.XFlush(ui.dpy)
|
|
982
|
+
ready, _, _ = select.select([ui.fileno(), wake_r], [], [], TRACK_INTERVAL)
|
|
983
|
+
if wake_r in ready:
|
|
984
|
+
os.read(wake_r, 4096)
|
|
985
|
+
return 0
|
|
986
|
+
finally:
|
|
987
|
+
if ui:
|
|
988
|
+
ui.close()
|
|
989
|
+
lock.close()
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def check(env=None):
|
|
993
|
+
"""--check-json: environment diagnostics only; never creates windows."""
|
|
994
|
+
env = os.environ if env is None else env
|
|
995
|
+
report = {'type': 'check', 'python': '.'.join(map(str, sys.version_info[:3])), 'platform': sys.platform,
|
|
996
|
+
'ok': False, 'errors': []}
|
|
997
|
+
problem = session_problem(env)
|
|
998
|
+
if problem:
|
|
999
|
+
report['errors'].append(problem)
|
|
1000
|
+
return report
|
|
1001
|
+
try:
|
|
1002
|
+
ui = XDisplay(require_compositor=True)
|
|
1003
|
+
report.update(compositor=ui.compositor, argb32=True, shapeInput=True, ewmh=True)
|
|
1004
|
+
ui.close()
|
|
1005
|
+
report['ok'] = True
|
|
1006
|
+
except OverlayError as error:
|
|
1007
|
+
report['errors'].append(str(error))
|
|
1008
|
+
return report
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def self_test(display_name):
|
|
1012
|
+
"""Owned windows only on an explicitly named (e.g. Xvfb) display: no user window, focus or input."""
|
|
1013
|
+
ui = XDisplay(display_name, require_compositor=False)
|
|
1014
|
+
result = {'type': 'self-test', 'display': display_name, 'compositor': ui.compositor}
|
|
1015
|
+
try:
|
|
1016
|
+
focus_before, revert = c_ulong(), c_int()
|
|
1017
|
+
ui.x.XGetInputFocus(ui.dpy, ctypes.byref(focus_before), ctypes.byref(revert))
|
|
1018
|
+
ui.create()
|
|
1019
|
+
target = ui._window(40, 30, 800, 600, True, 0)
|
|
1020
|
+
ui.x.XMapRaised(ui.dpy, target)
|
|
1021
|
+
ui.target, ui.owned_target = target, True
|
|
1022
|
+
sent = []
|
|
1023
|
+
companion = Companion(ui, sent.append, cell=16, test_mode=True)
|
|
1024
|
+
ui.companion = companion
|
|
1025
|
+
companion.start()
|
|
1026
|
+
viewport = sent[-1]
|
|
1027
|
+
frame = {'type': 'frame', 'revision': viewport['revision'], 'x': 2, 'y': 1, 'columns': 3, 'rows': 2,
|
|
1028
|
+
'text': '⣿⠁⢀\n⠀⣿⡇'}
|
|
1029
|
+
companion.handle(frame)
|
|
1030
|
+
|
|
1031
|
+
def pet_origin():
|
|
1032
|
+
dx, dy, child = c_int(), c_int(), c_ulong()
|
|
1033
|
+
ui.x.XSync(ui.dpy, 0)
|
|
1034
|
+
ui.x.XTranslateCoordinates(ui.dpy, ui.pet, ui.root, 0, 0, ctypes.byref(dx), ctypes.byref(dy), ctypes.byref(child))
|
|
1035
|
+
return dx.value, dy.value
|
|
1036
|
+
result['painted'] = sent[-1] == {'type': 'ack', 'painted': True} and companion.painted_dots == 22
|
|
1037
|
+
result['followGeometry'] = pet_origin() == (40 + 2 * 16, 30 + 1 * 32)
|
|
1038
|
+
result['inputShapeEmpty'] = ui.input_shape_rectangles(ui.pet) == 0
|
|
1039
|
+
ui.x.XMoveResizeWindow(ui.dpy, target, 140, 90, 900, 640)
|
|
1040
|
+
ui.x.XSync(ui.dpy, 0)
|
|
1041
|
+
companion.track()
|
|
1042
|
+
stale = dict(frame, revision=viewport['revision'])
|
|
1043
|
+
companion.handle(stale)
|
|
1044
|
+
result['staleFrameDiscarded'] = sent[-1] == {'type': 'ack', 'painted': False} and not ui.pet_mapped
|
|
1045
|
+
companion.handle(dict(frame, revision=companion.revision))
|
|
1046
|
+
result['followMoved'] = pet_origin() == (140 + 32, 90 + 32)
|
|
1047
|
+
ui.x.XUnmapWindow(ui.dpy, target)
|
|
1048
|
+
ui.x.XSync(ui.dpy, 0)
|
|
1049
|
+
companion.track()
|
|
1050
|
+
result['hideWhenUnmapped'] = not ui.pet_mapped and not companion.visible
|
|
1051
|
+
ui._destroy(target)
|
|
1052
|
+
ui.x.XSync(ui.dpy, 0)
|
|
1053
|
+
ui.owned_target = False
|
|
1054
|
+
companion.track()
|
|
1055
|
+
result['targetGone'] = ui.target is None and sent[-1].get('selected') is False
|
|
1056
|
+
focus_after = c_ulong()
|
|
1057
|
+
ui.x.XGetInputFocus(ui.dpy, ctypes.byref(focus_after), ctypes.byref(revert))
|
|
1058
|
+
result['focusPreserved'] = focus_after.value == focus_before.value
|
|
1059
|
+
companion.quit()
|
|
1060
|
+
result['released'] = ui.dpy is None and sent[-1] == {'type': 'quit'}
|
|
1061
|
+
result['passed'] = all(result[k] for k in ('painted', 'followGeometry', 'inputShapeEmpty', 'staleFrameDiscarded',
|
|
1062
|
+
'followMoved', 'hideWhenUnmapped', 'targetGone', 'focusPreserved',
|
|
1063
|
+
'released'))
|
|
1064
|
+
return result
|
|
1065
|
+
finally:
|
|
1066
|
+
ui.close()
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def main(argv):
|
|
1070
|
+
try:
|
|
1071
|
+
if argv == ['--check-json']:
|
|
1072
|
+
report = check()
|
|
1073
|
+
print(encode(report))
|
|
1074
|
+
return 0 if report['ok'] else 2
|
|
1075
|
+
if argv[:1] == ['--self-test']:
|
|
1076
|
+
if len(argv) != 2 or not argv[1].startswith(':'):
|
|
1077
|
+
raise OverlayError('--self-test requires an explicit isolated display such as :99 (Xvfb)')
|
|
1078
|
+
problem = session_problem({'DISPLAY': argv[1]})
|
|
1079
|
+
if problem:
|
|
1080
|
+
raise OverlayError(problem)
|
|
1081
|
+
report = self_test(argv[1])
|
|
1082
|
+
print(encode(report))
|
|
1083
|
+
return 0 if report['passed'] else 1
|
|
1084
|
+
return run(argv)
|
|
1085
|
+
except OverlayError as error:
|
|
1086
|
+
emit({'type': 'error', 'message': str(error)})
|
|
1087
|
+
return 2
|
|
1088
|
+
except Exception as error: # never leave the Node side waiting silently
|
|
1089
|
+
emit({'type': 'error', 'message': '%s: %s' % (type(error).__name__, error)})
|
|
1090
|
+
return 1
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
if __name__ == '__main__':
|
|
1094
|
+
sys.exit(main(sys.argv[1:]))
|