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
+378
-94
No files matched your search
@@ -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 tracker = new HyprlandWindowTracker();
|
||||
const privateTracker = tracker as unknown as {
|
||||
handleSocketEvent: (event: string) => void;
|
||||
pollGeometry: () => void;
|
||||
};
|
||||
privateTracker.pollGeometry = () => {
|
||||
calls.push('poll');
|
||||
};
|
||||
const tracker = new HyprlandWindowTracker(undefined, async (command, args) => {
|
||||
calls.push(`${command} ${args.join(' ')}`);
|
||||
return '[]';
|
||||
});
|
||||
tracker.onWindowFocusChange = (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 { execSync } from 'child_process';
|
||||
import { BaseWindowTracker } from './base-tracker';
|
||||
import {
|
||||
createCoalescedTask,
|
||||
execFileWithTimeout,
|
||||
ProcessCommandLineCache,
|
||||
type CommandRunner,
|
||||
} from './linux-poll';
|
||||
import { createLogger } from '../logger';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
@@ -98,16 +103,20 @@ function isMpvClassName(value: string | undefined): boolean {
|
||||
return value.trim().toLowerCase().includes('mpv');
|
||||
}
|
||||
|
||||
export function selectHyprlandMpvWindow(
|
||||
clients: HyprlandClient[],
|
||||
options: SelectHyprlandMpvWindowOptions,
|
||||
): HyprlandClient | null {
|
||||
const visibleMpvWindows = clients.filter(
|
||||
function filterVisibleMpvWindows(clients: HyprlandClient[]): HyprlandClient[] {
|
||||
return clients.filter(
|
||||
(client) =>
|
||||
(isMpvClassName(client.class) || isMpvClassName(client.initialClass)) &&
|
||||
client.mapped !== false &&
|
||||
client.hidden !== true,
|
||||
);
|
||||
}
|
||||
|
||||
export function selectHyprlandMpvWindow(
|
||||
clients: HyprlandClient[],
|
||||
options: SelectHyprlandMpvWindowOptions,
|
||||
): HyprlandClient | null {
|
||||
const visibleMpvWindows = filterVisibleMpvWindows(clients);
|
||||
|
||||
if (!options.targetMpvSocketPath) {
|
||||
return preferActiveHyprlandWindow(visibleMpvWindows, options.activeWindowAddress);
|
||||
@@ -221,20 +230,30 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
private pollTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
private eventSocket: net.Socket | null = null;
|
||||
private readonly targetMpvSocketPath: string | null;
|
||||
private readonly runCommand: CommandRunner;
|
||||
private readonly commandLines: ProcessCommandLineCache;
|
||||
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();
|
||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
||||
this.runCommand = runCommand;
|
||||
this.commandLines = new ProcessCommandLineCache(runCommand);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
||||
this.pollGeometry();
|
||||
this.stopped = false;
|
||||
this.pollInterval = setInterval(() => void this.pollGeometry(), 250);
|
||||
void this.pollGeometry();
|
||||
this.connectEventSocket();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
@@ -249,6 +268,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
}
|
||||
}
|
||||
|
||||
override refreshNow(): Promise<void> {
|
||||
return this.pollGeometry();
|
||||
}
|
||||
|
||||
private connectEventSocket(): void {
|
||||
const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE;
|
||||
if (!hyprlandSig) {
|
||||
@@ -297,10 +320,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
if (name === 'activewindowv2') {
|
||||
const wasFocused = this.isTargetWindowFocused();
|
||||
this.activeWindowAddress = data || null;
|
||||
this.pollGeometry();
|
||||
if (this.isTargetWindowFocused() === wasFocused) {
|
||||
this.onWindowFocusChange?.(this.isTargetWindowFocused());
|
||||
}
|
||||
void this.pollGeometry().then(() => {
|
||||
// Re-emit unchanged focus so the overlay still refreshes its z-order.
|
||||
if (!this.stopped && this.isTargetWindowFocused() === wasFocused) {
|
||||
this.onWindowFocusChange?.(this.isTargetWindowFocused());
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -320,7 +345,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => {
|
||||
const pollTimeout = setTimeout(() => {
|
||||
this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout);
|
||||
this.pollGeometry();
|
||||
void this.pollGeometry();
|
||||
}, delayMs);
|
||||
return pollTimeout;
|
||||
});
|
||||
@@ -329,56 +354,59 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
}
|
||||
}
|
||||
|
||||
private pollGeometry(): void {
|
||||
try {
|
||||
const output = execSync('hyprctl -j clients', { encoding: 'utf-8' });
|
||||
const clients = parseHyprctlClients(output);
|
||||
if (!clients) {
|
||||
this.updateGeometry(null);
|
||||
return;
|
||||
}
|
||||
const mpvWindow = this.findTargetWindow(clients);
|
||||
private async pollGeometryOnce(): Promise<void> {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mpvWindow: HyprlandClient | null;
|
||||
let monitors: HyprlandMonitor[] | null = null;
|
||||
try {
|
||||
const clients = parseHyprctlClients(await this.runCommand('hyprctl', ['-j', 'clients']));
|
||||
mpvWindow = clients ? await this.findTargetWindow(clients) : null;
|
||||
if (mpvWindow) {
|
||||
const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress;
|
||||
this.updateGeometry(
|
||||
resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)),
|
||||
focused,
|
||||
);
|
||||
this.updateTargetWindowFocused(focused);
|
||||
} else {
|
||||
this.updateGeometry(null);
|
||||
monitors = await this.getHyprlandMonitors(mpvWindow);
|
||||
}
|
||||
} catch (err) {
|
||||
// hyprctl not available or failed - silent fail
|
||||
} catch {
|
||||
// hyprctl/ps not available, timed out, or failed - keep the last geometry
|
||||
return;
|
||||
}
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mpvWindow) {
|
||||
const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress;
|
||||
this.updateGeometry(resolveHyprlandWindowGeometry(mpvWindow, monitors), focused);
|
||||
this.updateTargetWindowFocused(focused);
|
||||
} else {
|
||||
this.updateGeometry(null);
|
||||
}
|
||||
}
|
||||
|
||||
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, {
|
||||
targetMpvSocketPath: this.targetMpvSocketPath,
|
||||
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)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync('hyprctl -j monitors', { encoding: 'utf-8' });
|
||||
return parseHyprctlMonitors(output);
|
||||
return parseHyprctlMonitors(await this.runCommand('hyprctl', ['-j', 'monitors']));
|
||||
} catch {
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { BaseWindowTracker } from './base-tracker';
|
||||
import {
|
||||
createCoalescedTask,
|
||||
execFileWithTimeout,
|
||||
ProcessCommandLineCache,
|
||||
type CommandRunner,
|
||||
} from './linux-poll';
|
||||
|
||||
interface SwayRect {
|
||||
x: number;
|
||||
@@ -38,24 +43,36 @@ interface SwayNode {
|
||||
export class SwayWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = 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();
|
||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
||||
this.runCommand = runCommand;
|
||||
this.commandLines = new ProcessCommandLineCache(runCommand);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
||||
this.pollGeometry();
|
||||
this.stopped = false;
|
||||
this.pollInterval = setInterval(() => void this.pollGeometry(), 250);
|
||||
void this.pollGeometry();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
override refreshNow(): Promise<void> {
|
||||
return this.pollGeometry();
|
||||
}
|
||||
|
||||
private collectMpvWindows(node: SwayNode): SwayNode[] {
|
||||
const windows: SwayNode[] = [];
|
||||
if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') {
|
||||
@@ -77,56 +94,56 @@ export class SwayWindowTracker extends BaseWindowTracker {
|
||||
return windows;
|
||||
}
|
||||
|
||||
private findTargetSocketWindow(node: SwayNode): SwayNode | null {
|
||||
private async findTargetSocketWindow(node: SwayNode): Promise<SwayNode | null> {
|
||||
const windows = this.collectMpvWindows(node);
|
||||
if (!this.targetMpvSocketPath) {
|
||||
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] : [])),
|
||||
);
|
||||
return (
|
||||
windows.find((candidate) => {
|
||||
const commandLine = candidate.pid ? commandLines.get(candidate.pid) : undefined;
|
||||
return commandLine !== undefined && this.matchesTargetSocket(commandLine);
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
private isWindowForTargetSocket(node: SwayNode): boolean {
|
||||
if (!node.pid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const commandLine = this.getWindowCommandLine(node.pid);
|
||||
if (!commandLine) {
|
||||
return false;
|
||||
}
|
||||
|
||||
private matchesTargetSocket(commandLine: string): boolean {
|
||||
return (
|
||||
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
|
||||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
|
||||
);
|
||||
}
|
||||
|
||||
private getWindowCommandLine(pid: number): string | null {
|
||||
const commandLine = execSync(`ps -p ${pid} -o args=`, {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
return commandLine || null;
|
||||
}
|
||||
private async pollGeometryOnce(): Promise<void> {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
private pollGeometry(): void {
|
||||
let mpvWindow: SwayNode | null;
|
||||
try {
|
||||
const output = execSync('swaymsg -t get_tree', { encoding: 'utf-8' });
|
||||
const tree: SwayNode = JSON.parse(output);
|
||||
const mpvWindow = this.findTargetSocketWindow(tree);
|
||||
const tree = JSON.parse(await this.runCommand('swaymsg', ['-t', 'get_tree'])) as SwayNode;
|
||||
mpvWindow = await 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) {
|
||||
this.updateGeometry({
|
||||
x: mpvWindow.rect.x,
|
||||
y: mpvWindow.rect.y,
|
||||
width: mpvWindow.rect.width,
|
||||
height: mpvWindow.rect.height,
|
||||
});
|
||||
} else {
|
||||
this.updateGeometry(null);
|
||||
}
|
||||
} catch (err) {
|
||||
// swaymsg not available or failed - silent fail
|
||||
if (mpvWindow && mpvWindow.rect) {
|
||||
this.updateGeometry({
|
||||
x: mpvWindow.rect.x,
|
||||
y: mpvWindow.rect.y,
|
||||
width: mpvWindow.rect.width,
|
||||
height: mpvWindow.rect.height,
|
||||
});
|
||||
} else {
|
||||
this.updateGeometry(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user