mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread (#289)
This commit is contained in:
6 files changed
+360
-76
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Hyprland and Sway window tracking no longer freezes the app when `hyprctl` or `swaymsg` is slow or hangs: polls now run in the background with a timeout and no longer pile up.
|
||||||
|
- The Hyprland/Sway trackers read each mpv process's command line once instead of spawning `ps` on every poll.
|
||||||
@@ -179,21 +179,119 @@ test('resolveHyprlandWindowGeometry uses monitor bounds for client-requested ful
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('HyprlandWindowTracker re-emits focus callback on active window events for z-order refresh', () => {
|
type PendingCommand = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
resolve: (output: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runner whose commands stay pending until the test resolves them.
|
||||||
|
function makeDeferredRunner() {
|
||||||
|
const pending: PendingCommand[] = [];
|
||||||
|
const runCommand = (command: string, args: string[]) =>
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
pending.push({ command, args, resolve });
|
||||||
|
});
|
||||||
|
return { pending, runCommand };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('HyprlandWindowTracker re-emits focus callback on active window events for z-order refresh', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const tracker = new HyprlandWindowTracker();
|
const tracker = new HyprlandWindowTracker(undefined, async (command, args) => {
|
||||||
const privateTracker = tracker as unknown as {
|
calls.push(`${command} ${args.join(' ')}`);
|
||||||
handleSocketEvent: (event: string) => void;
|
return '[]';
|
||||||
pollGeometry: () => void;
|
});
|
||||||
};
|
|
||||||
privateTracker.pollGeometry = () => {
|
|
||||||
calls.push('poll');
|
|
||||||
};
|
|
||||||
tracker.onWindowFocusChange = (focused) => {
|
tracker.onWindowFocusChange = (focused) => {
|
||||||
calls.push(`focus:${focused}`);
|
calls.push(`focus:${focused}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
privateTracker.handleSocketEvent('activewindowv2>>0xmpv');
|
(tracker as unknown as { handleSocketEvent: (event: string) => void }).handleSocketEvent(
|
||||||
|
'activewindowv2>>0xmpv',
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
assert.deepEqual(calls, ['poll', 'focus:false']);
|
assert.deepEqual(calls, ['hyprctl -j clients', 'focus:false']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HyprlandWindowTracker coalesces polls requested while hyprctl is still running', async () => {
|
||||||
|
const { pending, runCommand } = makeDeferredRunner();
|
||||||
|
const tracker = new HyprlandWindowTracker(undefined, runCommand);
|
||||||
|
|
||||||
|
const polls = [tracker.refreshNow(), tracker.refreshNow(), tracker.refreshNow()];
|
||||||
|
await flush();
|
||||||
|
assert.equal(pending.length, 1);
|
||||||
|
|
||||||
|
pending[0]!.resolve(JSON.stringify([makeClient({ at: [10, 20] })]));
|
||||||
|
await flush();
|
||||||
|
assert.equal(pending.length, 2);
|
||||||
|
|
||||||
|
pending[1]!.resolve(JSON.stringify([makeClient({ at: [30, 40] })]));
|
||||||
|
await Promise.all(polls);
|
||||||
|
assert.equal(pending.length, 2);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), { x: 30, y: 40, width: 1280, height: 720 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HyprlandWindowTracker ignores a poll that resolves after stop', async () => {
|
||||||
|
const { pending, runCommand } = makeDeferredRunner();
|
||||||
|
const tracker = new HyprlandWindowTracker(undefined, runCommand);
|
||||||
|
let found = 0;
|
||||||
|
tracker.onWindowFound = () => {
|
||||||
|
found += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const poll = tracker.refreshNow();
|
||||||
|
tracker.stop();
|
||||||
|
await flush();
|
||||||
|
pending[0]!.resolve(JSON.stringify([makeClient()]));
|
||||||
|
await poll;
|
||||||
|
|
||||||
|
assert.equal(found, 0);
|
||||||
|
assert.equal(tracker.getGeometry(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HyprlandWindowTracker reads each mpv command line once and re-reads after the pid leaves', async () => {
|
||||||
|
let clients = [makeClient({ pid: 10 })];
|
||||||
|
const psPids: string[] = [];
|
||||||
|
const tracker = new HyprlandWindowTracker('/tmp/subminer.sock', async (command, args) => {
|
||||||
|
if (command === 'hyprctl') {
|
||||||
|
return JSON.stringify(clients);
|
||||||
|
}
|
||||||
|
psPids.push(args[1]!);
|
||||||
|
return 'mpv --input-ipc-server=/tmp/subminer.sock video.mkv\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
await tracker.refreshNow();
|
||||||
|
await tracker.refreshNow();
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.deepEqual(psPids, ['10']);
|
||||||
|
|
||||||
|
clients = [];
|
||||||
|
await tracker.refreshNow();
|
||||||
|
clients = [makeClient({ pid: 10 })];
|
||||||
|
await tracker.refreshNow();
|
||||||
|
assert.deepEqual(psPids, ['10', '10']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HyprlandWindowTracker still tracks mpv when ps fails for another pid, and retries that pid', async () => {
|
||||||
|
const psPids: string[] = [];
|
||||||
|
const tracker = new HyprlandWindowTracker('/tmp/subminer.sock', async (command, args) => {
|
||||||
|
if (command === 'hyprctl') {
|
||||||
|
return JSON.stringify([makeClient({ pid: 10 }), makeClient({ pid: 20 })]);
|
||||||
|
}
|
||||||
|
psPids.push(args[1]!);
|
||||||
|
if (args[1] === '20') {
|
||||||
|
throw new Error('ps exited 1');
|
||||||
|
}
|
||||||
|
return 'mpv --input-ipc-server=/tmp/subminer.sock video.mkv\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
await tracker.refreshNow();
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
|
||||||
|
await tracker.refreshNow();
|
||||||
|
assert.deepEqual(psPids.sort(), ['10', '20', '20']);
|
||||||
});
|
});
|
||||||
@@ -17,8 +17,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { BaseWindowTracker } from './base-tracker';
|
import { BaseWindowTracker } from './base-tracker';
|
||||||
|
import {
|
||||||
|
createCoalescedTask,
|
||||||
|
execFileWithTimeout,
|
||||||
|
ProcessCommandLineCache,
|
||||||
|
type CommandRunner,
|
||||||
|
} from './linux-poll';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
|
|
||||||
@@ -98,16 +103,20 @@ function isMpvClassName(value: string | undefined): boolean {
|
|||||||
return value.trim().toLowerCase().includes('mpv');
|
return value.trim().toLowerCase().includes('mpv');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectHyprlandMpvWindow(
|
function filterVisibleMpvWindows(clients: HyprlandClient[]): HyprlandClient[] {
|
||||||
clients: HyprlandClient[],
|
return clients.filter(
|
||||||
options: SelectHyprlandMpvWindowOptions,
|
|
||||||
): HyprlandClient | null {
|
|
||||||
const visibleMpvWindows = clients.filter(
|
|
||||||
(client) =>
|
(client) =>
|
||||||
(isMpvClassName(client.class) || isMpvClassName(client.initialClass)) &&
|
(isMpvClassName(client.class) || isMpvClassName(client.initialClass)) &&
|
||||||
client.mapped !== false &&
|
client.mapped !== false &&
|
||||||
client.hidden !== true,
|
client.hidden !== true,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectHyprlandMpvWindow(
|
||||||
|
clients: HyprlandClient[],
|
||||||
|
options: SelectHyprlandMpvWindowOptions,
|
||||||
|
): HyprlandClient | null {
|
||||||
|
const visibleMpvWindows = filterVisibleMpvWindows(clients);
|
||||||
|
|
||||||
if (!options.targetMpvSocketPath) {
|
if (!options.targetMpvSocketPath) {
|
||||||
return preferActiveHyprlandWindow(visibleMpvWindows, options.activeWindowAddress);
|
return preferActiveHyprlandWindow(visibleMpvWindows, options.activeWindowAddress);
|
||||||
@@ -221,20 +230,30 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
|||||||
private pollTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
private pollTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
private eventSocket: net.Socket | null = null;
|
private eventSocket: net.Socket | null = null;
|
||||||
private readonly targetMpvSocketPath: string | null;
|
private readonly targetMpvSocketPath: string | null;
|
||||||
|
private readonly runCommand: CommandRunner;
|
||||||
|
private readonly commandLines: ProcessCommandLineCache;
|
||||||
private activeWindowAddress: string | null = null;
|
private activeWindowAddress: string | null = null;
|
||||||
|
private stopped = false;
|
||||||
|
// Interval ticks, focus events, and geometry bursts all funnel through here,
|
||||||
|
// so a slow hyprctl never has more than one poll in flight.
|
||||||
|
private readonly pollGeometry = createCoalescedTask(() => this.pollGeometryOnce());
|
||||||
|
|
||||||
constructor(targetMpvSocketPath?: string) {
|
constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileWithTimeout) {
|
||||||
super();
|
super();
|
||||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
||||||
|
this.runCommand = runCommand;
|
||||||
|
this.commandLines = new ProcessCommandLineCache(runCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
this.stopped = false;
|
||||||
this.pollGeometry();
|
this.pollInterval = setInterval(() => void this.pollGeometry(), 250);
|
||||||
|
void this.pollGeometry();
|
||||||
this.connectEventSocket();
|
this.connectEventSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
if (this.pollInterval) {
|
if (this.pollInterval) {
|
||||||
clearInterval(this.pollInterval);
|
clearInterval(this.pollInterval);
|
||||||
this.pollInterval = null;
|
this.pollInterval = null;
|
||||||
@@ -249,6 +268,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override refreshNow(): Promise<void> {
|
||||||
|
return this.pollGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
private connectEventSocket(): void {
|
private connectEventSocket(): void {
|
||||||
const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE;
|
const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE;
|
||||||
if (!hyprlandSig) {
|
if (!hyprlandSig) {
|
||||||
@@ -297,10 +320,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
|||||||
if (name === 'activewindowv2') {
|
if (name === 'activewindowv2') {
|
||||||
const wasFocused = this.isTargetWindowFocused();
|
const wasFocused = this.isTargetWindowFocused();
|
||||||
this.activeWindowAddress = data || null;
|
this.activeWindowAddress = data || null;
|
||||||
this.pollGeometry();
|
void this.pollGeometry().then(() => {
|
||||||
if (this.isTargetWindowFocused() === wasFocused) {
|
// Re-emit unchanged focus so the overlay still refreshes its z-order.
|
||||||
|
if (!this.stopped && this.isTargetWindowFocused() === wasFocused) {
|
||||||
this.onWindowFocusChange?.(this.isTargetWindowFocused());
|
this.onWindowFocusChange?.(this.isTargetWindowFocused());
|
||||||
}
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +345,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
|||||||
this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => {
|
this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => {
|
||||||
const pollTimeout = setTimeout(() => {
|
const pollTimeout = setTimeout(() => {
|
||||||
this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout);
|
this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout);
|
||||||
this.pollGeometry();
|
void this.pollGeometry();
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
return pollTimeout;
|
return pollTimeout;
|
||||||
});
|
});
|
||||||
@@ -329,56 +354,59 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private pollGeometry(): void {
|
private async pollGeometryOnce(): Promise<void> {
|
||||||
|
if (this.stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mpvWindow: HyprlandClient | null;
|
||||||
|
let monitors: HyprlandMonitor[] | null = null;
|
||||||
try {
|
try {
|
||||||
const output = execSync('hyprctl -j clients', { encoding: 'utf-8' });
|
const clients = parseHyprctlClients(await this.runCommand('hyprctl', ['-j', 'clients']));
|
||||||
const clients = parseHyprctlClients(output);
|
mpvWindow = clients ? await this.findTargetWindow(clients) : null;
|
||||||
if (!clients) {
|
if (mpvWindow) {
|
||||||
this.updateGeometry(null);
|
monitors = await this.getHyprlandMonitors(mpvWindow);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// hyprctl/ps not available, timed out, or failed - keep the last geometry
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.stopped) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mpvWindow = this.findTargetWindow(clients);
|
|
||||||
|
|
||||||
if (mpvWindow) {
|
if (mpvWindow) {
|
||||||
const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress;
|
const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress;
|
||||||
this.updateGeometry(
|
this.updateGeometry(resolveHyprlandWindowGeometry(mpvWindow, monitors), focused);
|
||||||
resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)),
|
|
||||||
focused,
|
|
||||||
);
|
|
||||||
this.updateTargetWindowFocused(focused);
|
this.updateTargetWindowFocused(focused);
|
||||||
} else {
|
} else {
|
||||||
this.updateGeometry(null);
|
this.updateGeometry(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
// hyprctl not available or failed - silent fail
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null {
|
private async findTargetWindow(clients: HyprlandClient[]): Promise<HyprlandClient | null> {
|
||||||
|
// Resolve command lines up front so window selection itself stays synchronous.
|
||||||
|
const commandLines = this.targetMpvSocketPath
|
||||||
|
? await this.commandLines.lookup(
|
||||||
|
filterVisibleMpvWindows(clients).flatMap((client) => (client.pid ? [client.pid] : [])),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
return selectHyprlandMpvWindow(clients, {
|
return selectHyprlandMpvWindow(clients, {
|
||||||
targetMpvSocketPath: this.targetMpvSocketPath,
|
targetMpvSocketPath: this.targetMpvSocketPath,
|
||||||
activeWindowAddress: this.activeWindowAddress,
|
activeWindowAddress: this.activeWindowAddress,
|
||||||
getWindowCommandLine: (pid) => this.getWindowCommandLine(pid),
|
getWindowCommandLine: (pid) => commandLines?.get(pid) ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHyprlandMonitors(client: HyprlandClient): HyprlandMonitor[] | null {
|
private async getHyprlandMonitors(client: HyprlandClient): Promise<HyprlandMonitor[] | null> {
|
||||||
if (!isHyprlandFullscreenClient(client)) {
|
if (!isHyprlandFullscreenClient(client)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = execSync('hyprctl -j monitors', { encoding: 'utf-8' });
|
return parseHyprctlMonitors(await this.runCommand('hyprctl', ['-j', 'monitors']));
|
||||||
return parseHyprctlMonitors(output);
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getWindowCommandLine(pid: number): string | null {
|
|
||||||
const commandLine = execSync(`ps -p ${pid} -o args=`, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
}).trim();
|
|
||||||
return commandLine || null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
SubMiner - All-in-one sentence mining overlay
|
||||||
|
Copyright (C) 2024 sudacode
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
|
||||||
|
// Shared polling helpers for the Hyprland and Sway trackers, which poll a
|
||||||
|
// compositor CLI on a short interval from the Electron main thread.
|
||||||
|
|
||||||
|
export type CommandRunner = (command: string, args: string[]) => Promise<string>;
|
||||||
|
|
||||||
|
const COMMAND_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
// Runs a command off the main thread. The timeout (SIGKILL, so a wedged client
|
||||||
|
// can't ignore it) keeps a hung compositor from stalling the poll loop forever.
|
||||||
|
export function execFileWithTimeout(command: string, args: string[]): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
{ encoding: 'utf-8', timeout: COMMAND_TIMEOUT_MS, killSignal: 'SIGKILL' },
|
||||||
|
(error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps an async task so overlapping calls coalesce: at most one run is in
|
||||||
|
// flight, and every call made during it shares a single follow-up run. The
|
||||||
|
// returned promise settles after a run that started no earlier than the call.
|
||||||
|
export function createCoalescedTask(task: () => Promise<void>): () => Promise<void> {
|
||||||
|
let active: Promise<void> | null = null;
|
||||||
|
let queued: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const run = (): Promise<void> => {
|
||||||
|
const current: Promise<void> = task().finally(() => {
|
||||||
|
if (active === current) {
|
||||||
|
active = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
active = current;
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (queued) {
|
||||||
|
return queued;
|
||||||
|
}
|
||||||
|
if (!active) {
|
||||||
|
return run();
|
||||||
|
}
|
||||||
|
const next = (): Promise<void> => {
|
||||||
|
queued = null;
|
||||||
|
return run();
|
||||||
|
};
|
||||||
|
queued = active.then(next, next);
|
||||||
|
return queued;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caches process command lines by pid. A pid's argv doesn't change, so each
|
||||||
|
// pid costs one `ps` spawn. Pids missing from the latest lookup are evicted,
|
||||||
|
// which keeps the cache bounded and re-reads a pid if it gets reused.
|
||||||
|
export class ProcessCommandLineCache {
|
||||||
|
private readonly commandLines = new Map<number, string>();
|
||||||
|
|
||||||
|
constructor(private readonly runCommand: CommandRunner) {}
|
||||||
|
|
||||||
|
// A pid whose `ps` call fails (e.g. it exited mid-poll) is left out of the
|
||||||
|
// result instead of failing the lookup. Empty results aren't cached, so that
|
||||||
|
// pid is retried on the next poll.
|
||||||
|
async lookup(pids: readonly number[]): Promise<ReadonlyMap<number, string>> {
|
||||||
|
const wanted = new Set(pids);
|
||||||
|
for (const pid of this.commandLines.keys()) {
|
||||||
|
if (!wanted.has(pid)) {
|
||||||
|
this.commandLines.delete(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
[...wanted]
|
||||||
|
.filter((pid) => !this.commandLines.has(pid))
|
||||||
|
.map(async (pid) => {
|
||||||
|
const commandLine = (
|
||||||
|
await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']).catch(() => '')
|
||||||
|
).trim();
|
||||||
|
if (commandLine) {
|
||||||
|
this.commandLines.set(pid, commandLine);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return this.commandLines;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { SwayWindowTracker } from './sway-tracker';
|
||||||
|
|
||||||
|
test('SwayWindowTracker tracks the mpv window bound to the target socket', async () => {
|
||||||
|
const tree = {
|
||||||
|
nodes: [
|
||||||
|
{ pid: 10, app_id: 'mpv', rect: { x: 0, y: 0, width: 800, height: 600 } },
|
||||||
|
{ pid: 20, app_id: 'mpv', rect: { x: 100, y: 50, width: 1280, height: 720 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const commandLines = new Map([
|
||||||
|
['10', 'mpv --input-ipc-server=/tmp/other.sock a.mkv'],
|
||||||
|
['20', 'mpv --input-ipc-server=/tmp/subminer.sock b.mkv'],
|
||||||
|
]);
|
||||||
|
const tracker = new SwayWindowTracker('/tmp/subminer.sock', async (command, args) =>
|
||||||
|
command === 'swaymsg' ? JSON.stringify(tree) : (commandLines.get(args[1]!) ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tracker.refreshNow();
|
||||||
|
|
||||||
|
assert.deepEqual(tracker.getGeometry(), { x: 100, y: 50, width: 1280, height: 720 });
|
||||||
|
});
|
||||||
@@ -16,8 +16,13 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { BaseWindowTracker } from './base-tracker';
|
import { BaseWindowTracker } from './base-tracker';
|
||||||
|
import {
|
||||||
|
createCoalescedTask,
|
||||||
|
execFileWithTimeout,
|
||||||
|
ProcessCommandLineCache,
|
||||||
|
type CommandRunner,
|
||||||
|
} from './linux-poll';
|
||||||
|
|
||||||
interface SwayRect {
|
interface SwayRect {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -38,24 +43,36 @@ interface SwayNode {
|
|||||||
export class SwayWindowTracker extends BaseWindowTracker {
|
export class SwayWindowTracker extends BaseWindowTracker {
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private readonly targetMpvSocketPath: string | null;
|
private readonly targetMpvSocketPath: string | null;
|
||||||
|
private readonly runCommand: CommandRunner;
|
||||||
|
private readonly commandLines: ProcessCommandLineCache;
|
||||||
|
private stopped = false;
|
||||||
|
private readonly pollGeometry = createCoalescedTask(() => this.pollGeometryOnce());
|
||||||
|
|
||||||
constructor(targetMpvSocketPath?: string) {
|
constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileWithTimeout) {
|
||||||
super();
|
super();
|
||||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
||||||
|
this.runCommand = runCommand;
|
||||||
|
this.commandLines = new ProcessCommandLineCache(runCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
this.stopped = false;
|
||||||
this.pollGeometry();
|
this.pollInterval = setInterval(() => void this.pollGeometry(), 250);
|
||||||
|
void this.pollGeometry();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
if (this.pollInterval) {
|
if (this.pollInterval) {
|
||||||
clearInterval(this.pollInterval);
|
clearInterval(this.pollInterval);
|
||||||
this.pollInterval = null;
|
this.pollInterval = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override refreshNow(): Promise<void> {
|
||||||
|
return this.pollGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
private collectMpvWindows(node: SwayNode): SwayNode[] {
|
private collectMpvWindows(node: SwayNode): SwayNode[] {
|
||||||
const windows: SwayNode[] = [];
|
const windows: SwayNode[] = [];
|
||||||
if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') {
|
if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') {
|
||||||
@@ -77,43 +94,46 @@ export class SwayWindowTracker extends BaseWindowTracker {
|
|||||||
return windows;
|
return windows;
|
||||||
}
|
}
|
||||||
|
|
||||||
private findTargetSocketWindow(node: SwayNode): SwayNode | null {
|
private async findTargetSocketWindow(node: SwayNode): Promise<SwayNode | null> {
|
||||||
const windows = this.collectMpvWindows(node);
|
const windows = this.collectMpvWindows(node);
|
||||||
if (!this.targetMpvSocketPath) {
|
if (!this.targetMpvSocketPath) {
|
||||||
return windows[0] || null;
|
return windows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return windows.find((candidate) => this.isWindowForTargetSocket(candidate)) || null;
|
const commandLines = await this.commandLines.lookup(
|
||||||
}
|
windows.flatMap((window) => (window.pid ? [window.pid] : [])),
|
||||||
|
);
|
||||||
private isWindowForTargetSocket(node: SwayNode): boolean {
|
return (
|
||||||
if (!node.pid) {
|
windows.find((candidate) => {
|
||||||
return false;
|
const commandLine = candidate.pid ? commandLines.get(candidate.pid) : undefined;
|
||||||
}
|
return commandLine !== undefined && this.matchesTargetSocket(commandLine);
|
||||||
|
}) || null
|
||||||
const commandLine = this.getWindowCommandLine(node.pid);
|
);
|
||||||
if (!commandLine) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private matchesTargetSocket(commandLine: string): boolean {
|
||||||
return (
|
return (
|
||||||
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
|
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
|
||||||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
|
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getWindowCommandLine(pid: number): string | null {
|
private async pollGeometryOnce(): Promise<void> {
|
||||||
const commandLine = execSync(`ps -p ${pid} -o args=`, {
|
if (this.stopped) {
|
||||||
encoding: 'utf-8',
|
return;
|
||||||
}).trim();
|
|
||||||
return commandLine || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private pollGeometry(): void {
|
let mpvWindow: SwayNode | null;
|
||||||
try {
|
try {
|
||||||
const output = execSync('swaymsg -t get_tree', { encoding: 'utf-8' });
|
const tree = JSON.parse(await this.runCommand('swaymsg', ['-t', 'get_tree'])) as SwayNode;
|
||||||
const tree: SwayNode = JSON.parse(output);
|
mpvWindow = await this.findTargetSocketWindow(tree);
|
||||||
const mpvWindow = this.findTargetSocketWindow(tree);
|
} catch {
|
||||||
|
// swaymsg/ps not available, timed out, or failed - keep the last geometry
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mpvWindow && mpvWindow.rect) {
|
if (mpvWindow && mpvWindow.rect) {
|
||||||
this.updateGeometry({
|
this.updateGeometry({
|
||||||
@@ -125,8 +145,5 @@ export class SwayWindowTracker extends BaseWindowTracker {
|
|||||||
} else {
|
} else {
|
||||||
this.updateGeometry(null);
|
this.updateGeometry(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
// swaymsg not available or failed - silent fail
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user