@desklink/host 0.1.0 → 0.2.0
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/NOTICE +3 -0
- package/README.md +72 -16
- package/bin/desklink-host.mjs +14 -3
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +5 -1
- package/dist/bridge.js.map +1 -1
- package/dist/engineProcess.d.ts.map +1 -1
- package/dist/engineProcess.js +17 -5
- package/dist/engineProcess.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/keeper.d.ts +52 -0
- package/dist/keeper.d.ts.map +1 -0
- package/dist/keeper.js +101 -0
- package/dist/keeper.js.map +1 -0
- package/dist/protocol.d.ts +42 -5
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/resolveEngine.d.ts +3 -2
- package/dist/resolveEngine.d.ts.map +1 -1
- package/dist/resolveEngine.js +20 -5
- package/dist/resolveEngine.js.map +1 -1
- package/docs/BOUNDARY.md +11 -6
- package/docs/PROTOCOL.md +297 -33
- package/engine/Cargo.lock +196 -1
- package/engine/Cargo.toml +26 -5
- package/engine/build.rs +98 -23
- package/engine/native/agent_overlay_mac.m +133 -0
- package/engine/native/agent_overlay_wayland.c +192 -0
- package/engine/native/agent_overlay_x11.c +103 -0
- package/engine/native/mac_stream.mm +188 -0
- package/engine/src/capture.rs +169 -19
- package/engine/src/capture_mac.rs +145 -0
- package/engine/src/clipboard_mac.rs +182 -0
- package/engine/src/convert.rs +57 -1
- package/engine/src/indicator.rs +162 -0
- package/engine/src/input.rs +12 -0
- package/engine/src/input_mac.rs +312 -0
- package/engine/src/keeper.rs +622 -0
- package/engine/src/keymap.rs +25 -0
- package/engine/src/keymap_mac.rs +209 -0
- package/engine/src/mac.rs +722 -0
- package/engine/src/main.rs +158 -2
- package/engine/src/peer.rs +429 -22
- package/engine/src/portal.rs +4 -0
- package/engine/src/portal_mac.rs +68 -0
- package/engine/src/protocol.rs +186 -3
- package/engine/src/session.rs +1307 -100
- package/engine/src/x11.rs +23 -3
- package/engine/src/x11_mac.rs +40 -0
- package/examples/reference-client.html +33 -4
- package/package.json +2 -2
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
//! The display keeper: a kiosk window manager for one X screen.
|
|
2
|
+
//!
|
|
3
|
+
//! A consumer that owns a private X server (one `Xvfb` per screen it created)
|
|
4
|
+
//! runs the keeper on it. The keeper takes SubstructureRedirect on the root
|
|
5
|
+
//! window — it is that screen's window manager — fills the screen with every
|
|
6
|
+
//! normal top-level window, parks tiny helper windows off-screen, keeps the
|
|
7
|
+
//! input focus on the newest window, and reports the screen's windows as one
|
|
8
|
+
//! JSON line per change on stdout.
|
|
9
|
+
//!
|
|
10
|
+
//! That report is what lets a consumer say "a browser is open on this screen"
|
|
11
|
+
//! without asking any browser tool: it reads what is really mapped.
|
|
12
|
+
//!
|
|
13
|
+
//! The contract is a stdout JSON-lines stream and nothing else; there are no
|
|
14
|
+
//! stdin commands. Authentication is the ambient `XAUTHORITY`. The keeper exits
|
|
15
|
+
//! by itself when the display goes away, which is the normal end of its life,
|
|
16
|
+
//! and reports fatal refusals as one error line before exiting.
|
|
17
|
+
|
|
18
|
+
use anyhow::{anyhow, Result};
|
|
19
|
+
use std::collections::VecDeque;
|
|
20
|
+
use std::io::{BufWriter, Write};
|
|
21
|
+
use std::time::{Duration, Instant};
|
|
22
|
+
use x11rb::connection::Connection;
|
|
23
|
+
use x11rb::errors::ReplyError;
|
|
24
|
+
use x11rb::protocol::xproto::{
|
|
25
|
+
Atom, AtomEnum, ChangeWindowAttributesAux, ConfigureWindowAux, ConnectionExt as _, EventMask,
|
|
26
|
+
InputFocus, MapState, StackMode, Window, WindowClass, ACCESS_ERROR,
|
|
27
|
+
};
|
|
28
|
+
use x11rb::protocol::Event;
|
|
29
|
+
use x11rb::rust_connection::RustConnection;
|
|
30
|
+
|
|
31
|
+
/// A top-level window smaller than this on either side is not content. An
|
|
32
|
+
/// emulator draws its side toolbar as a tiny second window; parked off-screen,
|
|
33
|
+
/// it cannot end up in a capture of the screen.
|
|
34
|
+
pub const MIN_CONTENT_SIDE: u16 = 120;
|
|
35
|
+
|
|
36
|
+
/// Where parked windows live: far enough off any real screen to be invisible.
|
|
37
|
+
pub const PARKED_XY: i16 = -30000;
|
|
38
|
+
|
|
39
|
+
/// A change is reported after this much quiet on the screen.
|
|
40
|
+
const DEBOUNCE: Duration = Duration::from_millis(100);
|
|
41
|
+
|
|
42
|
+
/// … but never later than this after the first unreported change, so a busy
|
|
43
|
+
/// screen cannot starve its consumer of reports.
|
|
44
|
+
const MAX_REPORT_DELAY: Duration = Duration::from_millis(300);
|
|
45
|
+
|
|
46
|
+
/// One top-level window on the kept screen, as reported to the consumer.
|
|
47
|
+
pub struct KeeperWindow {
|
|
48
|
+
pub id: u32,
|
|
49
|
+
pub title: Option<String>,
|
|
50
|
+
pub class: Option<String>,
|
|
51
|
+
pub pid: Option<u32>,
|
|
52
|
+
pub width: u16,
|
|
53
|
+
pub height: u16,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// A window this size or larger on both sides is content; anything smaller is
|
|
57
|
+
/// parked, and parked means never focused either.
|
|
58
|
+
fn is_content(width: u16, height: u16) -> bool {
|
|
59
|
+
width >= MIN_CONTENT_SIDE && height >= MIN_CONTENT_SIDE
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Where a top-level window of this size belongs on a screen of `screen_w` by
|
|
63
|
+
/// `screen_h`: filling the screen, or parked off it when it is too small to be
|
|
64
|
+
/// content.
|
|
65
|
+
fn kiosk_geometry(width: u16, height: u16, screen_w: u16, screen_h: u16) -> (i32, i32, u32, u32) {
|
|
66
|
+
if is_content(width, height) {
|
|
67
|
+
(0, 0, u32::from(screen_w), u32::from(screen_h))
|
|
68
|
+
} else {
|
|
69
|
+
(
|
|
70
|
+
i32::from(PARKED_XY),
|
|
71
|
+
i32::from(PARKED_XY),
|
|
72
|
+
u32::from(width),
|
|
73
|
+
u32::from(height),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// `WM_CLASS` is two NUL-separated strings: instance, then class. The class is
|
|
79
|
+
/// the one consumers filter on ("Chromium", "Google-chrome").
|
|
80
|
+
fn decode_wm_class(data: &[u8]) -> Option<String> {
|
|
81
|
+
let mut strings = data
|
|
82
|
+
.split(|&byte| byte == 0)
|
|
83
|
+
.filter(|part| !part.is_empty());
|
|
84
|
+
strings.next()?;
|
|
85
|
+
strings.next().map(latin1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// A title is the trimmed text; empty is no title. The caller decodes the
|
|
89
|
+
/// property's bytes (`_NET_WM_NAME` is UTF-8; the `WM_NAME` fallback Latin-1).
|
|
90
|
+
fn decode_title(title: &str) -> Option<String> {
|
|
91
|
+
let title = title.trim().to_owned();
|
|
92
|
+
(!title.is_empty()).then_some(title)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// `STRING` properties are ISO Latin-1.
|
|
96
|
+
fn latin1(bytes: &[u8]) -> String {
|
|
97
|
+
bytes.iter().map(|&byte| byte as char).collect()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// The stdout line that reports a window list.
|
|
101
|
+
fn window_line(windows: &[KeeperWindow]) -> String {
|
|
102
|
+
serde_json::to_string(&serde_json::json!({
|
|
103
|
+
"windows": windows
|
|
104
|
+
.iter()
|
|
105
|
+
.map(|window| serde_json::json!({
|
|
106
|
+
"id": window.id,
|
|
107
|
+
"title": window.title,
|
|
108
|
+
"class": window.class,
|
|
109
|
+
"pid": window.pid,
|
|
110
|
+
"width": window.width,
|
|
111
|
+
"height": window.height,
|
|
112
|
+
}))
|
|
113
|
+
.collect::<Vec<_>>()
|
|
114
|
+
}))
|
|
115
|
+
.expect("a window report always serialises")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// The display a `keep` invocation names: the value after `--display`, and
|
|
119
|
+
/// nothing else. One spelling, per the documented contract.
|
|
120
|
+
fn display_argument(args: &[String]) -> Option<String> {
|
|
121
|
+
let mut remaining = args.iter();
|
|
122
|
+
while let Some(arg) = remaining.next() {
|
|
123
|
+
if arg == "--display" {
|
|
124
|
+
return remaining.next().cloned();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
None
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// `desklink-host keep --display :N`: keep one display until it goes away. The
|
|
131
|
+
/// display is required on purpose: a bare `keep` must never go hunting for a
|
|
132
|
+
/// screen to manage.
|
|
133
|
+
pub fn command(args: &[String]) -> i32 {
|
|
134
|
+
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
135
|
+
println!("usage: desklink-host keep --display :N");
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
let display = display_argument(args);
|
|
139
|
+
let mut out = BufWriter::new(std::io::stdout().lock());
|
|
140
|
+
match keep(display.as_deref(), &mut out) {
|
|
141
|
+
Ok(()) => 0,
|
|
142
|
+
Err(error) => {
|
|
143
|
+
eprintln!("error: {error:#}");
|
|
144
|
+
1
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// Runs the keeper until the display goes away. A refused startup is reported
|
|
150
|
+
/// as the protocol's one-line error object on stdout and returned as `Err`;
|
|
151
|
+
/// the display going away is a normal end and returns `Ok`.
|
|
152
|
+
pub fn keep(display: Option<&str>, out: &mut impl Write) -> Result<()> {
|
|
153
|
+
let Some(display) = display.filter(|display| !display.is_empty()) else {
|
|
154
|
+
return fatal(out, "no display given");
|
|
155
|
+
};
|
|
156
|
+
let (conn, screen_number) = match RustConnection::connect(Some(display)) {
|
|
157
|
+
Ok(opened) => opened,
|
|
158
|
+
Err(error) => return fatal(out, &format!("cannot open X display {display}: {error}")),
|
|
159
|
+
};
|
|
160
|
+
let Some(screen) = conn.setup().roots.get(screen_number) else {
|
|
161
|
+
return fatal(out, "the X display has no screen");
|
|
162
|
+
};
|
|
163
|
+
let root = screen.root;
|
|
164
|
+
let (screen_w, screen_h) = (screen.width_in_pixels, screen.height_in_pixels);
|
|
165
|
+
|
|
166
|
+
// The claim is the whole election: a server hands SubstructureRedirect to
|
|
167
|
+
// exactly one client, and refuses a second with an Access error.
|
|
168
|
+
let claim = conn.change_window_attributes(
|
|
169
|
+
root,
|
|
170
|
+
&ChangeWindowAttributesAux::new()
|
|
171
|
+
.event_mask(EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY),
|
|
172
|
+
);
|
|
173
|
+
// The display was opened, so a connection that dies during the election
|
|
174
|
+
// is the display going away — the normal end. Only the server's own error
|
|
175
|
+
// replies refuse the keeper.
|
|
176
|
+
let claim = match claim {
|
|
177
|
+
Ok(cookie) => cookie,
|
|
178
|
+
Err(_) => return Ok(()),
|
|
179
|
+
};
|
|
180
|
+
match claim.check() {
|
|
181
|
+
Ok(()) => {}
|
|
182
|
+
Err(ReplyError::X11Error(error)) if error.error_code == ACCESS_ERROR => {
|
|
183
|
+
return fatal(out, "another window manager");
|
|
184
|
+
}
|
|
185
|
+
Err(ReplyError::ConnectionError(_)) => return Ok(()),
|
|
186
|
+
Err(error) => return fatal(out, &format!("cannot keep {display:?}: {error}")),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Created if missing: a fresh Xvfb may not have them yet, and this keeper
|
|
190
|
+
// is the only client that reads them on this screen. With the claim won, a
|
|
191
|
+
// failed request is the display going away — the keeper's normal end.
|
|
192
|
+
let (Some(net_wm_name), Some(utf8_string), Some(net_wm_pid)) = (
|
|
193
|
+
intern(&conn, "_NET_WM_NAME"),
|
|
194
|
+
intern(&conn, "UTF8_STRING"),
|
|
195
|
+
intern(&conn, "_NET_WM_PID"),
|
|
196
|
+
) else {
|
|
197
|
+
return Ok(());
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Windows already on the screen when the keeper starts join the kiosk.
|
|
201
|
+
let Ok(Ok(tree)) = conn.query_tree(root).map(|cookie| cookie.reply()) else {
|
|
202
|
+
return Ok(());
|
|
203
|
+
};
|
|
204
|
+
let mut tracked: VecDeque<Window> = VecDeque::new();
|
|
205
|
+
for window in tree.children {
|
|
206
|
+
track(&conn, window, &mut tracked, screen_w, screen_h);
|
|
207
|
+
}
|
|
208
|
+
let _ = refocus(&conn, &tracked);
|
|
209
|
+
|
|
210
|
+
// The consumer learns the screen's state once at startup, then on change.
|
|
211
|
+
write_line(
|
|
212
|
+
out,
|
|
213
|
+
&window_line(&snapshot(&conn, root, net_wm_name, utf8_string, net_wm_pid)),
|
|
214
|
+
)?;
|
|
215
|
+
|
|
216
|
+
// (first unreported change, when the report is due)
|
|
217
|
+
let mut dirty: Option<(Instant, Instant)> = None;
|
|
218
|
+
loop {
|
|
219
|
+
if let Some((first, due)) = dirty {
|
|
220
|
+
if Instant::now() >= due || Instant::now().duration_since(first) >= MAX_REPORT_DELAY {
|
|
221
|
+
dirty = None;
|
|
222
|
+
let line =
|
|
223
|
+
window_line(&snapshot(&conn, root, net_wm_name, utf8_string, net_wm_pid));
|
|
224
|
+
write_line(out, &line)?;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// From here on, every connection error means the display is gone (X
|
|
229
|
+
// protocol errors surface through replies, and the only checked reply
|
|
230
|
+
// is the claim above). That is the keeper's normal end.
|
|
231
|
+
if conn.flush().is_err() {
|
|
232
|
+
return Ok(());
|
|
233
|
+
}
|
|
234
|
+
let event = if dirty.is_some() {
|
|
235
|
+
match conn.poll_for_event() {
|
|
236
|
+
Ok(Some(event)) => event,
|
|
237
|
+
Ok(None) => {
|
|
238
|
+
std::thread::sleep(Duration::from_millis(5));
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
Err(_) => return Ok(()),
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
match conn.wait_for_event() {
|
|
245
|
+
Ok(event) => event,
|
|
246
|
+
Err(_) => return Ok(()),
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
match event {
|
|
250
|
+
Event::MapRequest(event) => {
|
|
251
|
+
// A new window wants to be seen: give it its kiosk geometry,
|
|
252
|
+
// then map it. Override-redirect windows never ask.
|
|
253
|
+
if let Some((x, y, width, height)) = mapped_size(&conn, event.window)
|
|
254
|
+
.map(|(w, h)| kiosk_geometry(w, h, screen_w, screen_h))
|
|
255
|
+
{
|
|
256
|
+
conn.configure_window(
|
|
257
|
+
event.window,
|
|
258
|
+
&ConfigureWindowAux::new()
|
|
259
|
+
.x(x)
|
|
260
|
+
.y(y)
|
|
261
|
+
.width(width)
|
|
262
|
+
.height(height)
|
|
263
|
+
.stack_mode(StackMode::ABOVE),
|
|
264
|
+
);
|
|
265
|
+
conn.map_window(event.window);
|
|
266
|
+
track(&conn, event.window, &mut tracked, screen_w, screen_h);
|
|
267
|
+
let _ = refocus(&conn, &tracked);
|
|
268
|
+
mark_dirty(&mut dirty);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
Event::ConfigureRequest(event) => {
|
|
272
|
+
// The client asks for a geometry; the kiosk answer is ours.
|
|
273
|
+
// Zero size fields mean "unchanged", so read the current size.
|
|
274
|
+
let current = mapped_size(&conn, event.window);
|
|
275
|
+
if let Some((width, height)) = current.map(|(w, h)| {
|
|
276
|
+
(
|
|
277
|
+
if event.width > 0 { event.width } else { w },
|
|
278
|
+
if event.height > 0 { event.height } else { h },
|
|
279
|
+
)
|
|
280
|
+
}) {
|
|
281
|
+
let (x, y, width, height) = kiosk_geometry(width, height, screen_w, screen_h);
|
|
282
|
+
conn.configure_window(
|
|
283
|
+
event.window,
|
|
284
|
+
&ConfigureWindowAux::new()
|
|
285
|
+
.x(x)
|
|
286
|
+
.y(y)
|
|
287
|
+
.width(width)
|
|
288
|
+
.height(height),
|
|
289
|
+
);
|
|
290
|
+
mark_dirty(&mut dirty);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
Event::MapNotify(event) => {
|
|
294
|
+
// Covers windows that appeared without a request through this
|
|
295
|
+
// connection. Already-tracked and unkeepable windows are skipped
|
|
296
|
+
// inside `track`.
|
|
297
|
+
if track(&conn, event.window, &mut tracked, screen_w, screen_h) {
|
|
298
|
+
let _ = refocus(&conn, &tracked);
|
|
299
|
+
mark_dirty(&mut dirty);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
Event::UnmapNotify(event) => {
|
|
303
|
+
if untrack(&mut tracked, event.window) {
|
|
304
|
+
let _ = refocus(&conn, &tracked);
|
|
305
|
+
mark_dirty(&mut dirty);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
Event::DestroyNotify(event) => {
|
|
309
|
+
if untrack(&mut tracked, event.window) {
|
|
310
|
+
let _ = refocus(&conn, &tracked);
|
|
311
|
+
mark_dirty(&mut dirty);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
Event::ConfigureNotify(_) | Event::PropertyNotify(_) => {
|
|
315
|
+
mark_dirty(&mut dirty);
|
|
316
|
+
}
|
|
317
|
+
_ => {}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/// Records that the screen changed and a report is due.
|
|
323
|
+
fn mark_dirty(dirty: &mut Option<(Instant, Instant)>) {
|
|
324
|
+
let now = Instant::now();
|
|
325
|
+
match dirty {
|
|
326
|
+
Some((_, due)) => {
|
|
327
|
+
*due = now + DEBOUNCE;
|
|
328
|
+
}
|
|
329
|
+
None => *dirty = Some((now, now + DEBOUNCE)),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// Marks a window kept (kiosk geometry, property watching) if it is a normal
|
|
334
|
+
/// top-level window, and reports whether the screen's state changed.
|
|
335
|
+
fn track(
|
|
336
|
+
conn: &RustConnection,
|
|
337
|
+
window: Window,
|
|
338
|
+
tracked: &mut VecDeque<Window>,
|
|
339
|
+
screen_w: u16,
|
|
340
|
+
screen_h: u16,
|
|
341
|
+
) -> bool {
|
|
342
|
+
let Ok(Ok(attributes)) = conn
|
|
343
|
+
.get_window_attributes(window)
|
|
344
|
+
.map(|cookie| cookie.reply())
|
|
345
|
+
else {
|
|
346
|
+
return false;
|
|
347
|
+
};
|
|
348
|
+
if attributes.override_redirect
|
|
349
|
+
|| attributes.class != WindowClass::INPUT_OUTPUT
|
|
350
|
+
|| tracked.contains(&window)
|
|
351
|
+
{
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
conn.change_window_attributes(
|
|
355
|
+
window,
|
|
356
|
+
&ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE),
|
|
357
|
+
);
|
|
358
|
+
if attributes.map_state == MapState::VIEWABLE {
|
|
359
|
+
let (x, y, width, height) = mapped_size(conn, window)
|
|
360
|
+
.map(|(w, h)| kiosk_geometry(w, h, screen_w, screen_h))
|
|
361
|
+
.unwrap_or((0, 0, u32::from(screen_w), u32::from(screen_h)));
|
|
362
|
+
conn.configure_window(
|
|
363
|
+
window,
|
|
364
|
+
&ConfigureWindowAux::new()
|
|
365
|
+
.x(x)
|
|
366
|
+
.y(y)
|
|
367
|
+
.width(width)
|
|
368
|
+
.height(height),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
tracked.push_back(window);
|
|
372
|
+
true
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/// Stops tracking a window; reports whether it was tracked.
|
|
376
|
+
fn untrack(tracked: &mut VecDeque<Window>, window: Window) -> bool {
|
|
377
|
+
let before = tracked.len();
|
|
378
|
+
tracked.retain(|&tracked_window| tracked_window != window);
|
|
379
|
+
tracked.len() != before
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// Puts the input focus on the newest tracked content window, so keyboard
|
|
383
|
+
/// events an engine session sends go to what the user would be looking at.
|
|
384
|
+
/// Parked helpers are never focused: an off-screen window would swallow keys.
|
|
385
|
+
fn refocus(conn: &RustConnection, tracked: &VecDeque<Window>) -> Result<()> {
|
|
386
|
+
let newest = tracked.iter().rev().copied().find(|&window| {
|
|
387
|
+
mapped_size(conn, window).is_some_and(|(width, height)| is_content(width, height))
|
|
388
|
+
});
|
|
389
|
+
if let Some(window) = newest {
|
|
390
|
+
conn.set_input_focus(InputFocus::POINTER_ROOT, window, x11rb::CURRENT_TIME)?;
|
|
391
|
+
}
|
|
392
|
+
Ok(())
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/// The current size of a window worth keeping, if it still exists.
|
|
396
|
+
fn mapped_size(conn: &RustConnection, window: Window) -> Option<(u16, u16)> {
|
|
397
|
+
let Ok(Ok(geometry)) = conn.get_geometry(window).map(|cookie| cookie.reply()) else {
|
|
398
|
+
return None;
|
|
399
|
+
};
|
|
400
|
+
Some((geometry.width, geometry.height))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/// Every visible, non-override-redirect top-level window, bottom of the
|
|
404
|
+
/// stacking order first.
|
|
405
|
+
fn snapshot(
|
|
406
|
+
conn: &RustConnection,
|
|
407
|
+
root: Window,
|
|
408
|
+
net_wm_name: Atom,
|
|
409
|
+
utf8_string: Atom,
|
|
410
|
+
net_wm_pid: Atom,
|
|
411
|
+
) -> Vec<KeeperWindow> {
|
|
412
|
+
let Ok(Ok(tree)) = conn.query_tree(root).map(|cookie| cookie.reply()) else {
|
|
413
|
+
return Vec::new();
|
|
414
|
+
};
|
|
415
|
+
let mut windows = Vec::new();
|
|
416
|
+
for &window in &tree.children {
|
|
417
|
+
let Ok(Ok(attributes)) = conn
|
|
418
|
+
.get_window_attributes(window)
|
|
419
|
+
.map(|cookie| cookie.reply())
|
|
420
|
+
else {
|
|
421
|
+
continue;
|
|
422
|
+
};
|
|
423
|
+
if attributes.override_redirect
|
|
424
|
+
|| attributes.class != WindowClass::INPUT_OUTPUT
|
|
425
|
+
|| attributes.map_state != MapState::VIEWABLE
|
|
426
|
+
{
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
let Ok(Ok(geometry)) = conn.get_geometry(window).map(|cookie| cookie.reply()) else {
|
|
430
|
+
continue;
|
|
431
|
+
};
|
|
432
|
+
let property = |name: Atom, type_: Atom, length: u32| {
|
|
433
|
+
conn.get_property(false, window, name, type_, 0, length)
|
|
434
|
+
.ok()
|
|
435
|
+
.and_then(|cookie| cookie.reply().ok())
|
|
436
|
+
};
|
|
437
|
+
let title = property(net_wm_name, utf8_string, u32::MAX)
|
|
438
|
+
.map(|reply| reply.value)
|
|
439
|
+
.and_then(|value| decode_title(&String::from_utf8_lossy(&value)))
|
|
440
|
+
.or_else(|| {
|
|
441
|
+
property(AtomEnum::WM_NAME.into(), AtomEnum::STRING.into(), u32::MAX)
|
|
442
|
+
.map(|reply| reply.value)
|
|
443
|
+
.and_then(|value| decode_title(&latin1(&value)))
|
|
444
|
+
});
|
|
445
|
+
let class = property(AtomEnum::WM_CLASS.into(), AtomEnum::STRING.into(), u32::MAX)
|
|
446
|
+
.map(|reply| reply.value)
|
|
447
|
+
.and_then(|value| decode_wm_class(&value));
|
|
448
|
+
let pid = property(net_wm_pid, AtomEnum::CARDINAL.into(), 1).and_then(|reply| {
|
|
449
|
+
let mut values = reply.value32()?;
|
|
450
|
+
values.next()
|
|
451
|
+
});
|
|
452
|
+
windows.push(KeeperWindow {
|
|
453
|
+
id: window,
|
|
454
|
+
title,
|
|
455
|
+
class,
|
|
456
|
+
pid,
|
|
457
|
+
width: geometry.width,
|
|
458
|
+
height: geometry.height,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
windows
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/// Interns an atom, or `None` when the connection is gone; the caller has
|
|
465
|
+
/// already won the WM claim, so there is nothing left to refuse.
|
|
466
|
+
fn intern(conn: &RustConnection, name: &str) -> Option<Atom> {
|
|
467
|
+
conn.intern_atom(false, name.as_bytes())
|
|
468
|
+
.ok()?
|
|
469
|
+
.reply()
|
|
470
|
+
.ok()
|
|
471
|
+
.map(|reply| reply.atom)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
fn write_line(out: &mut impl Write, line: &str) -> Result<()> {
|
|
475
|
+
out.write_all(line.as_bytes())?;
|
|
476
|
+
out.write_all(b"\n")?;
|
|
477
|
+
out.flush()?;
|
|
478
|
+
Ok(())
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// One error line, then stop. This is how a refused keeper talks.
|
|
482
|
+
fn fatal(out: &mut impl Write, error: &str) -> Result<()> {
|
|
483
|
+
write_line(out, &serde_json::json!({ "error": error }).to_string())?;
|
|
484
|
+
Err(anyhow!("{error}"))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#[cfg(test)]
|
|
488
|
+
mod tests {
|
|
489
|
+
use super::*;
|
|
490
|
+
|
|
491
|
+
#[test]
|
|
492
|
+
fn content_fills_the_screen_and_helpers_are_parked() {
|
|
493
|
+
assert_eq!(kiosk_geometry(1240, 760, 1280, 800), (0, 0, 1280, 800));
|
|
494
|
+
// Exactly at the limit is content; one pixel under is not.
|
|
495
|
+
assert_eq!(kiosk_geometry(120, 120, 1280, 800), (0, 0, 1280, 800));
|
|
496
|
+
assert_eq!(
|
|
497
|
+
kiosk_geometry(119, 800, 1280, 800),
|
|
498
|
+
(i32::from(PARKED_XY), i32::from(PARKED_XY), 119, 800)
|
|
499
|
+
);
|
|
500
|
+
assert_eq!(
|
|
501
|
+
kiosk_geometry(1000, 24, 1280, 800),
|
|
502
|
+
(i32::from(PARKED_XY), i32::from(PARKED_XY), 1000, 24)
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#[test]
|
|
507
|
+
fn wm_class_reads_the_class_not_the_instance() {
|
|
508
|
+
assert_eq!(
|
|
509
|
+
decode_wm_class(b"google-chrome\0Google-chrome\0"),
|
|
510
|
+
Some(String::from("Google-chrome"))
|
|
511
|
+
);
|
|
512
|
+
assert_eq!(decode_wm_class(b"instance-only\0"), None);
|
|
513
|
+
assert_eq!(decode_wm_class(b""), None);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
#[test]
|
|
517
|
+
fn titles_come_from_either_name_property_and_blank_is_none() {
|
|
518
|
+
assert_eq!(
|
|
519
|
+
decode_title("Pricing — Acme Store"),
|
|
520
|
+
Some(String::from("Pricing — Acme Store"))
|
|
521
|
+
);
|
|
522
|
+
assert_eq!(
|
|
523
|
+
decode_title("PPF probe - Google Chrome"),
|
|
524
|
+
Some(String::from("PPF probe - Google Chrome"))
|
|
525
|
+
);
|
|
526
|
+
assert_eq!(decode_title(" "), None);
|
|
527
|
+
assert_eq!(decode_title(""), None);
|
|
528
|
+
assert_eq!(
|
|
529
|
+
decode_title(&latin1(b"Gr\xf6\xdfe")),
|
|
530
|
+
Some(String::from("Gr\u{00f6}\u{00df}e"))
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
#[test]
|
|
535
|
+
fn the_report_line_is_exactly_the_promised_shape() {
|
|
536
|
+
let line = window_line(&[KeeperWindow {
|
|
537
|
+
id: 0x0040_0001,
|
|
538
|
+
title: Some(String::from("PPF probe - Google Chrome")),
|
|
539
|
+
class: Some(String::from("Google-chrome")),
|
|
540
|
+
pid: Some(1003571),
|
|
541
|
+
width: 1280,
|
|
542
|
+
height: 800,
|
|
543
|
+
}]);
|
|
544
|
+
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
|
|
545
|
+
assert_eq!(
|
|
546
|
+
parsed,
|
|
547
|
+
serde_json::json!({
|
|
548
|
+
"windows": [{
|
|
549
|
+
"id": 0x40_0001_u64,
|
|
550
|
+
"title": "PPF probe - Google Chrome",
|
|
551
|
+
"class": "Google-chrome",
|
|
552
|
+
"pid": 1003571,
|
|
553
|
+
"width": 1280,
|
|
554
|
+
"height": 800,
|
|
555
|
+
}]
|
|
556
|
+
})
|
|
557
|
+
);
|
|
558
|
+
assert_eq!(window_line(&[]), r#"{"windows":[]}"#);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
#[test]
|
|
562
|
+
fn missing_fields_are_null_not_absent() {
|
|
563
|
+
let line = window_line(&[KeeperWindow {
|
|
564
|
+
id: 7,
|
|
565
|
+
title: None,
|
|
566
|
+
class: None,
|
|
567
|
+
pid: None,
|
|
568
|
+
width: 100,
|
|
569
|
+
height: 100,
|
|
570
|
+
}]);
|
|
571
|
+
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
|
|
572
|
+
let window = &parsed["windows"][0];
|
|
573
|
+
assert_eq!(window["title"], serde_json::Value::Null);
|
|
574
|
+
assert_eq!(window["class"], serde_json::Value::Null);
|
|
575
|
+
assert_eq!(window["pid"], serde_json::Value::Null);
|
|
576
|
+
assert!(window.get("width").is_some());
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
#[test]
|
|
580
|
+
fn an_unopenable_display_is_refused_in_one_error_line() {
|
|
581
|
+
let mut out = Vec::new();
|
|
582
|
+
assert!(keep(Some("not a display"), &mut out).is_err());
|
|
583
|
+
let line: serde_json::Value =
|
|
584
|
+
serde_json::from_str(&String::from_utf8(out).unwrap()).unwrap();
|
|
585
|
+
assert!(line["error"]
|
|
586
|
+
.as_str()
|
|
587
|
+
.is_some_and(|error| !error.is_empty()));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
#[test]
|
|
591
|
+
fn a_refused_keeper_says_why_in_its_error_line() {
|
|
592
|
+
let mut out = Vec::new();
|
|
593
|
+
let error = fatal(&mut out, "another window manager").unwrap_err();
|
|
594
|
+
assert_eq!(error.to_string(), "another window manager");
|
|
595
|
+
assert_eq!(
|
|
596
|
+
String::from_utf8(out).unwrap(),
|
|
597
|
+
"{\"error\":\"another window manager\"}\n"
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
#[test]
|
|
602
|
+
fn the_display_flag_has_one_spelling() {
|
|
603
|
+
let flag = |args: &[&str]| {
|
|
604
|
+
display_argument(
|
|
605
|
+
&args
|
|
606
|
+
.iter()
|
|
607
|
+
.map(|arg| String::from(*arg))
|
|
608
|
+
.collect::<Vec<_>>(),
|
|
609
|
+
)
|
|
610
|
+
};
|
|
611
|
+
assert_eq!(flag(&["--display", ":42"]), Some(String::from(":42")));
|
|
612
|
+
assert_eq!(
|
|
613
|
+
flag(&["--display", ":42", "--x"]),
|
|
614
|
+
Some(String::from(":42"))
|
|
615
|
+
);
|
|
616
|
+
assert_eq!(flag(&[]), None);
|
|
617
|
+
assert_eq!(flag(&["--display"]), None);
|
|
618
|
+
// The undocumented `--display=:42` spelling is not a display: `keep`
|
|
619
|
+
// refuses it rather than guessing what was meant.
|
|
620
|
+
assert_eq!(flag(&["--display=:42"]), None);
|
|
621
|
+
}
|
|
622
|
+
}
|
package/engine/src/keymap.rs
CHANGED
|
@@ -72,6 +72,18 @@ pub fn named_key(name: &str) -> Option<Keystroke> {
|
|
|
72
72
|
"End" => keycode::END,
|
|
73
73
|
"PageUp" => keycode::PAGE_UP,
|
|
74
74
|
"PageDown" => keycode::PAGE_DOWN,
|
|
75
|
+
"F1" => keycode::F1,
|
|
76
|
+
"F2" => keycode::F2,
|
|
77
|
+
"F3" => keycode::F3,
|
|
78
|
+
"F4" => keycode::F4,
|
|
79
|
+
"F5" => keycode::F5,
|
|
80
|
+
"F6" => keycode::F6,
|
|
81
|
+
"F7" => keycode::F7,
|
|
82
|
+
"F8" => keycode::F8,
|
|
83
|
+
"F9" => keycode::F9,
|
|
84
|
+
"F10" => keycode::F10,
|
|
85
|
+
"F11" => keycode::F11,
|
|
86
|
+
"F12" => keycode::F12,
|
|
75
87
|
_ => return None,
|
|
76
88
|
};
|
|
77
89
|
Some(Keystroke::simple(code))
|
|
@@ -309,7 +321,20 @@ mod tests {
|
|
|
309
321
|
fn named_keys_and_modifiers_resolve_to_linux_key_codes() {
|
|
310
322
|
assert_eq!(named_key("Enter").unwrap().code, keycode::ENTER);
|
|
311
323
|
assert_eq!(named_key("ArrowLeft").unwrap().code, keycode::LEFT);
|
|
324
|
+
for (name, code) in [
|
|
325
|
+
("Delete", keycode::DELETE), ("Home", keycode::HOME), ("End", keycode::END),
|
|
326
|
+
("PageUp", keycode::PAGE_UP), ("PageDown", keycode::PAGE_DOWN),
|
|
327
|
+
("F1", keycode::F1), ("F2", keycode::F2), ("F3", keycode::F3),
|
|
328
|
+
("F4", keycode::F4), ("F5", keycode::F5), ("F6", keycode::F6),
|
|
329
|
+
("F7", keycode::F7), ("F8", keycode::F8), ("F9", keycode::F9),
|
|
330
|
+
("F10", keycode::F10), ("F11", keycode::F11), ("F12", keycode::F12),
|
|
331
|
+
] {
|
|
332
|
+
assert_eq!(named_key(name).unwrap().code, code);
|
|
333
|
+
assert!(crate::input::native_keycode(code).is_ok(), "{name} must reach inputtino");
|
|
334
|
+
}
|
|
312
335
|
assert_eq!(modifier_key("Control"), Some(keycode::LEFT_CTRL));
|
|
336
|
+
assert_eq!(modifier_key("Alt"), Some(keycode::LEFT_ALT));
|
|
337
|
+
assert_eq!(modifier_key("Super"), Some(keycode::LEFT_META));
|
|
313
338
|
assert!(named_key("Frobnicate").is_none());
|
|
314
339
|
assert!(modifier_key("Hyper").is_none());
|
|
315
340
|
}
|