@desklink/host 0.5.6 → 0.5.7
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/docs/PROTOCOL.md +25 -2
- package/engine/Cargo.lock +1 -1
- package/engine/Cargo.toml +1 -1
- package/engine/src/capture.rs +0 -1
- package/engine/src/hyprland.rs +319 -0
- package/engine/src/main.rs +2 -0
- package/engine/src/session.rs +67 -4
- package/package.json +3 -3
package/docs/PROTOCOL.md
CHANGED
|
@@ -198,7 +198,9 @@ build has, not what this machine can necessarily use.
|
|
|
198
198
|
|
|
199
199
|
`input.unavailable_reason` and `input.grant` describe the portal/uinput path:
|
|
200
200
|
without kernel input access a portal session opens and captures, but input
|
|
201
|
-
messages are refused with `input-unavailable`.
|
|
201
|
+
messages are refused with `input-unavailable`. A Hyprland window share's
|
|
202
|
+
pointer moves are the exception: they warp through Hyprland's own IPC instead
|
|
203
|
+
of any device (see Input and clipboard). X display capture uses XTest
|
|
202
204
|
instead and does not require that grant. The engine probes XTest on that named
|
|
203
205
|
display; if it is unavailable, capture and clipboard still open, and input
|
|
204
206
|
messages are refused with `input-unavailable`. X11 clipboard uses CLIPBOARD on the
|
|
@@ -270,7 +272,9 @@ animating in and out, with light and dark keylines so it reads on any content.
|
|
|
270
272
|
On Wayland it uses a click-through layer-shell surface with an edge glow, drawn
|
|
271
273
|
at the highest announced output integer scale, capped at 4; its bounded area is
|
|
272
274
|
masked before capture/diff (pixels behind the effect can remain briefly stale).
|
|
273
|
-
On
|
|
275
|
+
On a located Hyprland window share the cue gets no pointer or click feedback —
|
|
276
|
+
the engine places the pointer itself, so a stream fraction is not the window's
|
|
277
|
+
desktop point (see Input and clipboard); keys still show. On bare X11 it is an override-redirect, click-through window that follows the
|
|
274
278
|
pointer, so it stays
|
|
275
279
|
visible over windows that set their own cursor and never replaces the cursor.
|
|
276
280
|
Its size follows `Xft.dpi` from 96–384 DPI, defaulting to 1x otherwise;
|
|
@@ -688,6 +692,25 @@ so a tap would land at the same fraction of the desktop instead of on the
|
|
|
688
692
|
window. Keys, text, wheel, pointer `cancel` and `release_all` are unaffected;
|
|
689
693
|
share the monitor to point and click.
|
|
690
694
|
|
|
695
|
+
On Hyprland the engine places the pointer itself. Hyprland announces each
|
|
696
|
+
window share by title on its event socket, and the engine takes the one window
|
|
697
|
+
in `j/clients` with that title and the stream's size. That match runs once, at
|
|
698
|
+
the first tap, and the announcement says nothing else about which share it is:
|
|
699
|
+
if the shared window's title changes before that first tap and another window
|
|
700
|
+
of the stream's size now carries the announced title, taps land on the other
|
|
701
|
+
window; and when two window shares are announced at once (another session, or
|
|
702
|
+
any other consumer of the portal), the engine cannot tell which announcement
|
|
703
|
+
is this share's, so every tap refuses with `input-unavailable` for the
|
|
704
|
+
session's lifetime. Each `move`, `down` and
|
|
705
|
+
`up` warps the pointer to the window's current position plus the local point
|
|
706
|
+
(so a window moved after the share started still maps); buttons and keys go
|
|
707
|
+
through the input device as before. A tap is still refused with
|
|
708
|
+
`input-unavailable` when the window cannot be told apart (no announcement,
|
|
709
|
+
no such window, or several that look alike), when it has closed or sits on a
|
|
710
|
+
hidden workspace, when another window covers the tapped point or a layer
|
|
711
|
+
surface sits over it, or when the window hangs off screen. Other compositors
|
|
712
|
+
keep the refusal above.
|
|
713
|
+
|
|
691
714
|
The engine holds its own pressed-key/button state. `close`, `release_all`,
|
|
692
715
|
control-channel loss and process exit all synthesize releases for exactly the
|
|
693
716
|
keys and buttons this session pressed, so a dropped connection cannot leave a
|
package/engine/Cargo.lock
CHANGED
package/engine/Cargo.toml
CHANGED
package/engine/src/capture.rs
CHANGED
|
@@ -303,7 +303,6 @@ fn run_loop(
|
|
|
303
303
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
|
304
304
|
*pw::keys::MEDIA_ROLE => "Screen",
|
|
305
305
|
};
|
|
306
|
-
props.insert(*pw::keys::TARGET_OBJECT, node_id.to_string());
|
|
307
306
|
let stream = pw::stream::StreamBox::new(&core, "desklink-capture", props)
|
|
308
307
|
.context("pw_stream_new failed")?;
|
|
309
308
|
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
//! Where a shared window sits on a Hyprland desktop, from Hyprland's own IPC.
|
|
2
|
+
//!
|
|
3
|
+
//! Hyprland's ScreenCast portal reports every stream at (0, 0) and never says
|
|
4
|
+
//! which window was picked, while the pointer device spans the whole desktop.
|
|
5
|
+
//! Hyprland itself announces each window share by title on its event socket
|
|
6
|
+
//! (`screencastv2>>1,window,<title>`) and lists every window's place in
|
|
7
|
+
//! `j/clients`. So the shared window is the one with that title and the
|
|
8
|
+
//! stream's size, followed by its address from then on, and the pointer goes
|
|
9
|
+
//! onto it through Hyprland's own cursor warp at the window's current place:
|
|
10
|
+
//! a window moved after the share started still maps. Anything less certain
|
|
11
|
+
//! (no announcement, no such window, two alike, a window off screen) is a
|
|
12
|
+
//! refusal, never a guess.
|
|
13
|
+
|
|
14
|
+
use serde_json::Value;
|
|
15
|
+
use std::io::{BufRead, BufReader, Read, Write};
|
|
16
|
+
use std::net::Shutdown;
|
|
17
|
+
use std::os::unix::net::UnixStream;
|
|
18
|
+
use std::path::PathBuf;
|
|
19
|
+
use std::sync::{Arc, Mutex, MutexGuard};
|
|
20
|
+
|
|
21
|
+
use crate::session::WINDOW_POINTER_REFUSAL as UNKNOWN;
|
|
22
|
+
|
|
23
|
+
fn instance() -> Option<PathBuf> {
|
|
24
|
+
let signature = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE")?;
|
|
25
|
+
let runtime = std::env::var_os("XDG_RUNTIME_DIR")?;
|
|
26
|
+
Some(PathBuf::from(runtime).join("hypr").join(signature))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
|
30
|
+
mutex
|
|
31
|
+
.lock()
|
|
32
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Hyprland's window-share announcements, heard from before the portal asks
|
|
36
|
+
/// for consent. Dropping it closes the event socket, which ends its reader.
|
|
37
|
+
pub struct ShareWatch {
|
|
38
|
+
socket: UnixStream,
|
|
39
|
+
titles: Arc<Mutex<Vec<String>>>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
impl ShareWatch {
|
|
43
|
+
/// `None` when this is not a Hyprland session.
|
|
44
|
+
pub fn start() -> Option<Self> {
|
|
45
|
+
let socket = UnixStream::connect(instance()?.join(".socket2.sock")).ok()?;
|
|
46
|
+
let reader = socket.try_clone().ok()?;
|
|
47
|
+
let titles = Arc::new(Mutex::new(Vec::new()));
|
|
48
|
+
let heard = titles.clone();
|
|
49
|
+
std::thread::spawn(move || {
|
|
50
|
+
for line in BufReader::new(reader).lines().map_while(Result::ok) {
|
|
51
|
+
if let Some(title) = line.strip_prefix("screencastv2>>1,window,") {
|
|
52
|
+
lock(&heard).push(title.to_owned());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
Some(Self { socket, titles })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
impl Drop for ShareWatch {
|
|
61
|
+
fn drop(&mut self) {
|
|
62
|
+
let _ = self.socket.shutdown(Shutdown::Both);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// The window a portal window share shows, placed by Hyprland.
|
|
67
|
+
pub struct WindowLocator {
|
|
68
|
+
watch: ShareWatch,
|
|
69
|
+
/// The stream's own pixel size, which the window's size times its
|
|
70
|
+
/// monitor's scale must match.
|
|
71
|
+
source: (i64, i64),
|
|
72
|
+
address: Mutex<Option<String>>,
|
|
73
|
+
/// Whether this Hyprland reads a Lua config, whose `dispatch` takes Lua.
|
|
74
|
+
lua: Mutex<Option<bool>>,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
impl WindowLocator {
|
|
78
|
+
pub fn new(watch: ShareWatch, width: i32, height: i32) -> Self {
|
|
79
|
+
Self {
|
|
80
|
+
watch,
|
|
81
|
+
source: (i64::from(width.max(1)), i64::from(height.max(1))),
|
|
82
|
+
address: Mutex::new(None),
|
|
83
|
+
lua: Mutex::new(None),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Put the pointer on `(x, y)` in the shared window's own pixels.
|
|
88
|
+
pub fn place(&self, x: i64, y: i64) -> Result<(), String> {
|
|
89
|
+
let (clients, monitors) = (query("j/clients")?, query("j/monitors")?);
|
|
90
|
+
let clients = clients.as_array().map(Vec::as_slice).unwrap_or_default();
|
|
91
|
+
// A window nobody sees has no point on the screen.
|
|
92
|
+
let shown = |window: &Value| {
|
|
93
|
+
let workspace = &window["workspace"]["id"];
|
|
94
|
+
window["mapped"].as_bool() != Some(false)
|
|
95
|
+
&& window["hidden"].as_bool() != Some(true)
|
|
96
|
+
&& (window["pinned"].as_bool() == Some(true)
|
|
97
|
+
|| monitors.as_array().into_iter().flatten().any(|monitor| {
|
|
98
|
+
&monitor["activeWorkspace"]["id"] == workspace
|
|
99
|
+
|| &monitor["specialWorkspace"]["id"] == workspace
|
|
100
|
+
}))
|
|
101
|
+
};
|
|
102
|
+
let mut address = lock(&self.address);
|
|
103
|
+
if address.is_none() {
|
|
104
|
+
*address = Some(self.identify(clients, &monitors)?);
|
|
105
|
+
}
|
|
106
|
+
let index = clients
|
|
107
|
+
.iter()
|
|
108
|
+
.position(|client| client["address"].as_str() == address.as_deref())
|
|
109
|
+
.ok_or("a shared window cannot take taps: the window has closed")?;
|
|
110
|
+
let window = &clients[index];
|
|
111
|
+
if !shown(window) {
|
|
112
|
+
return Err(String::from(
|
|
113
|
+
"a shared window cannot take taps while it is on a hidden workspace",
|
|
114
|
+
));
|
|
115
|
+
}
|
|
116
|
+
let (left, top, width, height) = bounds(window).ok_or(UNKNOWN)?;
|
|
117
|
+
let (source_w, source_h) = self.source;
|
|
118
|
+
let (px, py) = (
|
|
119
|
+
left + x as f64 * width / source_w as f64,
|
|
120
|
+
top + y as f64 * height / source_h as f64,
|
|
121
|
+
);
|
|
122
|
+
// Hyprland clamps the cursor to the screen edge, so a point off every
|
|
123
|
+
// monitor would press something else or nothing.
|
|
124
|
+
let on_screen = monitors.as_array().into_iter().flatten().any(|monitor| {
|
|
125
|
+
let scale = monitor["scale"].as_f64().filter(|scale| *scale > 0.0);
|
|
126
|
+
monitor["disabled"].as_bool() != Some(true)
|
|
127
|
+
&& matches!(
|
|
128
|
+
(
|
|
129
|
+
monitor["x"].as_f64(),
|
|
130
|
+
monitor["y"].as_f64(),
|
|
131
|
+
monitor["width"].as_f64(),
|
|
132
|
+
monitor["height"].as_f64(),
|
|
133
|
+
scale
|
|
134
|
+
),
|
|
135
|
+
(Some(mx), Some(my), Some(mw), Some(mh), Some(scale))
|
|
136
|
+
if (mx..mx + mw / scale).contains(&px)
|
|
137
|
+
&& (my..my + mh / scale).contains(&py)
|
|
138
|
+
)
|
|
139
|
+
});
|
|
140
|
+
if !on_screen {
|
|
141
|
+
return Err(String::from(
|
|
142
|
+
"a shared window cannot take taps where it hangs off screen",
|
|
143
|
+
));
|
|
144
|
+
}
|
|
145
|
+
// Hyprland draws tiled windows, then floating ones, then fullscreen
|
|
146
|
+
// ones, and pinned ones above all; j/clients lists them in the
|
|
147
|
+
// compositor's own stacking order, so a later layer-mate holding the
|
|
148
|
+
// point is what a press there would reach. A floating window let over
|
|
149
|
+
// fullscreen draws above it, but j/clients cannot tell that one from
|
|
150
|
+
// any floating window, so either pairing over the point is a refusal.
|
|
151
|
+
let layer = |w: &Value| {
|
|
152
|
+
if w["pinned"].as_bool() == Some(true) {
|
|
153
|
+
3
|
|
154
|
+
} else if w["fullscreen"].as_i64().is_some_and(|mode| mode > 0) {
|
|
155
|
+
2
|
|
156
|
+
} else {
|
|
157
|
+
u8::from(w["floating"].as_bool() == Some(true))
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
let covered = clients.iter().enumerate().any(|(i, other)| {
|
|
161
|
+
shown(other)
|
|
162
|
+
&& bounds(other).is_some_and(|(l, t, w, h)| {
|
|
163
|
+
(l..l + w).contains(&px) && (t..t + h).contains(&py)
|
|
164
|
+
})
|
|
165
|
+
&& match (layer(other), layer(window), i > index) {
|
|
166
|
+
(1, 2, _) | (2, 1, _) => true,
|
|
167
|
+
(above, own, later) => above > own || (above == own && later),
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
if covered {
|
|
171
|
+
return Err(String::from(
|
|
172
|
+
"a shared window cannot take taps where another window covers it",
|
|
173
|
+
));
|
|
174
|
+
}
|
|
175
|
+
// Bars, notifications and other layer surfaces draw above windows and
|
|
176
|
+
// are absent from the window-only video: a top or overlay one holding
|
|
177
|
+
// the point is what a press there would reach. Their boxes come local
|
|
178
|
+
// to their monitor, which j/monitors places.
|
|
179
|
+
let anchored = |name: &str| -> Option<(f64, f64)> {
|
|
180
|
+
for monitor in monitors.as_array().into_iter().flatten() {
|
|
181
|
+
if monitor["name"].as_str() == Some(name) {
|
|
182
|
+
return Some((monitor["x"].as_f64()?, monitor["y"].as_f64()?));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
None
|
|
186
|
+
};
|
|
187
|
+
let overlaid = query("j/layers")?
|
|
188
|
+
.as_object()
|
|
189
|
+
.into_iter()
|
|
190
|
+
.flatten()
|
|
191
|
+
.map(|(name, monitor)| Some((anchored(name)?, monitor)))
|
|
192
|
+
.collect::<Option<Vec<((f64, f64), &Value)>>>()
|
|
193
|
+
.ok_or(String::from(
|
|
194
|
+
"a shared window cannot take taps: a screen's layers are unknown",
|
|
195
|
+
))?
|
|
196
|
+
.into_iter()
|
|
197
|
+
.any(|((mx, my), monitor)| {
|
|
198
|
+
monitor["levels"]
|
|
199
|
+
.as_object()
|
|
200
|
+
.into_iter()
|
|
201
|
+
.flatten()
|
|
202
|
+
.any(|(level, surfaces)| {
|
|
203
|
+
let level = level.parse::<u8>().unwrap_or(0);
|
|
204
|
+
(level >= 2)
|
|
205
|
+
&& surfaces.as_array().into_iter().flatten().any(|surface| {
|
|
206
|
+
let (sx, sy) = (surface["x"].as_f64(), surface["y"].as_f64());
|
|
207
|
+
let (sw, sh) = (surface["w"].as_f64(), surface["h"].as_f64());
|
|
208
|
+
!matches!(
|
|
209
|
+
surface["namespace"].as_str(),
|
|
210
|
+
Some("desklink-agent" | "desklink-point")
|
|
211
|
+
) && matches!(
|
|
212
|
+
(sx, sy, sw, sh),
|
|
213
|
+
(Some(sx), Some(sy), Some(sw), Some(sh))
|
|
214
|
+
if (mx + sx..mx + sx + sw).contains(&px)
|
|
215
|
+
&& (my + sy..my + sy + sh).contains(&py)
|
|
216
|
+
)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
});
|
|
220
|
+
if overlaid {
|
|
221
|
+
return Err(String::from(
|
|
222
|
+
"a shared window cannot take taps where an overlay covers it",
|
|
223
|
+
));
|
|
224
|
+
}
|
|
225
|
+
self.warp((px.floor() as i64, py.floor() as i64))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// The address of the one window Hyprland said it is sharing.
|
|
229
|
+
fn identify(&self, clients: &[Value], monitors: &Value) -> Result<String, String> {
|
|
230
|
+
let mut titles = lock(&self.watch.titles).clone();
|
|
231
|
+
titles.dedup();
|
|
232
|
+
let [title] = titles.as_slice() else {
|
|
233
|
+
return Err(String::from(UNKNOWN));
|
|
234
|
+
};
|
|
235
|
+
let (source_w, source_h) = self.source;
|
|
236
|
+
let scale = |monitor: &Value| {
|
|
237
|
+
monitors
|
|
238
|
+
.as_array()
|
|
239
|
+
.into_iter()
|
|
240
|
+
.flatten()
|
|
241
|
+
.find(|m| &m["id"] == monitor)
|
|
242
|
+
.and_then(|m| m["scale"].as_f64())
|
|
243
|
+
};
|
|
244
|
+
let alike: Vec<&Value> = clients
|
|
245
|
+
.iter()
|
|
246
|
+
.filter(|client| {
|
|
247
|
+
let size = |i: usize| client["size"][i].as_f64();
|
|
248
|
+
client["title"].as_str() == Some(title.as_str())
|
|
249
|
+
&& client["mapped"].as_bool() != Some(false)
|
|
250
|
+
&& client["hidden"].as_bool() != Some(true)
|
|
251
|
+
&& matches!(
|
|
252
|
+
(size(0), size(1), scale(&client["monitor"])),
|
|
253
|
+
(Some(w), Some(h), Some(s))
|
|
254
|
+
if ((w * s).round() as i64 - source_w).abs() <= 1
|
|
255
|
+
&& ((h * s).round() as i64 - source_h).abs() <= 1
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
.collect();
|
|
259
|
+
match alike.as_slice() {
|
|
260
|
+
[window] => window["address"]
|
|
261
|
+
.as_str()
|
|
262
|
+
.map(str::to_owned)
|
|
263
|
+
.ok_or_else(|| String::from(UNKNOWN)),
|
|
264
|
+
[] => Err(String::from(UNKNOWN)),
|
|
265
|
+
_ => Err(format!(
|
|
266
|
+
"a shared window cannot take taps: {} windows look like it; share the whole screen to point and click",
|
|
267
|
+
alike.len()
|
|
268
|
+
)),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn warp(&self, (x, y): (i64, i64)) -> Result<(), String> {
|
|
273
|
+
let legacy = format!("dispatch movecursor {x} {y}");
|
|
274
|
+
let lua = format!("dispatch hl.dsp.cursor.move({{ x = {x}, y = {y} }})");
|
|
275
|
+
let mut dialect = lock(&self.lua);
|
|
276
|
+
let tries: &[(bool, &str)] = match *dialect {
|
|
277
|
+
Some(true) => &[(true, &lua)],
|
|
278
|
+
Some(false) => &[(false, &legacy)],
|
|
279
|
+
None => &[(false, &legacy), (true, &lua)],
|
|
280
|
+
};
|
|
281
|
+
let mut said = String::new();
|
|
282
|
+
for (is_lua, command) in tries {
|
|
283
|
+
said = request(command)?;
|
|
284
|
+
if said.trim() == "ok" {
|
|
285
|
+
*dialect = Some(*is_lua);
|
|
286
|
+
return Ok(());
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
Err(format!(
|
|
290
|
+
"Hyprland did not move the pointer: {}",
|
|
291
|
+
said.trim()
|
|
292
|
+
))
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// A window's box in the layout: left, top, width, height.
|
|
297
|
+
fn bounds(window: &Value) -> Option<(f64, f64, f64, f64)> {
|
|
298
|
+
let pair = |key: &str| Some((window[key][0].as_f64()?, window[key][1].as_f64()?));
|
|
299
|
+
let ((left, top), (width, height)) = pair("at").zip(pair("size"))?;
|
|
300
|
+
Some((left, top, width, height))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fn request(command: &str) -> Result<String, String> {
|
|
304
|
+
let ask = || -> std::io::Result<String> {
|
|
305
|
+
let path = instance()
|
|
306
|
+
.ok_or_else(|| std::io::Error::other("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
|
|
307
|
+
let mut socket = UnixStream::connect(path.join(".socket.sock"))?;
|
|
308
|
+
socket.write_all(command.as_bytes())?;
|
|
309
|
+
let mut reply = String::new();
|
|
310
|
+
socket.read_to_string(&mut reply)?;
|
|
311
|
+
Ok(reply)
|
|
312
|
+
};
|
|
313
|
+
ask().map_err(|error| format!("Hyprland's control socket did not answer: {error}"))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
fn query(command: &str) -> Result<Value, String> {
|
|
317
|
+
serde_json::from_str(&request(command)?)
|
|
318
|
+
.map_err(|error| format!("Hyprland's {command} was not JSON: {error}"))
|
|
319
|
+
}
|
package/engine/src/main.rs
CHANGED
package/engine/src/session.rs
CHANGED
|
@@ -816,6 +816,11 @@ struct Inner {
|
|
|
816
816
|
point_overlay: Mutex<Option<crate::indicator::Indicator>>,
|
|
817
817
|
#[cfg(target_os = "linux")]
|
|
818
818
|
point_desktop: PointDesktop,
|
|
819
|
+
/// A portal window share's place on a Hyprland desktop; absent elsewhere,
|
|
820
|
+
/// where a shared window's taps are refused. Dropped on close, which
|
|
821
|
+
/// closes its Hyprland event socket.
|
|
822
|
+
#[cfg(target_os = "linux")]
|
|
823
|
+
window: Mutex<Option<crate::hyprland::WindowLocator>>,
|
|
819
824
|
capture: Mutex<Option<FrameSource>>,
|
|
820
825
|
/// Absent for an encoded source: there is no local keyboard to translate a
|
|
821
826
|
/// character through, because the client's keys are forwarded instead.
|
|
@@ -1195,6 +1200,8 @@ impl Session {
|
|
|
1195
1200
|
None
|
|
1196
1201
|
};
|
|
1197
1202
|
let mut cursor_info = serde_json::json!({"mode":"embedded", "positions":false});
|
|
1203
|
+
#[cfg(target_os = "linux")]
|
|
1204
|
+
let mut share_watch = None;
|
|
1198
1205
|
let Selected {
|
|
1199
1206
|
source,
|
|
1200
1207
|
capture,
|
|
@@ -1225,6 +1232,9 @@ impl Session {
|
|
|
1225
1232
|
_ => {
|
|
1226
1233
|
#[cfg(target_os = "linux")]
|
|
1227
1234
|
{
|
|
1235
|
+
// Hyprland names the window it shares only on its
|
|
1236
|
+
// event socket, as the share starts.
|
|
1237
|
+
share_watch = crate::hyprland::ShareWatch::start();
|
|
1228
1238
|
portal::open(request.restore_token.as_deref(), request.cursor).await
|
|
1229
1239
|
}
|
|
1230
1240
|
#[cfg(not(target_os = "linux"))]
|
|
@@ -1354,6 +1364,10 @@ impl Session {
|
|
|
1354
1364
|
let source_w = source.width.max(1) as usize;
|
|
1355
1365
|
let source_h = source.height.max(1) as usize;
|
|
1356
1366
|
let (width, height) = fit(source_w, source_h, request.max_width, request.max_height);
|
|
1367
|
+
#[cfg(target_os = "linux")]
|
|
1368
|
+
let window = share_watch
|
|
1369
|
+
.filter(|_| source.source_type.as_deref() == Some("Window"))
|
|
1370
|
+
.map(|watch| crate::hyprland::WindowLocator::new(watch, source.width, source.height));
|
|
1357
1371
|
|
|
1358
1372
|
let bitrate_kbps = if request.bitrate_kbps == 0 {
|
|
1359
1373
|
auto_bitrate(width, height)
|
|
@@ -1469,6 +1483,8 @@ impl Session {
|
|
|
1469
1483
|
} else {
|
|
1470
1484
|
PointDesktop::Wayland
|
|
1471
1485
|
},
|
|
1486
|
+
#[cfg(target_os = "linux")]
|
|
1487
|
+
window: Mutex::new(window),
|
|
1472
1488
|
capture: Mutex::new(Some(capture)),
|
|
1473
1489
|
layout: Mutex::new(Some(layout)),
|
|
1474
1490
|
#[cfg(target_os = "linux")]
|
|
@@ -1567,6 +1583,8 @@ impl Session {
|
|
|
1567
1583
|
point_overlay: Mutex::new(None),
|
|
1568
1584
|
#[cfg(target_os = "linux")]
|
|
1569
1585
|
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
1586
|
+
#[cfg(target_os = "linux")]
|
|
1587
|
+
window: Mutex::new(None),
|
|
1570
1588
|
capture: Mutex::new(None),
|
|
1571
1589
|
layout: Mutex::new(None),
|
|
1572
1590
|
#[cfg(target_os = "linux")]
|
|
@@ -2118,6 +2136,8 @@ impl Inner {
|
|
|
2118
2136
|
drop(capture);
|
|
2119
2137
|
#[cfg(target_os = "linux")]
|
|
2120
2138
|
drop(self.audio.lock().ok().and_then(|mut held| held.take()));
|
|
2139
|
+
#[cfg(target_os = "linux")]
|
|
2140
|
+
drop(lock(&self.window).take());
|
|
2121
2141
|
self.notify(SessionEvent::State {
|
|
2122
2142
|
capture: "ended",
|
|
2123
2143
|
transport: String::from("closed"),
|
|
@@ -2245,17 +2265,31 @@ impl Inner {
|
|
|
2245
2265
|
}
|
|
2246
2266
|
// A shared window's place on the desktop is unknown (the portal reports
|
|
2247
2267
|
// no origin for it), while the pointer device spans the whole desktop:
|
|
2248
|
-
// a tap would land somewhere the person never pointed.
|
|
2268
|
+
// a tap would land somewhere the person never pointed. Only Hyprland
|
|
2269
|
+
// says where the window is.
|
|
2270
|
+
#[cfg(target_os = "linux")]
|
|
2271
|
+
let located = lock(&self.window).is_some();
|
|
2272
|
+
#[cfg(not(target_os = "linux"))]
|
|
2273
|
+
let located = false;
|
|
2274
|
+
let feedback = if located {
|
|
2275
|
+
feedback.filter(|(kind, ..)| *kind == 'T')
|
|
2276
|
+
} else {
|
|
2277
|
+
feedback
|
|
2278
|
+
};
|
|
2249
2279
|
if self.source.source_type.as_deref() == Some("Window")
|
|
2280
|
+
&& !located
|
|
2250
2281
|
&& matches!(&message, ControlMessage::Pointer { phase, .. } if !matches!(phase, PointerPhase::Cancel))
|
|
2251
2282
|
{
|
|
2252
2283
|
self.reject(seq, "input-unavailable", WINDOW_POINTER_REFUSAL);
|
|
2253
2284
|
return;
|
|
2254
2285
|
}
|
|
2286
|
+
// A located window's pointer moves through the compositor; only its
|
|
2287
|
+
// buttons need the input device, which `pointer` checks first.
|
|
2255
2288
|
if !matches!(
|
|
2256
2289
|
&message,
|
|
2257
2290
|
ControlMessage::ClipboardRead { .. } | ControlMessage::ClipboardWrite { .. }
|
|
2258
|
-
) &&
|
|
2291
|
+
) && !(located && matches!(&message, ControlMessage::Pointer { .. }))
|
|
2292
|
+
&& lock(&self.input).is_none()
|
|
2259
2293
|
{
|
|
2260
2294
|
self.reject(seq, "input-unavailable", "no input backend");
|
|
2261
2295
|
return;
|
|
@@ -2380,6 +2414,28 @@ impl Inner {
|
|
|
2380
2414
|
// created for, and clamps to, the source's own pixels. One conversion
|
|
2381
2415
|
// here keeps every caller honest.
|
|
2382
2416
|
let (x, y) = to_source_pixels(x, y, (width, height), &self.source);
|
|
2417
|
+
#[cfg(target_os = "linux")]
|
|
2418
|
+
if let Some(window) = lock(&self.window).as_ref() {
|
|
2419
|
+
let place = || {
|
|
2420
|
+
window
|
|
2421
|
+
.place(x, y)
|
|
2422
|
+
.map_err(|message| ("input-unavailable", message))
|
|
2423
|
+
};
|
|
2424
|
+
return match phase {
|
|
2425
|
+
PointerPhase::Move => place(),
|
|
2426
|
+
PointerPhase::Down => {
|
|
2427
|
+
// Refused before the pointer moves: nothing could press.
|
|
2428
|
+
self.with_input(|_| Ok(()))?;
|
|
2429
|
+
place()?;
|
|
2430
|
+
self.with_input(|target| target.button(Button::from_number(button), true))
|
|
2431
|
+
}
|
|
2432
|
+
PointerPhase::Up => {
|
|
2433
|
+
self.with_input(|target| target.button(Button::from_number(button), false))?;
|
|
2434
|
+
place()
|
|
2435
|
+
}
|
|
2436
|
+
PointerPhase::Cancel => self.with_input(InputTarget::release_all),
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2383
2439
|
self.with_input(|target| match phase {
|
|
2384
2440
|
PointerPhase::Move => target.move_absolute(x, y),
|
|
2385
2441
|
PointerPhase::Down => {
|
|
@@ -3407,7 +3463,10 @@ fn spawn_peer_events(inner: &Arc<Inner>, mut events: tokio_mpsc::UnboundedReceiv
|
|
|
3407
3463
|
// desklink scout trace: the session's transport emit, one
|
|
3408
3464
|
// line after the peer event that caused it.
|
|
3409
3465
|
if crate::peer::dl_trace_on() {
|
|
3410
|
-
crate::peer::dl_eng!(
|
|
3466
|
+
crate::peer::dl_eng!(
|
|
3467
|
+
"session_transport_emit {}",
|
|
3468
|
+
format!("{state}").to_lowercase()
|
|
3469
|
+
);
|
|
3411
3470
|
}
|
|
3412
3471
|
}
|
|
3413
3472
|
PeerEvent::ControlOpen => {
|
|
@@ -3467,7 +3526,7 @@ fn available_parallelism() -> usize {
|
|
|
3467
3526
|
.unwrap_or(4)
|
|
3468
3527
|
}
|
|
3469
3528
|
|
|
3470
|
-
const WINDOW_POINTER_REFUSAL: &str = "a shared window cannot take taps: its place on the desktop is unknown; share the whole screen to point and click";
|
|
3529
|
+
pub(crate) const WINDOW_POINTER_REFUSAL: &str = "a shared window cannot take taps: its place on the desktop is unknown; share the whole screen to point and click";
|
|
3471
3530
|
|
|
3472
3531
|
/// Encoded-surface pixels to the source's own pixels. The client aims at what it
|
|
3473
3532
|
/// sees; the applier is sized and clamped to the source, so the layout origin
|
|
@@ -3583,6 +3642,8 @@ mod tests {
|
|
|
3583
3642
|
point_overlay: Mutex::new(None),
|
|
3584
3643
|
#[cfg(target_os = "linux")]
|
|
3585
3644
|
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
3645
|
+
#[cfg(target_os = "linux")]
|
|
3646
|
+
window: Mutex::new(None),
|
|
3586
3647
|
capture: Mutex::new(None),
|
|
3587
3648
|
layout: Mutex::new(None),
|
|
3588
3649
|
#[cfg(target_os = "linux")]
|
|
@@ -3842,6 +3903,8 @@ mod tests {
|
|
|
3842
3903
|
point_overlay: Mutex::new(None),
|
|
3843
3904
|
#[cfg(target_os = "linux")]
|
|
3844
3905
|
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
3906
|
+
#[cfg(target_os = "linux")]
|
|
3907
|
+
window: Mutex::new(None),
|
|
3845
3908
|
layout: Mutex::new(Some(Layout::from_environment().expect("a keymap"))),
|
|
3846
3909
|
#[cfg(target_os = "linux")]
|
|
3847
3910
|
audio: Mutex::new(None),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desklink/host",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"ws": "8.21.3"
|
|
39
39
|
},
|
|
40
40
|
"optionalDependencies": {
|
|
41
|
-
"@desklink/host-linux-x64-gnu": "0.5.
|
|
42
|
-
"@desklink/host-darwin-arm64": "0.5.
|
|
41
|
+
"@desklink/host-linux-x64-gnu": "0.5.7",
|
|
42
|
+
"@desklink/host-darwin-arm64": "0.5.7"
|
|
43
43
|
}
|
|
44
44
|
}
|