Skip to content
Open
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
1 change: 1 addition & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ interface Window {
onEditorModeChanged: (callback: (inEditor: boolean) => void) => () => void;
hudOverlayRendererReady: () => void;
hudOverlaySetWebcamPreviewVisible: (visible: boolean) => void;
hudOverlaySetFinalizing: (finalizing: boolean) => void;
getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>;
getHudOverlayMousePassthroughSupported: () => Promise<{
success: boolean;
Expand Down
26 changes: 26 additions & 0 deletions electron/hudFocusRecreation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { shouldRecreateHudToFocus } from "./hudFocusRecreation";

const idleHud = { platform: "linux", isHud: true, focused: false, captureInProgress: false };

describe("shouldRecreateHudToFocus", () => {
it("recreates an idle, unfocused HUD on Linux", () => {
expect(shouldRecreateHudToFocus(idleHud)).toBe(true);
});

it("keeps the HUD while it owns a capture", () => {
expect(shouldRecreateHudToFocus({ ...idleHud, captureInProgress: true })).toBe(false);
});

it("keeps a HUD that already has focus", () => {
expect(shouldRecreateHudToFocus({ ...idleHud, focused: true })).toBe(false);
});

it("leaves windows other than the HUD alone", () => {
expect(shouldRecreateHudToFocus({ ...idleHud, isHud: false })).toBe(false);
});

it.each(["darwin", "win32"] as const)("never recreates the HUD on %s", (platform) => {
expect(shouldRecreateHudToFocus({ ...idleHud, platform })).toBe(false);
});
});
20 changes: 20 additions & 0 deletions electron/hudFocusRecreation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Wayland compositors ignore focus(), so an unfocused HUD is destroyed and
* recreated to bring it forward. Its renderer owns the capture stream and the
* recorded data, so that has to wait while a recording is starting, running
* or being saved. Any other window is left alone: it may still be loading, or
* hold unsaved work.
*/
export function shouldRecreateHudToFocus({
platform,
isHud,
focused,
captureInProgress,
}: {
platform: string;
isHud: boolean;
focused: boolean;
captureInProgress: boolean;
}): boolean {
return platform === "linux" && isHud && !focused && !captureInProgress;
}
105 changes: 102 additions & 3 deletions electron/ipc/cursor/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
setHasLoggedInteractionHookFailure,
setInteractionCaptureCleanup,
setLastLeftClick,
setCompositorCursorSourceFailed,
setLinuxCursorScreenPoint,
} from "../state";
import type {
Expand All @@ -17,6 +18,8 @@ import type {
UiohookLike,
UiohookModuleNamespace,
} from "../types";
import { diffKwinButtons, startKwinClickEffect } from "./kwinClickEffect";
import { isKdeWaylandSession, startKwinCursorBridge } from "./kwinCursorBridge";
import {
getCursorCaptureElapsedMs,
getHookCursorScreenPoint,
Expand Down Expand Up @@ -56,6 +59,96 @@ export function stopInteractionCapture() {
}
}

let stopCompositorCursorSource: (() => void) | null = null;
let stopCompositorClickSource: (() => void) | null = null;
let compositorCursorSourceGeneration = 0;
let compositorClickSourceActive = false;

export function stopCompositorCursorCapture() {
compositorCursorSourceGeneration += 1;
stopCompositorCursorSource?.();
stopCompositorCursorSource = null;
stopCompositorClickSource?.();
stopCompositorClickSource = null;
compositorClickSourceActive = false;
setCompositorCursorSourceFailed(false);
}

export async function startCompositorCursorCapture() {
stopCompositorCursorCapture();
if (!isKdeWaylandSession(process.platform)) {
return;
}

const generation = compositorCursorSourceGeneration;
try {
const stop = await startKwinCursorBridge(
(point) => {
if (generation === compositorCursorSourceGeneration) {
setLinuxCursorScreenPoint({ ...point, updatedAt: Date.now(), logical: true });
}
},
() => {
if (generation !== compositorCursorSourceGeneration) {
return;
}
console.warn("[CursorTelemetry] KWin cursor position source exited.");
// Drop the stale position and let fresh X11 hook positions through.
stopCompositorCursorSource = null;
setLinuxCursorScreenPoint(null);
setCompositorCursorSourceFailed(true);
},
);
if (generation !== compositorCursorSourceGeneration) {
stop();
return;
}
stopCompositorCursorSource = stop;
} catch (error) {
console.warn("[CursorTelemetry] KWin cursor position source unavailable:", error);
if (generation === compositorCursorSourceGeneration) {
// Let fresh X11 hook positions through instead of recording nothing.
setCompositorCursorSourceFailed(true);
}
return;
}
Comment thread
selter2001 marked this conversation as resolved.

try {
const stopClicks = await startKwinClickEffect(
(buttons, oldButtons) => {
if (generation !== compositorCursorSourceGeneration) {
return;
}
const { pressed, released } = diffKwinButtons(buttons, oldButtons);
for (const button of pressed) {
recordCursorMouseDown(button);
}
for (let index = 0; index < released; index += 1) {
recordCursorMouseUp();
}
},
process.env,
() => {
if (generation !== compositorCursorSourceGeneration) {
return;
}
console.warn("[CursorTelemetry] KWin click source exited.");
// Hand clicks back to the X11 hook.
stopCompositorClickSource = null;
compositorClickSourceActive = false;
},
);
if (generation !== compositorCursorSourceGeneration) {
stopClicks();
return;
}
compositorClickSourceActive = true;
stopCompositorClickSource = stopClicks;
} catch (error) {
console.warn("[CursorTelemetry] KWin click source unavailable:", error);
}
}

function isUiohookLike(value: unknown): value is UiohookLike {
const candidate = value as Partial<UiohookLike> | null;
return typeof candidate?.on === "function" && typeof candidate?.start === "function";
Expand Down Expand Up @@ -268,12 +361,17 @@ export async function startInteractionCapture() {
return;
}

// With the compositor click source active the X11 hook would double-count clicks.
const onMouseDown = (event: HookMouseEvent) => {
recordCursorMouseDown(getHookMouseButton(event));
if (!compositorClickSourceActive) {
recordCursorMouseDown(getHookMouseButton(event));
}
};

const onMouseUp = () => {
recordCursorMouseUp();
if (!compositorClickSourceActive) {
recordCursorMouseUp();
}
};

const onMouseMove = (event: HookMouseEvent) => {
Expand All @@ -282,7 +380,8 @@ export async function startInteractionCapture() {
}

const point = getHookCursorScreenPoint(event);
if (!point) {
// The X11 hook only sees XWayland windows; the compositor source wins.
if (!point || stopCompositorCursorSource) {
return;
}

Expand Down
50 changes: 50 additions & 0 deletions electron/ipc/cursor/kwinClickEffect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
buildKwinClickEffectScript,
createKwinJournalButtonsParser,
diffKwinButtons,
KWIN_CLICK_JOURNAL_MARKER,
} from "./kwinClickEffect";

describe("diffKwinButtons", () => {
it("maps newly pressed Qt buttons to hook button numbers", () => {
expect(diffKwinButtons(1, 0)).toEqual({ pressed: [1], released: 0 });
expect(diffKwinButtons(2, 0)).toEqual({ pressed: [2], released: 0 });
expect(diffKwinButtons(4, 0)).toEqual({ pressed: [3], released: 0 });
expect(diffKwinButtons(3, 1)).toEqual({ pressed: [2], released: 0 });
});

it("counts releases and ignores unknown buttons", () => {
expect(diffKwinButtons(0, 1)).toEqual({ pressed: [], released: 1 });
expect(diffKwinButtons(0, 3)).toEqual({ pressed: [], released: 2 });
expect(diffKwinButtons(8, 0)).toEqual({ pressed: [], released: 0 });
});
});

describe("buildKwinClickEffectScript", () => {
it("prints button changes with the journal marker", () => {
const script = buildKwinClickEffectScript();
expect(script).toContain("effects.mouseChanged.connect");
expect(script).toContain(`print("${KWIN_CLICK_JOURNAL_MARKER} "`);
// Scripted effects have no D-Bus access.
expect(script).not.toContain("callDBus");
});
});

describe("createKwinJournalButtonsParser", () => {
it("emits button transitions across chunk boundaries and skips other lines", () => {
const changes: Array<[number, number]> = [];
const parser = createKwinJournalButtonsParser((buttons, oldButtons) =>
changes.push([buttons, oldButtons]),
);

parser(`js: ${KWIN_CLICK_JOURNAL_MARKER} 1 0\nkwin_wayland: something else\n`);
parser(`js: ${KWIN_CLICK_JOURNAL_MARKER} 0`);
parser(" 1\n");

expect(changes).toEqual([
[1, 0],
[0, 1],
]);
});
});
Loading