mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
68
src/window-trackers/base-tracker.ts
Normal file
68
src/window-trackers/base-tracker.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/window-trackers/hyprland-tracker.ts
Normal file
155
src/window-trackers/hyprland-tracker.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
85
src/window-trackers/index.ts
Normal file
85
src/window-trackers/index.ts
Normal 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,
|
||||
};
|
||||
210
src/window-trackers/macos-tracker.ts
Normal file
210
src/window-trackers/macos-tracker.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
132
src/window-trackers/sway-tracker.ts
Normal file
132
src/window-trackers/sway-tracker.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/window-trackers/x11-tracker.test.ts
Normal file
54
src/window-trackers/x11-tracker.test.ts
Normal 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));
|
||||
});
|
||||
199
src/window-trackers/x11-tracker.ts
Normal file
199
src/window-trackers/x11-tracker.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user