diff --git a/scripts/get-mpv-window-macos.swift b/scripts/get-mpv-window-macos.swift index c00e00b..2353256 100644 --- a/scripts/get-mpv-window-macos.swift +++ b/scripts/get-mpv-window-macos.swift @@ -11,6 +11,7 @@ // import Cocoa +import Foundation private struct WindowGeometry { let x: Int @@ -19,6 +20,58 @@ private struct WindowGeometry { 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 { let minX = Int(floor(x)) let minY = Int(floor(y)) @@ -93,6 +146,10 @@ private func geometryFromAccessibilityAPI() -> WindowGeometry? { for app in runningApps { let appElement = AXUIElementCreateApplication(app.processIdentifier) + if !windowHasTargetSocket(app.processIdentifier) { + continue + } + var windowsRef: CFTypeRef? let status = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) guard status == .success, let windows = windowsRef as? [AXUIElement], !windows.isEmpty else { @@ -106,6 +163,15 @@ private func geometryFromAccessibilityAPI() -> WindowGeometry? { continue } + var windowPid: pid_t = 0 + if AXUIElementGetPid(window, &windowPid) != .success { + continue + } + + if !windowHasTargetSocket(windowPid) { + continue + } + if let geometry = geometryFromAXWindow(window) { return geometry } @@ -127,6 +193,14 @@ private func geometryFromCoreGraphics() -> WindowGeometry? { 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 { continue } diff --git a/src/core/services/overlay-runtime-init-service.ts b/src/core/services/overlay-runtime-init-service.ts index 33bcd14..bd0315d 100644 --- a/src/core/services/overlay-runtime-init-service.ts +++ b/src/core/services/overlay-runtime-init-service.ts @@ -23,6 +23,7 @@ export function initializeOverlayRuntimeService(options: { getOverlayWindows: () => BrowserWindow[]; syncOverlayShortcuts: () => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void; + getMpvSocketPath: () => string; getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; getSubtitleTimingTracker: () => unknown | null; getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null; @@ -42,7 +43,10 @@ export function initializeOverlayRuntimeService(options: { const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility(); options.registerGlobalShortcuts(); - const windowTracker = createWindowTracker(options.backendOverride); + const windowTracker = createWindowTracker( + options.backendOverride, + options.getMpvSocketPath(), + ); options.setWindowTracker(windowTracker); if (windowTracker) { windowTracker.onGeometryChange = (geometry: WindowGeometry) => { diff --git a/src/main.ts b/src/main.ts index 358f3ae..b3899f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,6 +46,7 @@ import { BaseWindowTracker } from "./window-trackers"; import type { JimakuApiResponse, JimakuLanguagePreference, + RuntimeOptionId, SubtitleData, SubtitlePosition, Keybinding, @@ -59,6 +60,7 @@ import type { KikuMergePreviewResponse, RuntimeOptionState, MpvSubtitleRenderMetrics, + ResolvedConfig, } from "./types"; import { SubtitleTimingTracker } from "./subtitle-timing-tracker"; import { AnkiIntegration } from "./anki-integration"; @@ -155,13 +157,27 @@ import { updateVisibleOverlayVisibilityService, } from "./core/services"; 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 { SubsyncRuntimeDeps } from "./core/services/subsync-runner-service"; import { applyRuntimeOptionResultRuntimeService, } 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 { ConfigService, DEFAULT_CONFIG, @@ -172,11 +188,6 @@ import { import type { AppLifecycleDepsRuntimeOptions, } 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") { app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal"); @@ -620,7 +631,53 @@ function resolveMediaPathForJimaku(mediaPath: string | null): string | null { : 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.mpvSocketPath = startupState.mpvSocketPath; @@ -630,12 +687,15 @@ appState.autoStartOverlay = startupState.autoStartOverlay; appState.texthookerOnlyMode = startupState.texthookerOnlyMode; function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions { - return { + return createAppLifecycleRuntimeDepsBuilder({ app, platform: process.platform, - shouldStartApp: (nextArgs) => shouldStartApp(nextArgs), - parseArgs: (argv) => parseArgs(argv), - handleCliCommand: (nextArgs, source) => handleCliCommand(nextArgs, source), + shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs), + parseArgs: (argv: string[]) => parseArgs(argv), + handleCliCommand: ( + nextArgs: CliArgs, + source: CliCommandSource, + ) => handleCliCommand(nextArgs, source), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), logNoRunningInstance: () => appLogger.logNoRunningInstance(), onReady: async () => { @@ -676,11 +736,11 @@ function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions { updateVisibleOverlayVisibility(); updateInvisibleOverlayVisibility(); }, - }; + }); } function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps { - return { + return createAppReadyRuntimeDepsBuilder({ loadSubtitlePosition: () => loadSubtitlePosition(), resolveKeybindings: () => { appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); @@ -711,13 +771,13 @@ function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps { }, ); }, - setSecondarySubMode: (mode) => { + setSecondarySubMode: (mode: SecondarySubMode) => { appState.secondarySubMode = mode; }, defaultSecondarySubMode: "hover", defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), - startSubtitleWebsocket: (port) => { + startSubtitleWebsocket: (port: number) => { subtitleWsService.start(port, () => appState.currentSubText); }, log: (message) => appLogger.logInfo(message), @@ -738,147 +798,122 @@ function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps { shouldAutoInitializeOverlayRuntimeFromConfig(), initializeOverlayRuntime: () => initializeOverlayRuntime(), 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( args: CliArgs, source: CliCommandSource = "initial", ): void { - const deps = createCliCommandDepsRuntimeService(createCliCommandRuntimeServiceDeps()); + const deps = createCliCommandDepsRuntimeService( + createCliCommandRuntimeServiceDeps({ + mpv: { + getSocketPath: () => appState.mpvSocketPath, + setSocketPath: (socketPath: string) => { + appState.mpvSocketPath = socketPath; + }, + getClient: () => appState.mpvClient, + showOsd: (text: string) => showMpvOsd(text), + }, + texthooker: { + service: texthookerService, + getPort: () => appState.texthookerPort, + setPort: (port: number) => { + appState.texthookerPort = port; + }, + shouldOpenBrowser: () => + getResolvedConfig().texthooker?.openBrowser !== false, + openInBrowser: (url: string) => { + void shell.openExternal(url).catch((error) => { + console.error(`Failed to open browser for texthooker URL: ${url}`, error); + }); + }, + }, + overlay: { + isInitialized: () => appState.overlayRuntimeInitialized, + initialize: () => initializeOverlayRuntime(), + toggleVisible: () => toggleVisibleOverlay(), + toggleInvisible: () => toggleInvisibleOverlay(), + setVisible: (visible: boolean) => setVisibleOverlayVisible(visible), + setInvisible: (visible: boolean) => setInvisibleOverlayVisible(visible), + }, + mining: { + copyCurrentSubtitle: () => copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + }, + ui: { + openYomitanSettings: () => openYomitanSettings(), + cycleSecondarySubMode: () => cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + }, + app: { + stop: () => app.quit(), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), + }, + getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, + schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), + log: (message: string) => { + console.log(message); + }, + warn: (message: string) => { + console.warn(message); + }, + error: (message: string, err: unknown) => { + console.error(message, err); + }, + }), + ); handleCliCommandService(args, source, deps); } -function createCliCommandRuntimeServiceDeps(): CliCommandDepsRuntimeOptions { - return { - mpv: { - getSocketPath: () => appState.mpvSocketPath, - setSocketPath: (socketPath) => { - appState.mpvSocketPath = socketPath; - }, - getClient: () => appState.mpvClient, - showOsd: (text) => showMpvOsd(text), - }, - texthooker: { - service: texthookerService, - getPort: () => appState.texthookerPort, - setPort: (port) => { - appState.texthookerPort = port; - }, - shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, - openInBrowser: (url) => { - void shell.openExternal(url).catch((error) => { - console.error(`Failed to open browser for texthooker URL: ${url}`, error); - }); - }, - }, - overlay: { - isInitialized: () => appState.overlayRuntimeInitialized, - initialize: () => initializeOverlayRuntime(), - toggleVisible: () => toggleVisibleOverlay(), - toggleInvisible: () => toggleInvisibleOverlay(), - setVisible: (visible) => setVisibleOverlayVisible(visible), - setInvisible: (visible) => setInvisibleOverlayVisible(visible), - }, - mining: { - copyCurrentSubtitle: () => copyCurrentSubtitle(), - startPendingMultiCopy: (timeoutMs) => startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => mineSentenceCard(), - startPendingMineSentenceMultiple: (timeoutMs) => - startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - markLastCardAsAudioCard: () => markLastCardAsAudioCard(), - }, - ui: { - openYomitanSettings: () => openYomitanSettings(), - cycleSecondarySubMode: () => cycleSecondarySubMode(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - }, - app: { - stop: () => app.quit(), - hasMainWindow: () => Boolean(overlayManager.getMainWindow()), - }, - getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, - schedule: (fn, delayMs) => setTimeout(fn, delayMs), - log: (message) => { - console.log(message); - }, - warn: (message) => { - console.warn(message); - }, - error: (message, err) => { - console.error(message, err); - }, - }; -} - function handleInitialArgs(): void { if (!appState.initialArgs) return; handleCliCommand(appState.initialArgs, "initial"); } 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 }) => { updateCurrentMediaPath(path); }); mpvClient.on("media-title-change", ({ title }) => { updateCurrentMediaTitle(title); }); - mpvClient.on("subtitle-ass-change", ({ text }) => { - appState.currentSubAssText = text; + mpvClient.on("subtitle-metrics-change", ({ patch }) => { + updateMpvSubtitleRenderMetrics(patch); }); - mpvClient.on("subtitle-change", ({ text }) => { - appState.currentSubText = text; + mpvClient.on("secondary-subtitle-visibility", ({ visible }) => { + appState.previousSecondarySubVisibility = visible; }); } @@ -886,41 +921,14 @@ function createMpvClientRuntimeService(): MpvIpcClient { const mpvClient = new MpvIpcClient(appState.mpvSocketPath, { getResolvedConfig: () => getResolvedConfig(), autoStartOverlay: appState.autoStartOverlay, - setOverlayVisible: (visible) => setOverlayVisible(visible), + setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(), isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getReconnectTimer: () => appState.reconnectTimer, - setReconnectTimer: (timer) => { + setReconnectTimer: (timer: ReturnType | null) => { 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); return mpvClient; @@ -1085,6 +1093,7 @@ function initializeOverlayRuntime(): void { getResolvedConfig: () => getResolvedConfig(), getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getMpvClient: () => appState.mpvClient, + getMpvSocketPath: () => appState.mpvSocketPath, getRuntimeOptionsManager: () => appState.runtimeOptionsManager, setAnkiIntegration: (integration) => { appState.ankiIntegration = integration as AnkiIntegration | null; @@ -1126,7 +1135,7 @@ function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolve function getOverlayShortcutRuntimeHandlers() { return createOverlayShortcutRuntimeHandlers( { - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text: string) => showMpvOsd(text), openRuntimeOptions: () => { openRuntimeOptionsPalette(); }, @@ -1136,7 +1145,7 @@ function getOverlayShortcutRuntimeHandlers() { }); }, markAudioCard: () => markLastCardAsAudioCard(), - copySubtitleMultiple: (timeoutMs) => { + copySubtitleMultiple: (timeoutMs: number) => { startPendingMultiCopy(timeoutMs); }, copySubtitle: () => { @@ -1147,7 +1156,7 @@ function getOverlayShortcutRuntimeHandlers() { triggerFieldGrouping: () => triggerFieldGrouping(), triggerSubsync: () => triggerSubsyncFromConfig(), mineSentence: () => mineSentenceCard(), - mineSentenceMultiple: (timeoutMs) => { + mineSentenceMultiple: (timeoutMs: number) => { startPendingMineSentenceMultiple(timeoutMs); }, }, @@ -1495,31 +1504,30 @@ function handleOverlayModalClosed(modal: OverlayHostedModal): void { } function handleMpvCommandFromIpc(command: (string | number)[]): void { - handleMpvCommandFromIpcService(command, createMpvCommandRuntimeServiceDeps()); -} - -function createMpvCommandRuntimeServiceDeps(): HandleMpvCommandFromIpcOptions { - return { - specialCommands: SPECIAL_COMMANDS, - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - runtimeOptionsCycle: (id, direction) => { - if (!appState.runtimeOptionsManager) { - return { ok: false, error: "Runtime options manager unavailable" }; - } - return applyRuntimeOptionResultRuntimeService( - appState.runtimeOptionsManager.cycleOption(id, direction), - (text) => showMpvOsd(text), - ); - }, - showMpvOsd: (text) => showMpvOsd(text), - mpvReplaySubtitle: () => replayCurrentSubtitleRuntimeService(appState.mpvClient), - mpvPlayNextSubtitle: () => playNextSubtitleRuntimeService(appState.mpvClient), - mpvSendCommand: (rawCommand) => - sendMpvCommandRuntimeService(appState.mpvClient, rawCommand), - isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), - hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, - }; + handleMpvCommandFromIpcService( + command, + createMpvCommandRuntimeServiceDeps({ + specialCommands: SPECIAL_COMMANDS, + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => { + if (!appState.runtimeOptionsManager) { + return { ok: false, error: "Runtime options manager unavailable" }; + } + return applyRuntimeOptionResultRuntimeService( + appState.runtimeOptionsManager.cycleOption(id, direction), + (text) => showMpvOsd(text), + ); + }, + showMpvOsd: (text: string) => showMpvOsd(text), + mpvReplaySubtitle: () => replayCurrentSubtitleRuntimeService(appState.mpvClient), + mpvPlayNextSubtitle: () => playNextSubtitleRuntimeService(appState.mpvClient), + mpvSendCommand: (rawCommand: (string | number)[]) => + sendMpvCommandRuntimeService(appState.mpvClient, rawCommand), + isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), + hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, + }), + ); } async function runSubsyncManualFromIpc( @@ -1534,52 +1542,47 @@ const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({ }); registerIpcHandlersService( - createIpcDepsRuntimeService(createMainIpcRuntimeServiceDeps()), + createIpcDepsRuntimeService( + createMainIpcRuntimeServiceDeps({ + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(), + onOverlayModalClosed: (modal) => { + handleOverlayModalClosed(modal as OverlayHostedModal); + }, + openYomitanSettings: () => openYomitanSettings(), + quitApp: () => app.quit(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), + getCurrentSubtitleAss: () => appState.currentSubAssText, + getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics, + getSubtitlePosition: () => loadSubtitlePosition(), + getSubtitleStyle: () => getResolvedConfig().subtitleStyle ?? null, + saveSubtitlePosition: (position: unknown) => + saveSubtitlePosition(position as SubtitlePosition), + getMecabTokenizer: () => appState.mecabTokenizer, + handleMpvCommand: (command: (string | number)[]) => + handleMpvCommandFromIpc(command), + getKeybindings: () => appState.keybindings, + getSecondarySubMode: () => appState.secondarySubMode, + getMpvClient: () => appState.mpvClient, + runSubsyncManual: (request: unknown) => + runSubsyncManualFromIpc(request as SubsyncManualRunRequest), + getAnkiConnectStatus: () => appState.ankiIntegration !== null, + getRuntimeOptions: () => getRuntimeOptionsState(), + setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, + cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, + reportOverlayContentBounds: (payload: unknown) => { + overlayContentMeasurementStore.report(payload); + }, + }), + ), ); registerAnkiJimakuIpcRuntimeService( - createAnkiJimakuIpcRuntimeServiceDeps(), -); - -function createMainIpcRuntimeServiceDeps(): IpcDepsRuntimeOptions { - return { - getInvisibleWindow: () => overlayManager.getInvisibleWindow(), - getMainWindow: () => overlayManager.getMainWindow(), - getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), - getInvisibleOverlayVisibility: () => - overlayManager.getInvisibleOverlayVisible(), - onOverlayModalClosed: (modal) => - handleOverlayModalClosed(modal as OverlayHostedModal), - openYomitanSettings: () => openYomitanSettings(), - quitApp: () => app.quit(), - toggleVisibleOverlay: () => toggleVisibleOverlay(), - tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), - getCurrentSubtitleAss: () => appState.currentSubAssText, - getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics, - getSubtitlePosition: () => loadSubtitlePosition(), - getSubtitleStyle: () => getResolvedConfig().subtitleStyle ?? null, - saveSubtitlePosition: (position) => - saveSubtitlePosition(position as SubtitlePosition), - getMecabTokenizer: () => appState.mecabTokenizer, - handleMpvCommand: (command) => handleMpvCommandFromIpc(command), - getKeybindings: () => appState.keybindings, - getSecondarySubMode: () => appState.secondarySubMode, - getMpvClient: () => appState.mpvClient, - runSubsyncManual: (request) => - runSubsyncManualFromIpc(request as SubsyncManualRunRequest), - getAnkiConnectStatus: () => appState.ankiIntegration !== null, - getRuntimeOptions: () => getRuntimeOptionsState(), - setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, - cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, - reportOverlayContentBounds: (payload) => { - overlayContentMeasurementStore.report(payload); - }, - }; -} - -function createAnkiJimakuIpcRuntimeServiceDeps(): AnkiJimakuIpcRuntimeOptions { - return { - patchAnkiConnectEnabled: (enabled) => { + createAnkiJimakuIpcRuntimeServiceDeps({ + patchAnkiConnectEnabled: (enabled: boolean) => { configService.patchRawConfig({ ankiConnect: { enabled } }); }, getResolvedConfig: () => getResolvedConfig(), @@ -1587,25 +1590,32 @@ function createAnkiJimakuIpcRuntimeServiceDeps(): AnkiJimakuIpcRuntimeOptions { getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getMpvClient: () => appState.mpvClient, getAnkiIntegration: () => appState.ankiIntegration, - setAnkiIntegration: (integration) => { + setAnkiIntegration: (integration: AnkiIntegration | null) => { appState.ankiIntegration = integration; }, showDesktopNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), getFieldGroupingResolver: () => getFieldGroupingResolver(), - setFieldGroupingResolver: (resolver) => setFieldGroupingResolver(resolver), - parseMediaInfo: (mediaPath) => parseMediaInfo(resolveMediaPathForJimaku(mediaPath)), + setFieldGroupingResolver: ( + resolver: ((choice: KikuFieldGroupingChoice) => void) | null, + ) => setFieldGroupingResolver(resolver), + parseMediaInfo: (mediaPath: string | null) => + parseMediaInfo(resolveMediaPathForJimaku(mediaPath)), getCurrentMediaPath: () => appState.currentMediaPath, jimakuFetchJson: ( endpoint: string, query?: Record, - ): Promise> => jimakuFetchJson(endpoint, query), + ): Promise> => + jimakuFetchJson(endpoint, query), getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(), getJimakuLanguagePreference: () => getJimakuLanguagePreference(), resolveJimakuApiKey: () => resolveJimakuApiKey(), - isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), - downloadToFile: (url, destPath, headers) => - downloadToFile(url, destPath, headers), - }; -} + isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath), + downloadToFile: ( + url: string, + destPath: string, + headers: Record, + ) => downloadToFile(url, destPath, headers), + }), +); diff --git a/src/window-trackers/hyprland-tracker.ts b/src/window-trackers/hyprland-tracker.ts index 591201c..d0fc208 100644 --- a/src/window-trackers/hyprland-tracker.ts +++ b/src/window-trackers/hyprland-tracker.ts @@ -27,11 +27,18 @@ interface HyprlandClient { class: string; at: [number, number]; size: [number, number]; + pid?: number; } export class HyprlandWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | 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); @@ -95,7 +102,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker { try { const output = execSync("hyprctl clients -j", { encoding: "utf-8" }); const clients: HyprlandClient[] = JSON.parse(output); - const mpvWindow = clients.find((c) => c.class === "mpv"); + const mpvWindow = this.findTargetWindow(clients); if (mpvWindow) { this.updateGeometry({ @@ -111,4 +118,38 @@ export class HyprlandWindowTracker extends BaseWindowTracker { // 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; + } } diff --git a/src/window-trackers/index.ts b/src/window-trackers/index.ts index 9d8e10f..a89cc60 100644 --- a/src/window-trackers/index.ts +++ b/src/window-trackers/index.ts @@ -47,6 +47,7 @@ function normalizeCompositor(value: string): Compositor | null { export function createWindowTracker( override?: string | null, + targetMpvSocketPath?: string | null, ): BaseWindowTracker | null { let compositor = detectCompositor(); @@ -64,13 +65,21 @@ export function createWindowTracker( switch (compositor) { case "hyprland": - return new HyprlandWindowTracker(); + return new HyprlandWindowTracker( + targetMpvSocketPath?.trim() || undefined, + ); case "sway": - return new SwayWindowTracker(); + return new SwayWindowTracker( + targetMpvSocketPath?.trim() || undefined, + ); case "x11": - return new X11WindowTracker(); + return new X11WindowTracker( + targetMpvSocketPath?.trim() || undefined, + ); case "macos": - return new MacOSWindowTracker(); + return new MacOSWindowTracker( + targetMpvSocketPath?.trim() || undefined, + ); default: log.warn("No supported compositor detected. Window tracking disabled."); return null; diff --git a/src/window-trackers/macos-tracker.ts b/src/window-trackers/macos-tracker.ts index 6533189..b0ecf48 100644 --- a/src/window-trackers/macos-tracker.ts +++ b/src/window-trackers/macos-tracker.ts @@ -32,9 +32,11 @@ export class MacOSWindowTracker extends BaseWindowTracker { private helperType: "binary" | "swift" | null = null; private lastExecErrorFingerprint: string | null = null; private lastExecErrorLoggedAtMs = 0; + private readonly targetMpvSocketPath: string | null; - constructor() { + constructor(targetMpvSocketPath?: string) { super(); + this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; this.detectHelper(); } @@ -85,6 +87,21 @@ export class MacOSWindowTracker extends BaseWindowTracker { } 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) { @@ -110,14 +127,8 @@ export class MacOSWindowTracker extends BaseWindowTracker { return; } - // Fall back to Swift script for development. - const swiftPath = path.join( - __dirname, - "..", - "..", - "scripts", - "get-mpv-window-macos.swift", - ); + // Fall back to Swift script for development or if binary filtering is not + // supported in the current environment. if (this.tryUseHelper(swiftPath, "swift")) { return; } @@ -167,6 +178,9 @@ export class MacOSWindowTracker extends BaseWindowTracker { // 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, diff --git a/src/window-trackers/sway-tracker.ts b/src/window-trackers/sway-tracker.ts index 519b9e9..eecc560 100644 --- a/src/window-trackers/sway-tracker.ts +++ b/src/window-trackers/sway-tracker.ts @@ -27,6 +27,7 @@ interface SwayRect { } interface SwayNode { + pid?: number; app_id?: string; window_properties?: { class?: string }; rect?: SwayRect; @@ -36,6 +37,12 @@ interface SwayNode { export class SwayWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; + private readonly targetMpvSocketPath: string | null; + + constructor(targetMpvSocketPath?: string) { + super(); + this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + } start(): void { 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") { - return node; + windows.push(node); } if (node.nodes) { for (const child of node.nodes) { - const found = this.findMpvWindow(child); - if (found) return found; + windows.push(...this.collectMpvWindows(child)); } } if (node.floating_nodes) { for (const child of node.floating_nodes) { - const found = this.findMpvWindow(child); - if (found) return found; + windows.push(...this.collectMpvWindows(child)); } } - 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 { try { const output = execSync("swaymsg -t get_tree", { encoding: "utf-8" }); const tree: SwayNode = JSON.parse(output); - const mpvWindow = this.findMpvWindow(tree); + const mpvWindow = this.findTargetSocketWindow(tree); if (mpvWindow && mpvWindow.rect) { this.updateGeometry({ diff --git a/src/window-trackers/x11-tracker.ts b/src/window-trackers/x11-tracker.ts index 5375cea..882badf 100644 --- a/src/window-trackers/x11-tracker.ts +++ b/src/window-trackers/x11-tracker.ts @@ -21,6 +21,12 @@ import { BaseWindowTracker } from "./base-tracker"; export class X11WindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; + private readonly targetMpvSocketPath: string | null; + + constructor(targetMpvSocketPath?: string) { + super(); + this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + } start(): void { this.pollInterval = setInterval(() => this.pollGeometry(), 250); @@ -45,7 +51,17 @@ export class X11WindowTracker extends BaseWindowTracker { 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}`, { encoding: "utf-8", @@ -70,4 +86,55 @@ export class X11WindowTracker extends BaseWindowTracker { 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; + } }