mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 16:49:50 -07:00
fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101)
This commit is contained in:
@@ -70,6 +70,18 @@ export abstract class BaseWindowTracker {
|
||||
return false;
|
||||
}
|
||||
|
||||
getTargetWindowMediaSourceId(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
getTargetWindowNativeId(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
raiseTargetWindow(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
protected updateTargetWindowFocused(focused: boolean): void {
|
||||
if (this.targetWindowFocused === focused) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseX11WindowGeometry, parseX11WindowPid, X11WindowTracker } from './x11-tracker';
|
||||
import {
|
||||
normalizeX11WindowId,
|
||||
parseX11RootActiveWindowId,
|
||||
parseX11WindowGeometry,
|
||||
parseX11WindowPid,
|
||||
X11WindowTracker,
|
||||
} from './x11-tracker';
|
||||
import { parseMacOSHelperOutput } from './macos-tracker';
|
||||
|
||||
test('parseX11WindowGeometry parses xwininfo output', () => {
|
||||
@@ -38,6 +44,19 @@ test('parseX11WindowPid parses xprop output', () => {
|
||||
assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = not-a-number'), null);
|
||||
});
|
||||
|
||||
test('normalizeX11WindowId normalizes decimal and hex ids', () => {
|
||||
assert.equal(normalizeX11WindowId('123\n'), '123');
|
||||
assert.equal(normalizeX11WindowId('0x7b'), '123');
|
||||
assert.equal(normalizeX11WindowId(''), null);
|
||||
assert.equal(normalizeX11WindowId('nope'), null);
|
||||
});
|
||||
|
||||
test('parseX11RootActiveWindowId parses root _NET_ACTIVE_WINDOW output', () => {
|
||||
assert.equal(parseX11RootActiveWindowId('_NET_ACTIVE_WINDOW(WINDOW): window id # 0x7b'), '123');
|
||||
assert.equal(parseX11RootActiveWindowId('_NET_ACTIVE_WINDOW(WINDOW): window id # 0x0'), '0');
|
||||
assert.equal(parseX11RootActiveWindowId('_NET_ACTIVE_WINDOW: not found.'), null);
|
||||
});
|
||||
|
||||
test('X11WindowTracker searches only visible mpv windows', async () => {
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const tracker = new X11WindowTracker(undefined, async (command, args) => {
|
||||
@@ -63,6 +82,147 @@ Height: 360`;
|
||||
});
|
||||
});
|
||||
|
||||
test('X11WindowTracker updates target focus from active X11 window', async () => {
|
||||
let activeWindowId = '999';
|
||||
const tracker = new X11WindowTracker(undefined, async (command, args) => {
|
||||
if (command === 'xdotool' && args[0] === 'search') {
|
||||
return '123';
|
||||
}
|
||||
if (command === 'xprop' && args.join(' ') === '-root _NET_ACTIVE_WINDOW') {
|
||||
return `_NET_ACTIVE_WINDOW(WINDOW): window id # ${activeWindowId}`;
|
||||
}
|
||||
if (command === 'xwininfo') {
|
||||
return `Absolute upper-left X: 0
|
||||
Absolute upper-left Y: 0
|
||||
Width: 640
|
||||
Height: 360`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const focusStates: boolean[] = [];
|
||||
tracker.onWindowFocusChange = (focused) => {
|
||||
focusStates.push(focused);
|
||||
};
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||
assert.deepEqual(focusStates, []);
|
||||
|
||||
activeWindowId = '123';
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||
assert.deepEqual(focusStates, [true]);
|
||||
});
|
||||
|
||||
test('X11WindowTracker falls back to xdotool active window when root active window is unavailable', async () => {
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const tracker = new X11WindowTracker(undefined, async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
if (command === 'xdotool' && args[0] === 'search') {
|
||||
return '123';
|
||||
}
|
||||
if (command === 'xprop' && args.join(' ') === '-root _NET_ACTIVE_WINDOW') {
|
||||
throw new Error('missing root active window');
|
||||
}
|
||||
if (command === 'xdotool' && args[0] === 'getactivewindow') {
|
||||
return '999';
|
||||
}
|
||||
if (command === 'xwininfo') {
|
||||
return `Absolute upper-left X: 0
|
||||
Absolute upper-left Y: 0
|
||||
Width: 640
|
||||
Height: 360`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(call) => call.command === 'xprop' && call.args.join(' ') === '-root _NET_ACTIVE_WINDOW',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
commands.some((call) => call.command === 'xdotool' && call.args[0] === 'getactivewindow'),
|
||||
);
|
||||
});
|
||||
|
||||
test('X11WindowTracker treats a different root active X11 window as mpv unfocused', async () => {
|
||||
const socketPath = '/tmp/subminer-mpv.sock';
|
||||
const tracker = new X11WindowTracker(socketPath, async (command, args) => {
|
||||
if (command === 'xdotool' && args[0] === 'search') {
|
||||
return '123';
|
||||
}
|
||||
if (command === 'xprop' && args.join(' ') === '-root _NET_ACTIVE_WINDOW') {
|
||||
return '_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3e7';
|
||||
}
|
||||
if (command === 'xprop' && args.join(' ') === '-id 123 _NET_WM_PID') {
|
||||
return '_NET_WM_PID(CARDINAL) = 4242';
|
||||
}
|
||||
if (command === 'xprop' && args.join(' ') === '-id 999 _NET_WM_PID') {
|
||||
return '_NET_WM_PID(CARDINAL) = 9999';
|
||||
}
|
||||
if (command === 'ps') {
|
||||
return `mpv --input-ipc-server=${socketPath}`;
|
||||
}
|
||||
if (command === 'xwininfo') {
|
||||
return `Absolute upper-left X: 0
|
||||
Absolute upper-left Y: 0
|
||||
Width: 640
|
||||
Height: 360`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||
assert.equal(tracker.getTargetWindowMediaSourceId(), 'window:123:0');
|
||||
assert.equal(tracker.getTargetWindowNativeId(), '123');
|
||||
});
|
||||
|
||||
test('X11WindowTracker treats active X11 windows with matching PID as focused', async () => {
|
||||
const socketPath = '/tmp/subminer-mpv.sock';
|
||||
const tracker = new X11WindowTracker(socketPath, async (command, args) => {
|
||||
if (command === 'xdotool' && args[0] === 'search') {
|
||||
return '123';
|
||||
}
|
||||
if (command === 'xprop' && args.join(' ') === '-root _NET_ACTIVE_WINDOW') {
|
||||
return '_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3e7';
|
||||
}
|
||||
if (command === 'xprop' && args.join(' ') === '-id 123 _NET_WM_PID') {
|
||||
return '_NET_WM_PID(CARDINAL) = 4242';
|
||||
}
|
||||
if (command === 'xprop' && args.join(' ') === '-id 999 _NET_WM_PID') {
|
||||
return '_NET_WM_PID(CARDINAL) = 4242';
|
||||
}
|
||||
if (command === 'ps') {
|
||||
return `mpv --input-ipc-server=${socketPath}`;
|
||||
}
|
||||
if (command === 'xwininfo') {
|
||||
return `Absolute upper-left X: 0
|
||||
Absolute upper-left Y: 0
|
||||
Width: 640
|
||||
Height: 360`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||
});
|
||||
|
||||
test('X11WindowTracker skips overlapping polls while one command is in flight', async () => {
|
||||
let commandCalls = 0;
|
||||
let release: (() => void) | undefined;
|
||||
@@ -94,6 +254,67 @@ Height: 360`;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test('X11WindowTracker activates and raises the tracked mpv window without changing the target', async () => {
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const tracker = new X11WindowTracker(undefined, async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
if (command === 'xdotool' && args[0] === 'search') {
|
||||
return '123';
|
||||
}
|
||||
if (command === 'xdotool' && args[0] === 'windowactivate') {
|
||||
return '';
|
||||
}
|
||||
if (command === 'xdotool' && args[0] === 'windowraise') {
|
||||
return '';
|
||||
}
|
||||
if (command === 'xwininfo') {
|
||||
return `Absolute upper-left X: 0
|
||||
Absolute upper-left Y: 0
|
||||
Width: 640
|
||||
Height: 360`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const raised = await tracker.raiseTargetWindow();
|
||||
|
||||
assert.equal(raised, true);
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(call) => call.command === 'xdotool' && call.args.join(' ') === 'windowactivate 123',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(call) => call.command === 'xdotool' && call.args.join(' ') === 'windowraise 123',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('X11WindowTracker raises the same target id captured before activation', async () => {
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
const tracker = new X11WindowTracker(undefined, async (command, args) => {
|
||||
commands.push({ command, args });
|
||||
if (command === 'xdotool' && args[0] === 'windowactivate') {
|
||||
(tracker as unknown as { targetWindowId: string | null }).targetWindowId = '456';
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
(tracker as unknown as { targetWindowId: string | null }).targetWindowId = '123';
|
||||
|
||||
const raised = await tracker.raiseTargetWindow();
|
||||
|
||||
assert.equal(raised, true);
|
||||
assert.deepEqual(
|
||||
commands.filter((call) => call.command === 'xdotool').map((call) => call.args.join(' ')),
|
||||
['windowactivate 123', 'windowraise 123'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseMacOSHelperOutput parses geometry and focused state', () => {
|
||||
assert.deepEqual(parseMacOSHelperOutput('120,240,1280,720,1'), {
|
||||
geometry: {
|
||||
|
||||
@@ -63,10 +63,32 @@ export function parseX11WindowPid(raw: string): number | null {
|
||||
return Number.isInteger(pid) ? pid : null;
|
||||
}
|
||||
|
||||
export function normalizeX11WindowId(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return BigInt(trimmed).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseX11RootActiveWindowId(raw: string): string | null {
|
||||
const match = raw.match(/window id #\s*(\S+)/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return normalizeX11WindowId(match[1]!);
|
||||
}
|
||||
|
||||
export class X11WindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly targetMpvSocketPath: string | null;
|
||||
private readonly runCommand: CommandRunner;
|
||||
private targetWindowId: string | null = null;
|
||||
private targetWindowPid: number | null = null;
|
||||
private pollInFlight = false;
|
||||
private currentPollIntervalMs = 750;
|
||||
private readonly stablePollIntervalMs = 250;
|
||||
@@ -89,6 +111,38 @@ export class X11WindowTracker extends BaseWindowTracker {
|
||||
}
|
||||
}
|
||||
|
||||
override getTargetWindowMediaSourceId(): string | null {
|
||||
const normalizedWindowId = this.targetWindowId
|
||||
? normalizeX11WindowId(this.targetWindowId)
|
||||
: null;
|
||||
return normalizedWindowId ? `window:${normalizedWindowId}:0` : null;
|
||||
}
|
||||
|
||||
override getTargetWindowNativeId(): string | null {
|
||||
return this.targetWindowId ? normalizeX11WindowId(this.targetWindowId) : null;
|
||||
}
|
||||
|
||||
override async raiseTargetWindow(): Promise<boolean> {
|
||||
const targetWindowId = this.targetWindowId;
|
||||
if (!targetWindowId) {
|
||||
return false;
|
||||
}
|
||||
let raised = false;
|
||||
try {
|
||||
await this.runCommand('xdotool', ['windowactivate', targetWindowId]);
|
||||
raised = true;
|
||||
} catch {
|
||||
// Some WMs reject activation but accept a plain restack below.
|
||||
}
|
||||
try {
|
||||
await this.runCommand('xdotool', ['windowraise', targetWindowId]);
|
||||
raised = true;
|
||||
} catch {
|
||||
// Keep any successful activation result.
|
||||
}
|
||||
return raised;
|
||||
}
|
||||
|
||||
private resetPollInterval(intervalMs: number): void {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
@@ -132,9 +186,14 @@ export class X11WindowTracker extends BaseWindowTracker {
|
||||
|
||||
const windowId = await this.findTargetWindowId(windowIdList);
|
||||
if (!windowId) {
|
||||
this.targetWindowId = null;
|
||||
this.targetWindowPid = null;
|
||||
this.updateGeometry(null);
|
||||
return;
|
||||
}
|
||||
this.targetWindowId = windowId;
|
||||
const targetPid = this.targetWindowPid ?? (await this.getWindowPid(windowId));
|
||||
this.targetWindowPid = targetPid;
|
||||
|
||||
const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
|
||||
const geometry = parseX11WindowGeometry(winInfo);
|
||||
@@ -143,7 +202,9 @@ export class X11WindowTracker extends BaseWindowTracker {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateGeometry(geometry);
|
||||
const focused = await this.isWindowActive(windowId, targetPid);
|
||||
this.updateGeometry(geometry, focused);
|
||||
this.updateTargetWindowFocused(focused);
|
||||
if (this.pollInterval && this.currentPollIntervalMs !== this.stablePollIntervalMs) {
|
||||
this.currentPollIntervalMs = this.stablePollIntervalMs;
|
||||
this.resetPollInterval(this.currentPollIntervalMs);
|
||||
@@ -151,12 +212,20 @@ export class X11WindowTracker extends BaseWindowTracker {
|
||||
}
|
||||
|
||||
private async findTargetWindowId(windowIds: string[]): Promise<string | null> {
|
||||
this.targetWindowId = null;
|
||||
this.targetWindowPid = null;
|
||||
if (!this.targetMpvSocketPath) {
|
||||
return windowIds[0] ?? null;
|
||||
const windowId = windowIds[0] ?? null;
|
||||
if (windowId) {
|
||||
this.targetWindowPid = await this.getWindowPid(windowId);
|
||||
}
|
||||
return windowId;
|
||||
}
|
||||
|
||||
for (const windowId of windowIds) {
|
||||
if (await this.isWindowForTargetSocket(windowId)) {
|
||||
const pid = await this.getTargetSocketWindowPid(windowId);
|
||||
if (pid !== null) {
|
||||
this.targetWindowPid = pid;
|
||||
return windowId;
|
||||
}
|
||||
}
|
||||
@@ -164,21 +233,58 @@ export class X11WindowTracker extends BaseWindowTracker {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async isWindowForTargetSocket(windowId: string): Promise<boolean> {
|
||||
private async getTargetSocketWindowPid(windowId: string): Promise<number | null> {
|
||||
const pid = await this.getWindowPid(windowId);
|
||||
if (pid === null) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandLine = await this.getWindowCommandLine(pid);
|
||||
if (!commandLine) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const matchesTargetSocket =
|
||||
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
|
||||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
|
||||
);
|
||||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`);
|
||||
return matchesTargetSocket ? pid : null;
|
||||
}
|
||||
|
||||
private async isWindowActive(windowId: string, targetPid: number | null): Promise<boolean> {
|
||||
const activeWindowId = await this.getX11ActiveWindowId();
|
||||
if (!activeWindowId) {
|
||||
return true;
|
||||
}
|
||||
const normalizedTarget = normalizeX11WindowId(windowId);
|
||||
const normalizedActive = normalizeX11WindowId(activeWindowId);
|
||||
if (!normalizedTarget || !normalizedActive) {
|
||||
return true;
|
||||
}
|
||||
if (targetPid !== null) {
|
||||
const activePid = await this.getWindowPid(normalizedActive);
|
||||
if (activePid !== null) {
|
||||
return activePid === targetPid;
|
||||
}
|
||||
}
|
||||
return normalizedTarget === normalizedActive;
|
||||
}
|
||||
|
||||
private async getX11ActiveWindowId(): Promise<string | null> {
|
||||
try {
|
||||
const rootActiveWindow = parseX11RootActiveWindowId(
|
||||
await this.runCommand('xprop', ['-root', '_NET_ACTIVE_WINDOW']),
|
||||
);
|
||||
if (rootActiveWindow) {
|
||||
return rootActiveWindow;
|
||||
}
|
||||
} catch {
|
||||
// Fall back below. Some minimal WMs do not expose _NET_ACTIVE_WINDOW.
|
||||
}
|
||||
try {
|
||||
return normalizeX11WindowId(await this.runCommand('xdotool', ['getactivewindow']));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getWindowPid(windowId: string): Promise<number | null> {
|
||||
|
||||
Reference in New Issue
Block a user