feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
/*
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 { WindowGeometry } from '../types';
export type GeometryChangeCallback = (geometry: WindowGeometry) => void;
export type WindowFoundCallback = (geometry: WindowGeometry) => void;
export type WindowLostCallback = () => void;
export abstract class BaseWindowTracker {
protected currentGeometry: WindowGeometry | null = null;
protected windowFound: boolean = false;
public onGeometryChange: GeometryChangeCallback | null = null;
public onWindowFound: WindowFoundCallback | null = null;
public onWindowLost: WindowLostCallback | null = null;
abstract start(): void;
abstract stop(): void;
getGeometry(): WindowGeometry | null {
return this.currentGeometry;
}
isTracking(): boolean {
return this.windowFound;
}
protected updateGeometry(newGeometry: WindowGeometry | null): void {
if (newGeometry) {
if (!this.windowFound) {
this.windowFound = true;
if (this.onWindowFound) this.onWindowFound(newGeometry);
}
if (
!this.currentGeometry ||
this.currentGeometry.x !== newGeometry.x ||
this.currentGeometry.y !== newGeometry.y ||
this.currentGeometry.width !== newGeometry.width ||
this.currentGeometry.height !== newGeometry.height
) {
this.currentGeometry = newGeometry;
if (this.onGeometryChange) this.onGeometryChange(newGeometry);
}
} else {
if (this.windowFound) {
this.windowFound = false;
this.currentGeometry = null;
if (this.onWindowLost) this.onWindowLost();
}
}
}
}

View File

@@ -0,0 +1,155 @@
/*
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 * as net from 'net';
import { execSync } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('hyprland');
interface HyprlandClient {
class: string;
at: [number, number];
size: [number, number];
pid?: number;
}
export class HyprlandWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private eventSocket: net.Socket | null = null;
private readonly targetMpvSocketPath: string | null;
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
this.connectEventSocket();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
if (this.eventSocket) {
this.eventSocket.destroy();
this.eventSocket = null;
}
}
private connectEventSocket(): void {
const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE;
if (!hyprlandSig) {
log.info('HYPRLAND_INSTANCE_SIGNATURE not set, skipping event socket');
return;
}
const xdgRuntime = process.env.XDG_RUNTIME_DIR || '/tmp';
const socketPath = `${xdgRuntime}/hypr/${hyprlandSig}/.socket2.sock`;
this.eventSocket = new net.Socket();
this.eventSocket.on('connect', () => {
log.info('Connected to Hyprland event socket');
});
this.eventSocket.on('data', (data: Buffer) => {
const events = data.toString().split('\n');
for (const event of events) {
if (
event.includes('movewindow') ||
event.includes('windowtitle') ||
event.includes('openwindow') ||
event.includes('closewindow') ||
event.includes('fullscreen')
) {
this.pollGeometry();
}
}
});
this.eventSocket.on('error', (err: Error) => {
log.error('Hyprland event socket error:', err.message);
});
this.eventSocket.on('close', () => {
log.info('Hyprland event socket closed');
});
this.eventSocket.connect(socketPath);
}
private pollGeometry(): void {
try {
const output = execSync('hyprctl clients -j', { encoding: 'utf-8' });
const clients: HyprlandClient[] = JSON.parse(output);
const mpvWindow = this.findTargetWindow(clients);
if (mpvWindow) {
this.updateGeometry({
x: mpvWindow.at[0],
y: mpvWindow.at[1],
width: mpvWindow.size[0],
height: mpvWindow.size[1],
});
} else {
this.updateGeometry(null);
}
} catch (err) {
// hyprctl not available or failed - silent fail
}
}
private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null {
const mpvWindows = clients.filter((client) => client.class === 'mpv');
if (!this.targetMpvSocketPath) {
return mpvWindows[0] || null;
}
for (const mpvWindow of mpvWindows) {
if (!mpvWindow.pid) {
continue;
}
const commandLine = this.getWindowCommandLine(mpvWindow.pid);
if (!commandLine) {
continue;
}
if (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
) {
return mpvWindow;
}
}
return null;
}
private getWindowCommandLine(pid: number): string | null {
const commandLine = execSync(`ps -p ${pid} -o args=`, {
encoding: 'utf-8',
}).trim();
return commandLine || null;
}
}

View File

@@ -0,0 +1,85 @@
/*
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 { BaseWindowTracker } from './base-tracker';
import { HyprlandWindowTracker } from './hyprland-tracker';
import { SwayWindowTracker } from './sway-tracker';
import { X11WindowTracker } from './x11-tracker';
import { MacOSWindowTracker } from './macos-tracker';
import { createLogger } from '../logger';
const log = createLogger('tracker');
export type Compositor = 'hyprland' | 'sway' | 'x11' | 'macos' | null;
export type Backend = 'auto' | Exclude<Compositor, null>;
export function detectCompositor(): Compositor {
if (process.platform === 'darwin') return 'macos';
if (process.env.HYPRLAND_INSTANCE_SIGNATURE) return 'hyprland';
if (process.env.SWAYSOCK) return 'sway';
if (process.platform === 'linux') return 'x11';
return null;
}
function normalizeCompositor(value: string): Compositor | null {
const normalized = value.trim().toLowerCase();
if (normalized === 'hyprland') return 'hyprland';
if (normalized === 'sway') return 'sway';
if (normalized === 'x11') return 'x11';
if (normalized === 'macos') return 'macos';
return null;
}
export function createWindowTracker(
override?: string | null,
targetMpvSocketPath?: string | null,
): BaseWindowTracker | null {
let compositor = detectCompositor();
if (override && override !== 'auto') {
const normalized = normalizeCompositor(override);
if (normalized) {
compositor = normalized;
} else {
log.warn(`Unsupported backend override "${override}", falling back to auto.`);
}
}
log.info(`Detected compositor: ${compositor || 'none'}`);
switch (compositor) {
case 'hyprland':
return new HyprlandWindowTracker(targetMpvSocketPath?.trim() || undefined);
case 'sway':
return new SwayWindowTracker(targetMpvSocketPath?.trim() || undefined);
case 'x11':
return new X11WindowTracker(targetMpvSocketPath?.trim() || undefined);
case 'macos':
return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined);
default:
log.warn('No supported compositor detected. Window tracking disabled.');
return null;
}
}
export {
BaseWindowTracker,
HyprlandWindowTracker,
SwayWindowTracker,
X11WindowTracker,
MacOSWindowTracker,
};

View File

@@ -0,0 +1,210 @@
/*
subminer - Yomitan integration for mpv
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';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { BaseWindowTracker } from './base-tracker';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('macos');
export class MacOSWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollInFlight = false;
private helperPath: string | null = null;
private helperType: 'binary' | 'swift' | null = null;
private lastExecErrorFingerprint: string | null = null;
private lastExecErrorLoggedAtMs = 0;
private readonly targetMpvSocketPath: string | null;
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.detectHelper();
}
private materializeAsarHelper(sourcePath: string, helperType: 'binary' | 'swift'): string | null {
if (!sourcePath.includes('.asar')) {
return sourcePath;
}
const fileName =
helperType === 'binary' ? 'get-mpv-window-macos' : 'get-mpv-window-macos.swift';
const targetDir = path.join(os.tmpdir(), 'subminer', 'helpers');
const targetPath = path.join(targetDir, fileName);
try {
fs.mkdirSync(targetDir, { recursive: true });
fs.copyFileSync(sourcePath, targetPath);
fs.chmodSync(targetPath, 0o755);
log.info(`Materialized macOS helper from asar: ${targetPath}`);
return targetPath;
} catch (error) {
log.warn(`Failed to materialize helper from asar: ${sourcePath}`, error);
return null;
}
}
private tryUseHelper(candidatePath: string, helperType: 'binary' | 'swift'): boolean {
if (!fs.existsSync(candidatePath)) {
return false;
}
const resolvedPath = this.materializeAsarHelper(candidatePath, helperType);
if (!resolvedPath) {
return false;
}
this.helperPath = resolvedPath;
this.helperType = helperType;
log.info(`Using macOS helper (${helperType}): ${resolvedPath}`);
return true;
}
private detectHelper(): void {
const shouldFilterBySocket = this.targetMpvSocketPath !== null;
// Fall back to Swift helper first when filtering by socket path to avoid
// stale prebuilt binaries that don't support the new socket filter argument.
const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift');
if (shouldFilterBySocket && this.tryUseHelper(swiftPath, 'swift')) {
return;
}
// Prefer resources path (outside asar) in packaged apps.
const resourcesPath = process.resourcesPath;
if (resourcesPath) {
const resourcesBinaryPath = path.join(resourcesPath, 'scripts', 'get-mpv-window-macos');
if (this.tryUseHelper(resourcesBinaryPath, 'binary')) {
return;
}
}
// Dist binary path (development / unpacked installs).
const distBinaryPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos');
if (this.tryUseHelper(distBinaryPath, 'binary')) {
return;
}
// Fall back to Swift script for development or if binary filtering is not
// supported in the current environment.
if (this.tryUseHelper(swiftPath, 'swift')) {
return;
}
log.warn('macOS window tracking helper not found');
}
private maybeLogExecError(err: Error, stderr: string): void {
const now = Date.now();
const fingerprint = `${err.message}|${stderr.trim()}`;
const shouldLog =
this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000;
if (!shouldLog) {
return;
}
this.lastExecErrorFingerprint = fingerprint;
this.lastExecErrorLoggedAtMs = now;
log.warn('macOS helper execution failed', {
helperPath: this.helperPath,
helperType: this.helperType,
error: err.message,
stderr: stderr.trim(),
});
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
private pollGeometry(): void {
if (this.pollInFlight || !this.helperPath || !this.helperType) {
return;
}
this.pollInFlight = true;
// Use Core Graphics API via Swift helper for reliable window detection
// This works with both bundled and unbundled mpv installations
const command = this.helperType === 'binary' ? this.helperPath : 'swift';
const args = this.helperType === 'binary' ? [] : [this.helperPath];
if (this.targetMpvSocketPath) {
args.push(this.targetMpvSocketPath);
}
execFile(
command,
args,
{
encoding: 'utf-8',
timeout: 1000,
maxBuffer: 1024 * 1024,
},
(err, stdout, stderr) => {
if (err) {
this.maybeLogExecError(err, stderr || '');
this.updateGeometry(null);
this.pollInFlight = false;
return;
}
const result = (stdout || '').trim();
if (result && result !== 'not-found') {
const parts = result.split(',');
if (parts.length === 4) {
const x = parseInt(parts[0]!, 10);
const y = parseInt(parts[1]!, 10);
const width = parseInt(parts[2]!, 10);
const height = parseInt(parts[3]!, 10);
if (
Number.isFinite(x) &&
Number.isFinite(y) &&
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
) {
this.updateGeometry({
x,
y,
width,
height,
});
this.pollInFlight = false;
return;
}
}
}
this.updateGeometry(null);
this.pollInFlight = false;
},
);
}
}

View File

@@ -0,0 +1,132 @@
/*
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 { execSync } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
interface SwayRect {
x: number;
y: number;
width: number;
height: number;
}
interface SwayNode {
pid?: number;
app_id?: string;
window_properties?: { class?: string };
rect?: SwayRect;
nodes?: SwayNode[];
floating_nodes?: SwayNode[];
}
export class SwayWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | null;
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
private collectMpvWindows(node: SwayNode): SwayNode[] {
const windows: SwayNode[] = [];
if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') {
windows.push(node);
}
if (node.nodes) {
for (const child of node.nodes) {
windows.push(...this.collectMpvWindows(child));
}
}
if (node.floating_nodes) {
for (const child of node.floating_nodes) {
windows.push(...this.collectMpvWindows(child));
}
}
return windows;
}
private findTargetSocketWindow(node: SwayNode): SwayNode | null {
const windows = this.collectMpvWindows(node);
if (!this.targetMpvSocketPath) {
return windows[0] || null;
}
return windows.find((candidate) => this.isWindowForTargetSocket(candidate)) || null;
}
private isWindowForTargetSocket(node: SwayNode): boolean {
if (!node.pid) {
return false;
}
const commandLine = this.getWindowCommandLine(node.pid);
if (!commandLine) {
return false;
}
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 pollGeometry(): void {
try {
const output = execSync('swaymsg -t get_tree', { encoding: 'utf-8' });
const tree: SwayNode = JSON.parse(output);
const mpvWindow = this.findTargetSocketWindow(tree);
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
}
}
}

View File

@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseX11WindowGeometry, parseX11WindowPid, X11WindowTracker } from './x11-tracker';
test('parseX11WindowGeometry parses xwininfo output', () => {
const geometry = parseX11WindowGeometry(`
Absolute upper-left X: 120
Absolute upper-left Y: 240
Width: 1280
Height: 720
`);
assert.deepEqual(geometry, {
x: 120,
y: 240,
width: 1280,
height: 720,
});
});
test('parseX11WindowPid parses xprop output', () => {
assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = 4242'), 4242);
assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = not-a-number'), null);
});
test('X11WindowTracker skips overlapping polls while one command is in flight', async () => {
let commandCalls = 0;
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const tracker = new X11WindowTracker(undefined, async (command) => {
commandCalls += 1;
if (command === 'xdotool') {
await gate;
return '123';
}
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();
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(commandCalls, 1);
assert.ok(release);
release();
await new Promise((resolve) => setTimeout(resolve, 0));
});

View File

@@ -0,0 +1,199 @@
/*
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';
import { BaseWindowTracker } from './base-tracker';
type CommandRunner = (command: string, args: string[]) => Promise<string>;
function execFileUtf8(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { encoding: 'utf-8' }, (error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
});
});
}
export function parseX11WindowGeometry(winInfo: string): {
x: number;
y: number;
width: number;
height: number;
} | null {
const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/);
const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/);
const widthMatch = winInfo.match(/Width:\s*(\d+)/);
const heightMatch = winInfo.match(/Height:\s*(\d+)/);
if (!xMatch || !yMatch || !widthMatch || !heightMatch) {
return null;
}
return {
x: parseInt(xMatch[1]!, 10),
y: parseInt(yMatch[1]!, 10),
width: parseInt(widthMatch[1]!, 10),
height: parseInt(heightMatch[1]!, 10),
};
}
export function parseX11WindowPid(raw: string): number | null {
const pidMatch = raw.match(/= (\d+)/);
if (!pidMatch) {
return null;
}
const pid = Number.parseInt(pidMatch[1]!, 10);
return Number.isInteger(pid) ? pid : null;
}
export class X11WindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | null;
private readonly runCommand: CommandRunner;
private pollInFlight = false;
private currentPollIntervalMs = 750;
private readonly stablePollIntervalMs = 250;
constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileUtf8) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.runCommand = runCommand;
}
start(): void {
this.resetPollInterval(this.currentPollIntervalMs);
this.pollGeometry();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
private resetPollInterval(intervalMs: number): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
this.pollInterval = setInterval(() => this.pollGeometry(), intervalMs);
}
private pollGeometry(): void {
if (this.pollInFlight) {
return;
}
this.pollInFlight = true;
void this.pollGeometryAsync()
.catch(() => {
this.updateGeometry(null);
})
.finally(() => {
this.pollInFlight = false;
});
}
private async pollGeometryAsync(): Promise<void> {
const windowIdsOutput = await this.runCommand('xdotool', ['search', '--class', 'mpv']);
const windowIds = windowIdsOutput.trim();
if (!windowIds) {
this.updateGeometry(null);
return;
}
const windowIdList = windowIds.split(/\s+/).filter(Boolean);
if (windowIdList.length === 0) {
this.updateGeometry(null);
return;
}
const windowId = await this.findTargetWindowId(windowIdList);
if (!windowId) {
this.updateGeometry(null);
return;
}
const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
const geometry = parseX11WindowGeometry(winInfo);
if (!geometry) {
this.updateGeometry(null);
return;
}
this.updateGeometry(geometry);
if (this.pollInterval && this.currentPollIntervalMs !== this.stablePollIntervalMs) {
this.currentPollIntervalMs = this.stablePollIntervalMs;
this.resetPollInterval(this.currentPollIntervalMs);
}
}
private async findTargetWindowId(windowIds: string[]): Promise<string | null> {
if (!this.targetMpvSocketPath) {
return windowIds[0] ?? null;
}
for (const windowId of windowIds) {
if (await this.isWindowForTargetSocket(windowId)) {
return windowId;
}
}
return null;
}
private async isWindowForTargetSocket(windowId: string): Promise<boolean> {
const pid = await this.getWindowPid(windowId);
if (pid === null) {
return false;
}
const commandLine = await this.getWindowCommandLine(pid);
if (!commandLine) {
return false;
}
return (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
);
}
private async getWindowPid(windowId: string): Promise<number | null> {
let windowPid: string;
try {
windowPid = await this.runCommand('xprop', ['-id', windowId, '_NET_WM_PID']);
} catch {
return null;
}
return parseX11WindowPid(windowPid);
}
private async getWindowCommandLine(pid: number): Promise<string | null> {
let raw: string;
try {
raw = await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']);
} catch {
return null;
}
const commandLine = raw.trim();
return commandLine || null;
}
}