Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,25 @@ describe("PipeWireCursorAccumulator", () => {
expect(point.timeMs).toBe(500);
});

it("reports every sample as a move, because Wayland exposes no buttons", () => {
it("defaults a sample with no interaction to a move", () => {
// The helper omits interactionType on the common case, so the accumulator
// owns the "move" fallback. This is what the user not being in the `input`
// group looks like: every sample arrives bare.
const accumulator = new PipeWireCursorAccumulator(100);
accumulator.reset(0);
accumulator.addSample(sample(10, 5, 5));
expect(accumulator.toRecordingData().samples[0].interactionType).toBe("move");
});

it("preserves a click the helper read from evdev", () => {
const accumulator = new PipeWireCursorAccumulator(100);
accumulator.reset(0);
accumulator.addSample(sample(10, 5, 5, { interactionType: "click" }));
accumulator.addSample(sample(20, 6, 6));
const { samples } = accumulator.toRecordingData();
expect(samples.map((s) => s.interactionType)).toEqual(["click", "move"]);
});

it("re-bases onto the video's start and drops what came before it", () => {
// This is the single-session case. Cursor samples start flowing as soon
// as the helper does, but the video's frame 0 is only stamped once the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export type PipeWireHelperEvent =
visible: boolean;
assetId?: string;
asset?: PipeWireCursorAssetPayload;
/** `"click"` on the sample coinciding with a left-button press the
* helper read from evdev; absent on a plain move (see the helper's
* input.rs). The helper never emits the `"move"` default — that word
* is filled in below so it lives in exactly one place. */
interactionType?: "move" | "click";
}
| {
event: "audio-source";
Expand Down Expand Up @@ -166,8 +171,10 @@ export class PipeWireCursorAccumulator {
cx: clamp(payload.x / width, 0, 1),
cy: clamp(payload.y / height, 0, 1),
visible: payload.visible,
// Wayland exposes no click events to an unprivileged process.
interactionType: "move",
// The portal never reports a button; the helper tags a sample "click"
// only when it read a left-button press from evdev (needs the user in
// the `input` group). Everything else — the common case — is a move.
interactionType: payload.interactionType ?? "move",
...(payload.assetId ? { assetId: payload.assetId } : {}),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ import type { CursorRecordingSession } from "./session";
*
* Two consequences the caller should know about:
*
* * `interactionType` is always "move". Wayland exposes no portal for mouse
* buttons and /dev/input/event* is root:input, so clicks are unobtainable.
* * `interactionType` is "move" unless the helper could read left-button
* presses from evdev — which needs the user in the `input` group, because
* Wayland exposes no portal for mouse buttons and /dev/input/event* is
* root:input. When it can, the coinciding sample is tagged "click"; when it
* cannot, every sample is a move, as before. See the helper's input.rs.
* * The helper raises its own portal picker. On Wayland, Electron's
* `desktopCapturer` already raised one, so the user currently picks a source
* twice. Merging the two is the job of the capture stage that will reuse this
Expand Down
11 changes: 8 additions & 3 deletions electron/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,14 @@ electron/native/bin/linux-x64/openscreen-pipewire-helper '{"probeOnly":true}'

### Known gaps

- **Mouse clicks are unobtainable.** Wayland exposes no portal for input events
and `/dev/input/event*` is `root:input`, so every sample's `interactionType` is
`"move"`.
- **Mouse clicks need the `input` group.** Wayland exposes no portal for input
events, so the helper reads left-button presses straight from evdev
(`/dev/input/event*`). Those nodes are `root:input`, so a user outside the
`input` group gets no readable device and every sample's `interactionType`
stays `"move"` — the same as before. When a device is readable, the coinciding
sample is tagged `"click"`. Scope is deliberately narrow: `BTN_LEFT` only,
never keystrokes (see `pipewire-capture/src/input.rs`), and
`OPENSCREEN_DISABLE_CLICK_CAPTURE=1` turns it off entirely.
- **The user picks a source twice.** Electron's `desktopCapturer` raises its own
portal dialog for the video, and this helper raises a second one for the cursor.
Collapsing them requires one portal session serving both, which is why the
Expand Down
52 changes: 52 additions & 0 deletions electron/native/pipewire-capture/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions electron/native/pipewire-capture/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ serde_json = "1"
png = "0.17"
base64 = "0.22"
sha2 = "0.10"
evdev = "0.13"

[build-dependencies]
cc = "1"
Expand Down
28 changes: 28 additions & 0 deletions electron/native/pipewire-capture/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ pub enum Event {
asset_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
asset: Option<CursorAsset>,
/// `"click"` on the sample that coincides with a left-button press read
/// from evdev (see `input.rs`), absent otherwise. Omitted rather than
/// defaulted to `"move"` so the accumulator keeps that fallback in one
/// place and the wire stays quiet on the common case.
#[serde(skip_serializing_if = "Option::is_none")]
interaction_type: Option<String>,
},
/// Which capture node each audio source was linked to.
///
Expand Down Expand Up @@ -303,12 +309,32 @@ mod tests {
visible: true,
asset_id: None,
asset: None,
interaction_type: None,
});
assert_eq!(value["event"], "cursor-sample");
assert_eq!(value["x"], 100);
assert_eq!(value["visible"], true);
assert!(value.get("assetId").is_none());
assert!(value.get("asset").is_none());
// A plain move stays silent about its interaction so the accumulator's
// "move" default is the single source of that word.
assert!(value.get("interactionType").is_none());
}

#[test]
fn cursor_samples_report_a_click_when_tagged() {
let value = parse_one(&Event::CursorSample {
timestamp_ms: 12,
x: 100,
y: 200,
width: 1920,
height: 1080,
visible: true,
asset_id: None,
asset: None,
interaction_type: Some("click".to_owned()),
});
assert_eq!(value["interactionType"], "click");
}

#[test]
Expand All @@ -329,6 +355,7 @@ mod tests {
hotspot_x: 4,
hotspot_y: 3,
}),
interaction_type: None,
});
assert_eq!(value["assetId"], "abc");
assert_eq!(value["asset"]["imageDataUrl"], "data:image/png;base64,AA==");
Expand Down Expand Up @@ -359,6 +386,7 @@ mod tests {
visible: true,
asset_id: None,
asset: None,
interaction_type: None,
});
assert_eq!(value["timestampMs"], 1234, "a sample's capture time must not be overwritten");
}
Expand Down
Loading
Loading