Track mpv overlays by configured socket window only

This commit is contained in:
2026-02-14 13:44:10 -08:00
parent d2ca24f1c7
commit 910cf2dca4
8 changed files with 527 additions and 268 deletions

View File

@@ -11,6 +11,7 @@
// //
import Cocoa import Cocoa
import Foundation
private struct WindowGeometry { private struct WindowGeometry {
let x: Int let x: Int
@@ -19,6 +20,58 @@ private struct WindowGeometry {
let height: Int let height: Int
} }
private let targetMpvSocketPath: String? = {
guard CommandLine.arguments.count > 1 else {
return nil
}
let value = CommandLine.arguments[1].trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}()
private func windowCommandLineForPid(_ pid: pid_t) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/ps")
process.arguments = ["-p", "\(pid)", "-o", "args="]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
} catch {
return nil
}
if process.terminationStatus != 0 {
return nil
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let commandLine = String(data: data, encoding: .utf8) else {
return nil
}
return commandLine
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\n", with: " ")
}
private func windowHasTargetSocket(_ pid: pid_t) -> Bool {
guard let socketPath = targetMpvSocketPath else {
return true
}
guard let commandLine = windowCommandLineForPid(pid) else {
return false
}
return commandLine.contains("--input-ipc-server=\(socketPath)") ||
commandLine.contains("--input-ipc-server \(socketPath)")
}
private func geometryFromRect(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> WindowGeometry { private func geometryFromRect(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> WindowGeometry {
let minX = Int(floor(x)) let minX = Int(floor(x))
let minY = Int(floor(y)) let minY = Int(floor(y))
@@ -93,6 +146,10 @@ private func geometryFromAccessibilityAPI() -> WindowGeometry? {
for app in runningApps { for app in runningApps {
let appElement = AXUIElementCreateApplication(app.processIdentifier) let appElement = AXUIElementCreateApplication(app.processIdentifier)
if !windowHasTargetSocket(app.processIdentifier) {
continue
}
var windowsRef: CFTypeRef? var windowsRef: CFTypeRef?
let status = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) let status = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef)
guard status == .success, let windows = windowsRef as? [AXUIElement], !windows.isEmpty else { guard status == .success, let windows = windowsRef as? [AXUIElement], !windows.isEmpty else {
@@ -106,6 +163,15 @@ private func geometryFromAccessibilityAPI() -> WindowGeometry? {
continue continue
} }
var windowPid: pid_t = 0
if AXUIElementGetPid(window, &windowPid) != .success {
continue
}
if !windowHasTargetSocket(windowPid) {
continue
}
if let geometry = geometryFromAXWindow(window) { if let geometry = geometryFromAXWindow(window) {
return geometry return geometry
} }
@@ -127,6 +193,14 @@ private func geometryFromCoreGraphics() -> WindowGeometry? {
continue continue
} }
guard let ownerPid = window[kCGWindowOwnerPID as String] as? pid_t else {
continue
}
if !windowHasTargetSocket(ownerPid) {
continue
}
if let layer = window[kCGWindowLayer as String] as? Int, layer != 0 { if let layer = window[kCGWindowLayer as String] as? Int, layer != 0 {
continue continue
} }

View File

@@ -23,6 +23,7 @@ export function initializeOverlayRuntimeService(options: {
getOverlayWindows: () => BrowserWindow[]; getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void;
getMpvSocketPath: () => string;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null; getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null; getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
@@ -42,7 +43,10 @@ export function initializeOverlayRuntimeService(options: {
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility(); const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility();
options.registerGlobalShortcuts(); options.registerGlobalShortcuts();
const windowTracker = createWindowTracker(options.backendOverride); const windowTracker = createWindowTracker(
options.backendOverride,
options.getMpvSocketPath(),
);
options.setWindowTracker(windowTracker); options.setWindowTracker(windowTracker);
if (windowTracker) { if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => { windowTracker.onGeometryChange = (geometry: WindowGeometry) => {

View File

@@ -46,6 +46,7 @@ import { BaseWindowTracker } from "./window-trackers";
import type { import type {
JimakuApiResponse, JimakuApiResponse,
JimakuLanguagePreference, JimakuLanguagePreference,
RuntimeOptionId,
SubtitleData, SubtitleData,
SubtitlePosition, SubtitlePosition,
Keybinding, Keybinding,
@@ -59,6 +60,7 @@ import type {
KikuMergePreviewResponse, KikuMergePreviewResponse,
RuntimeOptionState, RuntimeOptionState,
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
ResolvedConfig,
} from "./types"; } from "./types";
import { SubtitleTimingTracker } from "./subtitle-timing-tracker"; import { SubtitleTimingTracker } from "./subtitle-timing-tracker";
import { AnkiIntegration } from "./anki-integration"; import { AnkiIntegration } from "./anki-integration";
@@ -155,13 +157,27 @@ import {
updateVisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService,
} from "./core/services"; } from "./core/services";
import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler"; import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler";
import { runAppReadyRuntimeService } from "./core/services/startup-service"; import {
runAppReadyRuntimeService,
} from "./core/services/startup-service";
import type { AppReadyRuntimeDeps } from "./core/services/startup-service"; import type { AppReadyRuntimeDeps } from "./core/services/startup-service";
import type { SubsyncRuntimeDeps } from "./core/services/subsync-runner-service"; import type { SubsyncRuntimeDeps } from "./core/services/subsync-runner-service";
import { import {
applyRuntimeOptionResultRuntimeService, applyRuntimeOptionResultRuntimeService,
} from "./core/services/runtime-options-ipc-service"; } from "./core/services/runtime-options-ipc-service";
import { createRuntimeOptionsIpcDeps, createSubsyncRuntimeDeps } from "./main/dependencies"; import {
createRuntimeOptionsIpcDeps,
createAnkiJimakuIpcRuntimeServiceDeps,
createCliCommandRuntimeServiceDeps,
createMainIpcRuntimeServiceDeps,
createMpvCommandRuntimeServiceDeps,
createSubsyncRuntimeDeps,
} from "./main/dependencies";
import {
createAppLifecycleRuntimeDeps as createAppLifecycleRuntimeDepsBuilder,
createAppReadyRuntimeDeps as createAppReadyRuntimeDepsBuilder,
} from "./main/app-lifecycle";
import { createStartupBootstrapRuntimeDeps } from "./main/startup";
import { import {
ConfigService, ConfigService,
DEFAULT_CONFIG, DEFAULT_CONFIG,
@@ -172,11 +188,6 @@ import {
import type { import type {
AppLifecycleDepsRuntimeOptions, AppLifecycleDepsRuntimeOptions,
} from "./core/services/app-lifecycle-service"; } from "./core/services/app-lifecycle-service";
import type { CliCommandDepsRuntimeOptions } from "./core/services/cli-command-service";
import type { HandleMpvCommandFromIpcOptions } from "./core/services/ipc-command-service";
import type { IpcDepsRuntimeOptions } from "./core/services/ipc-service";
import type { AnkiJimakuIpcRuntimeOptions } from "./core/services/anki-jimaku-service";
import type { StartupBootstrapRuntimeDeps } from "./core/services/startup-service";
if (process.platform === "linux") { if (process.platform === "linux") {
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal"); app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
@@ -620,7 +631,53 @@ function resolveMediaPathForJimaku(mediaPath: string | null): string | null {
: mediaPath; : mediaPath;
} }
const startupState = runStartupBootstrapRuntimeService(createStartupBootstrapRuntimeDeps()); const startupState = runStartupBootstrapRuntimeService(
createStartupBootstrapRuntimeDeps({
argv: process.argv,
parseArgs: (argv: string[]) => parseArgs(argv),
setLogLevelEnv: (level: string) => {
process.env.SUBMINER_LOG_LEVEL = level;
},
enableVerboseLogging: () => {
process.env.SUBMINER_LOG_LEVEL = "debug";
},
forceX11Backend: (args: CliArgs) => {
forceX11Backend(args);
},
enforceUnsupportedWaylandMode: (args: CliArgs) => {
enforceUnsupportedWaylandMode(args);
},
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
getDefaultSocketPath: () => getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
generateConfigTemplate: (config: ResolvedConfig) => generateConfigTemplate(config),
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => generateDefaultConfigFile(args, options),
onConfigGenerated: (exitCode: number) => {
process.exitCode = exitCode;
app.quit();
},
onGenerateConfigError: (error: Error) => {
console.error(`Failed to generate config: ${error.message}`);
process.exitCode = 1;
app.quit();
},
startAppLifecycle: (args: CliArgs) => {
startAppLifecycleService(
args,
createAppLifecycleDepsRuntimeService(createAppLifecycleRuntimeDeps()),
);
},
}),
);
appState.initialArgs = startupState.initialArgs; appState.initialArgs = startupState.initialArgs;
appState.mpvSocketPath = startupState.mpvSocketPath; appState.mpvSocketPath = startupState.mpvSocketPath;
@@ -630,12 +687,15 @@ appState.autoStartOverlay = startupState.autoStartOverlay;
appState.texthookerOnlyMode = startupState.texthookerOnlyMode; appState.texthookerOnlyMode = startupState.texthookerOnlyMode;
function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions { function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions {
return { return createAppLifecycleRuntimeDepsBuilder({
app, app,
platform: process.platform, platform: process.platform,
shouldStartApp: (nextArgs) => shouldStartApp(nextArgs), shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv) => parseArgs(argv), parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (nextArgs, source) => handleCliCommand(nextArgs, source), handleCliCommand: (
nextArgs: CliArgs,
source: CliCommandSource,
) => handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(), logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: async () => { onReady: async () => {
@@ -676,11 +736,11 @@ function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions {
updateVisibleOverlayVisibility(); updateVisibleOverlayVisibility();
updateInvisibleOverlayVisibility(); updateInvisibleOverlayVisibility();
}, },
}; });
} }
function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps { function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps {
return { return createAppReadyRuntimeDepsBuilder({
loadSubtitlePosition: () => loadSubtitlePosition(), loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => { resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
@@ -711,13 +771,13 @@ function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps {
}, },
); );
}, },
setSecondarySubMode: (mode) => { setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode; appState.secondarySubMode = mode;
}, },
defaultSecondarySubMode: "hover", defaultSecondarySubMode: "hover",
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port) => { startSubtitleWebsocket: (port: number) => {
subtitleWsService.start(port, () => appState.currentSubText); subtitleWsService.start(port, () => appState.currentSubText);
}, },
log: (message) => appLogger.logInfo(message), log: (message) => appLogger.logInfo(message),
@@ -738,82 +798,32 @@ function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps {
shouldAutoInitializeOverlayRuntimeFromConfig(), shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(), handleInitialArgs: () => handleInitialArgs(),
};
}
function createStartupBootstrapRuntimeDeps(): StartupBootstrapRuntimeDeps {
return {
argv: process.argv,
parseArgs: (argv) => parseArgs(argv),
setLogLevelEnv: (level) => {
process.env.SUBMINER_LOG_LEVEL = level;
},
enableVerboseLogging: () => {
process.env.SUBMINER_LOG_LEVEL = "debug";
},
forceX11Backend: (args) => {
forceX11Backend(args);
},
enforceUnsupportedWaylandMode: (args) => {
enforceUnsupportedWaylandMode(args);
},
getDefaultSocketPath: () => getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
runGenerateConfigFlow: (args) => {
if (!args.generateConfig || shouldStartApp(args)) {
return false;
}
generateDefaultConfigFile(args, {
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
generateTemplate: (config) => generateConfigTemplate(config as never),
})
.then((exitCode) => {
process.exitCode = exitCode;
app.quit();
})
.catch((error: Error) => {
console.error(`Failed to generate config: ${error.message}`);
process.exitCode = 1;
app.quit();
}); });
return true;
},
startAppLifecycle: (args) => {
startAppLifecycleService(
args,
createAppLifecycleDepsRuntimeService(createAppLifecycleRuntimeDeps()),
);
},
};
} }
function handleCliCommand( function handleCliCommand(
args: CliArgs, args: CliArgs,
source: CliCommandSource = "initial", source: CliCommandSource = "initial",
): void { ): void {
const deps = createCliCommandDepsRuntimeService(createCliCommandRuntimeServiceDeps()); const deps = createCliCommandDepsRuntimeService(
handleCliCommandService(args, source, deps); createCliCommandRuntimeServiceDeps({
}
function createCliCommandRuntimeServiceDeps(): CliCommandDepsRuntimeOptions {
return {
mpv: { mpv: {
getSocketPath: () => appState.mpvSocketPath, getSocketPath: () => appState.mpvSocketPath,
setSocketPath: (socketPath) => { setSocketPath: (socketPath: string) => {
appState.mpvSocketPath = socketPath; appState.mpvSocketPath = socketPath;
}, },
getClient: () => appState.mpvClient, getClient: () => appState.mpvClient,
showOsd: (text) => showMpvOsd(text), showOsd: (text: string) => showMpvOsd(text),
}, },
texthooker: { texthooker: {
service: texthookerService, service: texthookerService,
getPort: () => appState.texthookerPort, getPort: () => appState.texthookerPort,
setPort: (port) => { setPort: (port: number) => {
appState.texthookerPort = port; appState.texthookerPort = port;
}, },
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, shouldOpenBrowser: () =>
openInBrowser: (url) => { getResolvedConfig().texthooker?.openBrowser !== false,
openInBrowser: (url: string) => {
void shell.openExternal(url).catch((error) => { void shell.openExternal(url).catch((error) => {
console.error(`Failed to open browser for texthooker URL: ${url}`, error); console.error(`Failed to open browser for texthooker URL: ${url}`, error);
}); });
@@ -824,14 +834,14 @@ function createCliCommandRuntimeServiceDeps(): CliCommandDepsRuntimeOptions {
initialize: () => initializeOverlayRuntime(), initialize: () => initializeOverlayRuntime(),
toggleVisible: () => toggleVisibleOverlay(), toggleVisible: () => toggleVisibleOverlay(),
toggleInvisible: () => toggleInvisibleOverlay(), toggleInvisible: () => toggleInvisibleOverlay(),
setVisible: (visible) => setVisibleOverlayVisible(visible), setVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
setInvisible: (visible) => setInvisibleOverlayVisible(visible), setInvisible: (visible: boolean) => setInvisibleOverlayVisible(visible),
}, },
mining: { mining: {
copyCurrentSubtitle: () => copyCurrentSubtitle(), copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs) => startPendingMultiCopy(timeoutMs), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => mineSentenceCard(), mineSentenceCard: () => mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs) => startPendingMineSentenceMultiple: (timeoutMs: number) =>
startPendingMineSentenceMultiple(timeoutMs), startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(), updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(), triggerFieldGrouping: () => triggerFieldGrouping(),
@@ -849,17 +859,19 @@ function createCliCommandRuntimeServiceDeps(): CliCommandDepsRuntimeOptions {
hasMainWindow: () => Boolean(overlayManager.getMainWindow()), hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
}, },
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn, delayMs) => setTimeout(fn, delayMs), schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
log: (message) => { log: (message: string) => {
console.log(message); console.log(message);
}, },
warn: (message) => { warn: (message: string) => {
console.warn(message); console.warn(message);
}, },
error: (message, err) => { error: (message: string, err: unknown) => {
console.error(message, err); console.error(message, err);
}, },
}; }),
);
handleCliCommandService(args, source, deps);
} }
function handleInitialArgs(): void { function handleInitialArgs(): void {
@@ -868,17 +880,40 @@ function handleInitialArgs(): void {
} }
function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
mpvClient.on("subtitle-change", ({ text }) => {
appState.currentSubText = text;
subtitleWsService.broadcast(text);
void (async () => {
if (getOverlayWindows().length > 0) {
const subtitleData = await tokenizeSubtitle(text);
broadcastToOverlayWindows("subtitle:set", subtitleData);
}
})();
});
mpvClient.on("subtitle-ass-change", ({ text }) => {
appState.currentSubAssText = text;
broadcastToOverlayWindows("subtitle-ass:set", text);
});
mpvClient.on("secondary-subtitle-change", ({ text }) => {
broadcastToOverlayWindows("secondary-subtitle:set", text);
});
mpvClient.on("subtitle-timing", ({ text, start, end }) => {
if (!text.trim() || !appState.subtitleTimingTracker) {
return;
}
appState.subtitleTimingTracker.recordSubtitle(text, start, end);
});
mpvClient.on("media-path-change", ({ path }) => { mpvClient.on("media-path-change", ({ path }) => {
updateCurrentMediaPath(path); updateCurrentMediaPath(path);
}); });
mpvClient.on("media-title-change", ({ title }) => { mpvClient.on("media-title-change", ({ title }) => {
updateCurrentMediaTitle(title); updateCurrentMediaTitle(title);
}); });
mpvClient.on("subtitle-ass-change", ({ text }) => { mpvClient.on("subtitle-metrics-change", ({ patch }) => {
appState.currentSubAssText = text; updateMpvSubtitleRenderMetrics(patch);
}); });
mpvClient.on("subtitle-change", ({ text }) => { mpvClient.on("secondary-subtitle-visibility", ({ visible }) => {
appState.currentSubText = text; appState.previousSecondarySubVisibility = visible;
}); });
} }
@@ -886,41 +921,14 @@ function createMpvClientRuntimeService(): MpvIpcClient {
const mpvClient = new MpvIpcClient(appState.mpvSocketPath, { const mpvClient = new MpvIpcClient(appState.mpvSocketPath, {
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
autoStartOverlay: appState.autoStartOverlay, autoStartOverlay: appState.autoStartOverlay,
setOverlayVisible: (visible) => setOverlayVisible(visible), setOverlayVisible: (visible: boolean) => setOverlayVisible(visible),
shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility: () =>
shouldBindVisibleOverlayToMpvSubVisibility(), shouldBindVisibleOverlayToMpvSubVisibility(),
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getReconnectTimer: () => appState.reconnectTimer, getReconnectTimer: () => appState.reconnectTimer,
setReconnectTimer: (timer) => { setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer; appState.reconnectTimer = timer;
}, },
getCurrentSubText: () => appState.currentSubText,
setCurrentSubText: (text) => {
appState.currentSubText = text;
},
setCurrentSubAssText: (text) => {
appState.currentSubAssText = text;
},
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
subtitleWsBroadcast: (text) => {
subtitleWsService.broadcast(text);
},
getOverlayWindowsCount: () => getOverlayWindows().length,
tokenizeSubtitle: (text) => tokenizeSubtitle(text),
broadcastToOverlayWindows: (channel, ...channelArgs) => {
broadcastToOverlayWindows(channel, ...channelArgs);
},
updateMpvSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch);
},
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getPreviousSecondarySubVisibility: () => appState.previousSecondarySubVisibility,
setPreviousSecondarySubVisibility: (value) => {
appState.previousSecondarySubVisibility = value;
},
showMpvOsd: (text) => {
showMpvOsd(text);
},
}); });
bindMpvClientEventHandlers(mpvClient); bindMpvClientEventHandlers(mpvClient);
return mpvClient; return mpvClient;
@@ -1085,6 +1093,7 @@ function initializeOverlayRuntime(): void {
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
getMpvSocketPath: () => appState.mpvSocketPath,
getRuntimeOptionsManager: () => appState.runtimeOptionsManager, getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
setAnkiIntegration: (integration) => { setAnkiIntegration: (integration) => {
appState.ankiIntegration = integration as AnkiIntegration | null; appState.ankiIntegration = integration as AnkiIntegration | null;
@@ -1126,7 +1135,7 @@ function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolve
function getOverlayShortcutRuntimeHandlers() { function getOverlayShortcutRuntimeHandlers() {
return createOverlayShortcutRuntimeHandlers( return createOverlayShortcutRuntimeHandlers(
{ {
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text: string) => showMpvOsd(text),
openRuntimeOptions: () => { openRuntimeOptions: () => {
openRuntimeOptionsPalette(); openRuntimeOptionsPalette();
}, },
@@ -1136,7 +1145,7 @@ function getOverlayShortcutRuntimeHandlers() {
}); });
}, },
markAudioCard: () => markLastCardAsAudioCard(), markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs) => { copySubtitleMultiple: (timeoutMs: number) => {
startPendingMultiCopy(timeoutMs); startPendingMultiCopy(timeoutMs);
}, },
copySubtitle: () => { copySubtitle: () => {
@@ -1147,7 +1156,7 @@ function getOverlayShortcutRuntimeHandlers() {
triggerFieldGrouping: () => triggerFieldGrouping(), triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsync: () => triggerSubsyncFromConfig(), triggerSubsync: () => triggerSubsyncFromConfig(),
mineSentence: () => mineSentenceCard(), mineSentence: () => mineSentenceCard(),
mineSentenceMultiple: (timeoutMs) => { mineSentenceMultiple: (timeoutMs: number) => {
startPendingMineSentenceMultiple(timeoutMs); startPendingMineSentenceMultiple(timeoutMs);
}, },
}, },
@@ -1495,15 +1504,13 @@ function handleOverlayModalClosed(modal: OverlayHostedModal): void {
} }
function handleMpvCommandFromIpc(command: (string | number)[]): void { function handleMpvCommandFromIpc(command: (string | number)[]): void {
handleMpvCommandFromIpcService(command, createMpvCommandRuntimeServiceDeps()); handleMpvCommandFromIpcService(
} command,
createMpvCommandRuntimeServiceDeps({
function createMpvCommandRuntimeServiceDeps(): HandleMpvCommandFromIpcOptions {
return {
specialCommands: SPECIAL_COMMANDS, specialCommands: SPECIAL_COMMANDS,
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
runtimeOptionsCycle: (id, direction) => { runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => {
if (!appState.runtimeOptionsManager) { if (!appState.runtimeOptionsManager) {
return { ok: false, error: "Runtime options manager unavailable" }; return { ok: false, error: "Runtime options manager unavailable" };
} }
@@ -1512,14 +1519,15 @@ function createMpvCommandRuntimeServiceDeps(): HandleMpvCommandFromIpcOptions {
(text) => showMpvOsd(text), (text) => showMpvOsd(text),
); );
}, },
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text: string) => showMpvOsd(text),
mpvReplaySubtitle: () => replayCurrentSubtitleRuntimeService(appState.mpvClient), mpvReplaySubtitle: () => replayCurrentSubtitleRuntimeService(appState.mpvClient),
mpvPlayNextSubtitle: () => playNextSubtitleRuntimeService(appState.mpvClient), mpvPlayNextSubtitle: () => playNextSubtitleRuntimeService(appState.mpvClient),
mpvSendCommand: (rawCommand) => mpvSendCommand: (rawCommand: (string | number)[]) =>
sendMpvCommandRuntimeService(appState.mpvClient, rawCommand), sendMpvCommandRuntimeService(appState.mpvClient, rawCommand),
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
}; }),
);
} }
async function runSubsyncManualFromIpc( async function runSubsyncManualFromIpc(
@@ -1534,22 +1542,15 @@ const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
}); });
registerIpcHandlersService( registerIpcHandlersService(
createIpcDepsRuntimeService(createMainIpcRuntimeServiceDeps()), createIpcDepsRuntimeService(
); createMainIpcRuntimeServiceDeps({
registerAnkiJimakuIpcRuntimeService(
createAnkiJimakuIpcRuntimeServiceDeps(),
);
function createMainIpcRuntimeServiceDeps(): IpcDepsRuntimeOptions {
return {
getInvisibleWindow: () => overlayManager.getInvisibleWindow(), getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisibility: () => getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(),
overlayManager.getInvisibleOverlayVisible(), onOverlayModalClosed: (modal) => {
onOverlayModalClosed: (modal) => handleOverlayModalClosed(modal as OverlayHostedModal);
handleOverlayModalClosed(modal as OverlayHostedModal), },
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
quitApp: () => app.quit(), quitApp: () => app.quit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
@@ -1558,28 +1559,30 @@ function createMainIpcRuntimeServiceDeps(): IpcDepsRuntimeOptions {
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics, getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getSubtitlePosition: () => loadSubtitlePosition(), getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => getResolvedConfig().subtitleStyle ?? null, getSubtitleStyle: () => getResolvedConfig().subtitleStyle ?? null,
saveSubtitlePosition: (position) => saveSubtitlePosition: (position: unknown) =>
saveSubtitlePosition(position as SubtitlePosition), saveSubtitlePosition(position as SubtitlePosition),
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
handleMpvCommand: (command) => handleMpvCommandFromIpc(command), handleMpvCommand: (command: (string | number)[]) =>
handleMpvCommandFromIpc(command),
getKeybindings: () => appState.keybindings, getKeybindings: () => appState.keybindings,
getSecondarySubMode: () => appState.secondarySubMode, getSecondarySubMode: () => appState.secondarySubMode,
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
runSubsyncManual: (request) => runSubsyncManual: (request: unknown) =>
runSubsyncManualFromIpc(request as SubsyncManualRunRequest), runSubsyncManualFromIpc(request as SubsyncManualRunRequest),
getAnkiConnectStatus: () => appState.ankiIntegration !== null, getAnkiConnectStatus: () => appState.ankiIntegration !== null,
getRuntimeOptions: () => getRuntimeOptionsState(), getRuntimeOptions: () => getRuntimeOptionsState(),
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
reportOverlayContentBounds: (payload) => { reportOverlayContentBounds: (payload: unknown) => {
overlayContentMeasurementStore.report(payload); overlayContentMeasurementStore.report(payload);
}, },
}; }),
} ),
);
function createAnkiJimakuIpcRuntimeServiceDeps(): AnkiJimakuIpcRuntimeOptions { registerAnkiJimakuIpcRuntimeService(
return { createAnkiJimakuIpcRuntimeServiceDeps({
patchAnkiConnectEnabled: (enabled) => { patchAnkiConnectEnabled: (enabled: boolean) => {
configService.patchRawConfig({ ankiConnect: { enabled } }); configService.patchRawConfig({ ankiConnect: { enabled } });
}, },
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
@@ -1587,25 +1590,32 @@ function createAnkiJimakuIpcRuntimeServiceDeps(): AnkiJimakuIpcRuntimeOptions {
getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
getAnkiIntegration: () => appState.ankiIntegration, getAnkiIntegration: () => appState.ankiIntegration,
setAnkiIntegration: (integration) => { setAnkiIntegration: (integration: AnkiIntegration | null) => {
appState.ankiIntegration = integration; appState.ankiIntegration = integration;
}, },
showDesktopNotification, showDesktopNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(), createFieldGroupingCallback: () => createFieldGroupingCallback(),
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
getFieldGroupingResolver: () => getFieldGroupingResolver(), getFieldGroupingResolver: () => getFieldGroupingResolver(),
setFieldGroupingResolver: (resolver) => setFieldGroupingResolver(resolver), setFieldGroupingResolver: (
parseMediaInfo: (mediaPath) => parseMediaInfo(resolveMediaPathForJimaku(mediaPath)), resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => setFieldGroupingResolver(resolver),
parseMediaInfo: (mediaPath: string | null) =>
parseMediaInfo(resolveMediaPathForJimaku(mediaPath)),
getCurrentMediaPath: () => appState.currentMediaPath, getCurrentMediaPath: () => appState.currentMediaPath,
jimakuFetchJson: <T>( jimakuFetchJson: <T>(
endpoint: string, endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>, query?: Record<string, string | number | boolean | null | undefined>,
): Promise<JimakuApiResponse<T>> => jimakuFetchJson(endpoint, query), ): Promise<JimakuApiResponse<T>> =>
jimakuFetchJson<T>(endpoint, query),
getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(), getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(),
getJimakuLanguagePreference: () => getJimakuLanguagePreference(), getJimakuLanguagePreference: () => getJimakuLanguagePreference(),
resolveJimakuApiKey: () => resolveJimakuApiKey(), resolveJimakuApiKey: () => resolveJimakuApiKey(),
isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath),
downloadToFile: (url, destPath, headers) => downloadToFile: (
downloadToFile(url, destPath, headers), url: string,
}; destPath: string,
} headers: Record<string, string>,
) => downloadToFile(url, destPath, headers),
}),
);

View File

@@ -27,11 +27,18 @@ interface HyprlandClient {
class: string; class: string;
at: [number, number]; at: [number, number];
size: [number, number]; size: [number, number];
pid?: number;
} }
export class HyprlandWindowTracker extends BaseWindowTracker { export class HyprlandWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null; private pollInterval: ReturnType<typeof setInterval> | null = null;
private eventSocket: net.Socket | null = null; private eventSocket: net.Socket | null = null;
private readonly targetMpvSocketPath: string | null;
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
}
start(): void { start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250); this.pollInterval = setInterval(() => this.pollGeometry(), 250);
@@ -95,7 +102,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
try { try {
const output = execSync("hyprctl clients -j", { encoding: "utf-8" }); const output = execSync("hyprctl clients -j", { encoding: "utf-8" });
const clients: HyprlandClient[] = JSON.parse(output); const clients: HyprlandClient[] = JSON.parse(output);
const mpvWindow = clients.find((c) => c.class === "mpv"); const mpvWindow = this.findTargetWindow(clients);
if (mpvWindow) { if (mpvWindow) {
this.updateGeometry({ this.updateGeometry({
@@ -111,4 +118,38 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
// hyprctl not available or failed - silent fail // 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

@@ -47,6 +47,7 @@ function normalizeCompositor(value: string): Compositor | null {
export function createWindowTracker( export function createWindowTracker(
override?: string | null, override?: string | null,
targetMpvSocketPath?: string | null,
): BaseWindowTracker | null { ): BaseWindowTracker | null {
let compositor = detectCompositor(); let compositor = detectCompositor();
@@ -64,13 +65,21 @@ export function createWindowTracker(
switch (compositor) { switch (compositor) {
case "hyprland": case "hyprland":
return new HyprlandWindowTracker(); return new HyprlandWindowTracker(
targetMpvSocketPath?.trim() || undefined,
);
case "sway": case "sway":
return new SwayWindowTracker(); return new SwayWindowTracker(
targetMpvSocketPath?.trim() || undefined,
);
case "x11": case "x11":
return new X11WindowTracker(); return new X11WindowTracker(
targetMpvSocketPath?.trim() || undefined,
);
case "macos": case "macos":
return new MacOSWindowTracker(); return new MacOSWindowTracker(
targetMpvSocketPath?.trim() || undefined,
);
default: default:
log.warn("No supported compositor detected. Window tracking disabled."); log.warn("No supported compositor detected. Window tracking disabled.");
return null; return null;

View File

@@ -32,9 +32,11 @@ export class MacOSWindowTracker extends BaseWindowTracker {
private helperType: "binary" | "swift" | null = null; private helperType: "binary" | "swift" | null = null;
private lastExecErrorFingerprint: string | null = null; private lastExecErrorFingerprint: string | null = null;
private lastExecErrorLoggedAtMs = 0; private lastExecErrorLoggedAtMs = 0;
private readonly targetMpvSocketPath: string | null;
constructor() { constructor(targetMpvSocketPath?: string) {
super(); super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.detectHelper(); this.detectHelper();
} }
@@ -85,6 +87,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
} }
private detectHelper(): void { 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. // Prefer resources path (outside asar) in packaged apps.
const resourcesPath = process.resourcesPath; const resourcesPath = process.resourcesPath;
if (resourcesPath) { if (resourcesPath) {
@@ -110,14 +127,8 @@ export class MacOSWindowTracker extends BaseWindowTracker {
return; return;
} }
// Fall back to Swift script for development. // Fall back to Swift script for development or if binary filtering is not
const swiftPath = path.join( // supported in the current environment.
__dirname,
"..",
"..",
"scripts",
"get-mpv-window-macos.swift",
);
if (this.tryUseHelper(swiftPath, "swift")) { if (this.tryUseHelper(swiftPath, "swift")) {
return; return;
} }
@@ -167,6 +178,9 @@ export class MacOSWindowTracker extends BaseWindowTracker {
// This works with both bundled and unbundled mpv installations // This works with both bundled and unbundled mpv installations
const command = this.helperType === "binary" ? this.helperPath : "swift"; const command = this.helperType === "binary" ? this.helperPath : "swift";
const args = this.helperType === "binary" ? [] : [this.helperPath]; const args = this.helperType === "binary" ? [] : [this.helperPath];
if (this.targetMpvSocketPath) {
args.push(this.targetMpvSocketPath);
}
execFile( execFile(
command, command,

View File

@@ -27,6 +27,7 @@ interface SwayRect {
} }
interface SwayNode { interface SwayNode {
pid?: number;
app_id?: string; app_id?: string;
window_properties?: { class?: string }; window_properties?: { class?: string };
rect?: SwayRect; rect?: SwayRect;
@@ -36,6 +37,12 @@ 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;
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
}
start(): void { start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250); this.pollInterval = setInterval(() => this.pollGeometry(), 250);
@@ -49,33 +56,66 @@ export class SwayWindowTracker extends BaseWindowTracker {
} }
} }
private findMpvWindow(node: SwayNode): SwayNode | null { private collectMpvWindows(node: SwayNode): SwayNode[] {
const windows: SwayNode[] = [];
if (node.app_id === "mpv" || node.window_properties?.class === "mpv") { if (node.app_id === "mpv" || node.window_properties?.class === "mpv") {
return node; windows.push(node);
} }
if (node.nodes) { if (node.nodes) {
for (const child of node.nodes) { for (const child of node.nodes) {
const found = this.findMpvWindow(child); windows.push(...this.collectMpvWindows(child));
if (found) return found;
} }
} }
if (node.floating_nodes) { if (node.floating_nodes) {
for (const child of node.floating_nodes) { for (const child of node.floating_nodes) {
const found = this.findMpvWindow(child); windows.push(...this.collectMpvWindows(child));
if (found) return found;
} }
} }
return null; 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 { private pollGeometry(): void {
try { try {
const output = execSync("swaymsg -t get_tree", { encoding: "utf-8" }); const output = execSync("swaymsg -t get_tree", { encoding: "utf-8" });
const tree: SwayNode = JSON.parse(output); const tree: SwayNode = JSON.parse(output);
const mpvWindow = this.findMpvWindow(tree); const mpvWindow = this.findTargetSocketWindow(tree);
if (mpvWindow && mpvWindow.rect) { if (mpvWindow && mpvWindow.rect) {
this.updateGeometry({ this.updateGeometry({

View File

@@ -21,6 +21,12 @@ import { BaseWindowTracker } from "./base-tracker";
export class X11WindowTracker extends BaseWindowTracker { export class X11WindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null; private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | null;
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
}
start(): void { start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250); this.pollInterval = setInterval(() => this.pollGeometry(), 250);
@@ -45,7 +51,17 @@ export class X11WindowTracker extends BaseWindowTracker {
return; return;
} }
const windowId = windowIds.split("\n")[0]; const windowIdList = windowIds.split(/\s+/).filter(Boolean);
if (windowIdList.length === 0) {
this.updateGeometry(null);
return;
}
const windowId = this.findTargetWindowId(windowIdList);
if (!windowId) {
this.updateGeometry(null);
return;
}
const winInfo = execSync(`xwininfo -id ${windowId}`, { const winInfo = execSync(`xwininfo -id ${windowId}`, {
encoding: "utf-8", encoding: "utf-8",
@@ -70,4 +86,55 @@ export class X11WindowTracker extends BaseWindowTracker {
this.updateGeometry(null); this.updateGeometry(null);
} }
} }
private findTargetWindowId(windowIds: string[]): string | null {
if (!this.targetMpvSocketPath) {
return windowIds[0] ?? null;
}
for (const windowId of windowIds) {
if (this.isWindowForTargetSocket(windowId)) {
return windowId;
}
}
return null;
}
private isWindowForTargetSocket(windowId: string): boolean {
const pid = this.getWindowPid(windowId);
if (pid === null) {
return false;
}
const commandLine = this.getWindowCommandLine(pid);
if (!commandLine) {
return false;
}
return (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
);
}
private getWindowPid(windowId: string): number | null {
const windowPid = execSync(`xprop -id ${windowId} _NET_WM_PID`, {
encoding: "utf-8",
});
const pidMatch = windowPid.match(/= (\d+)/);
if (!pidMatch) {
return null;
}
const pid = Number.parseInt(pidMatch[1], 10);
return Number.isInteger(pid) ? pid : null;
}
private getWindowCommandLine(pid: number): string | null {
const commandLine = execSync(`ps -p ${pid} -o args=`, {
encoding: "utf-8",
}).trim();
return commandLine || null;
}
} }