From 44b0e3604e4d29460bd3d2946dd646f0cdd92eff Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 11 Feb 2026 18:03:57 -0800 Subject: [PATCH] Fix macOS overlay binding and subtitle alignment --- ...ative-window-bounds-for-overlay-binding.md | 44 +++++ package.json | 9 +- scripts/build-macos-helper.sh | 27 +++ scripts/get-mpv-window-macos.swift | 165 ++++++++++++++++++ src/renderer/renderer.ts | 9 + src/window-trackers/macos-tracker.ts | 153 +++++++++++++--- 6 files changed, 382 insertions(+), 25 deletions(-) create mode 100644 backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md create mode 100755 scripts/build-macos-helper.sh create mode 100644 scripts/get-mpv-window-macos.swift diff --git a/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md b/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md new file mode 100644 index 0000000..f59524f --- /dev/null +++ b/backlog/tasks/task-13 - Fix-macos-native-window-bounds-for-overlay-binding.md @@ -0,0 +1,44 @@ +--- +id: TASK-13 +title: Fix macOS native window bounds for overlay binding +status: Done +assignee: + - codex +created_date: '2026-02-11 15:45' +updated_date: '2026-02-11 16:20' +labels: + - bug + - macos + - overlay +dependencies: [] +references: + - src/window-trackers/macos-tracker.ts + - scripts/get-mpv-window-macos.swift +priority: high +--- + +## Description + + +Overlay windows on macOS are not properly aligned to the mpv window after switching from AppleScript window discovery to native Swift/CoreGraphics bounds retrieval. + +Implement a robust native bounds strategy that prefers Accessibility window geometry (matching app-window coordinates used previously) and falls back to filtered CoreGraphics windows when Accessibility data is unavailable. + + +## Acceptance Criteria + +- [x] #1 Overlay bounds track the active mpv window with correct position and size on macOS. +- [x] #2 Helper avoids selecting off-screen/non-primary mpv-related windows. +- [x] #3 Build succeeds with the updated macOS helper. + + +## Implementation Notes + + +Follow-up in progress after packaged app runtime showed fullscreen fallback behavior: +- Added packaged-app helper path resolution in tracker (`process.resourcesPath/scripts/get-mpv-window-macos`). +- Added `.asar` helper materialization to temp path so child process execution is possible if candidate path resolves inside asar. +- Added throttled tracker logging for helper execution failures to expose runtime errors without log spam. +- Updated Electron builder `extraResources` to ship `dist/scripts/get-mpv-window-macos` outside asar at `resources/scripts/get-mpv-window-macos`. +- Added macOS-only invisible subtitle vertical nudge (`+4px`) in renderer layout to align interactive subtitles with mpv glyph baseline after bounds fix. + diff --git a/package.json b/package.json index dee4887..08a318a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "main": "dist/main.js", "scripts": { - "build": "tsc && cp src/renderer/index.html src/renderer/style.css dist/renderer/", + "build": "tsc && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && bash scripts/build-macos-helper.sh", "check:main-lines": "bash scripts/check-main-lines.sh", "check:main-lines:baseline": "bash scripts/check-main-lines.sh 5300", "check:main-lines:gate1": "bash scripts/check-main-lines.sh 4500", @@ -85,7 +85,8 @@ "dist/**/*", "vendor/texthooker-ui/docs/**/*", "vendor/texthooker-ui/package.json", - "package.json" + "package.json", + "scripts/get-mpv-window-macos.swift" ], "extraResources": [ { @@ -95,6 +96,10 @@ { "from": "assets", "to": "assets" + }, + { + "from": "dist/scripts/get-mpv-window-macos", + "to": "scripts/get-mpv-window-macos" } ] } diff --git a/scripts/build-macos-helper.sh b/scripts/build-macos-helper.sh new file mode 100755 index 0000000..df4c786 --- /dev/null +++ b/scripts/build-macos-helper.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Build macOS window tracking helper binary + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SWIFT_SOURCE="$SCRIPT_DIR/get-mpv-window-macos.swift" +OUTPUT_DIR="$SCRIPT_DIR/../dist/scripts" +OUTPUT_BINARY="$OUTPUT_DIR/get-mpv-window-macos" + +# Only build on macOS +if [[ "$(uname)" != "Darwin" ]]; then + echo "Skipping macOS helper build (not on macOS)" + exit 0 +fi + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Compile Swift script to binary +echo "Compiling macOS window tracking helper..." +swiftc -O "$SWIFT_SOURCE" -o "$OUTPUT_BINARY" + +# Make executable +chmod +x "$OUTPUT_BINARY" + +echo "✓ Built $OUTPUT_BINARY" diff --git a/scripts/get-mpv-window-macos.swift b/scripts/get-mpv-window-macos.swift new file mode 100644 index 0000000..c00e00b --- /dev/null +++ b/scripts/get-mpv-window-macos.swift @@ -0,0 +1,165 @@ +#!/usr/bin/env swift +// +// get-mpv-window-macos.swift +// SubMiner - Get mpv window geometry on macOS +// +// This script uses Core Graphics APIs to find mpv windows system-wide. +// It works with both bundled and unbundled mpv installations. +// +// Usage: swift get-mpv-window-macos.swift +// Output: "x,y,width,height" or "not-found" +// + +import Cocoa + +private struct WindowGeometry { + let x: Int + let y: Int + let width: Int + let height: Int +} + +private func geometryFromRect(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> WindowGeometry { + let minX = Int(floor(x)) + let minY = Int(floor(y)) + let maxX = Int(ceil(x + width)) + let maxY = Int(ceil(y + height)) + return WindowGeometry( + x: minX, + y: minY, + width: max(0, maxX - minX), + height: max(0, maxY - minY) + ) +} + +private func normalizedMpvName(_ name: String) -> Bool { + let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "mpv" +} + +private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? { + var positionRef: CFTypeRef? + var sizeRef: CFTypeRef? + + let positionStatus = AXUIElementCopyAttributeValue(axWindow, kAXPositionAttribute as CFString, &positionRef) + let sizeStatus = AXUIElementCopyAttributeValue(axWindow, kAXSizeAttribute as CFString, &sizeRef) + + guard positionStatus == .success, + sizeStatus == .success, + let positionRaw = positionRef, + let sizeRaw = sizeRef, + CFGetTypeID(positionRaw) == AXValueGetTypeID(), + CFGetTypeID(sizeRaw) == AXValueGetTypeID() else { + return nil + } + + let positionValue = positionRaw as! AXValue + let sizeValue = sizeRaw as! AXValue + + guard AXValueGetType(positionValue) == .cgPoint, + AXValueGetType(sizeValue) == .cgSize else { + return nil + } + + var position = CGPoint.zero + var size = CGSize.zero + + guard AXValueGetValue(positionValue, .cgPoint, &position), + AXValueGetValue(sizeValue, .cgSize, &size) else { + return nil + } + + let geometry = geometryFromRect( + x: position.x, + y: position.y, + width: size.width, + height: size.height + ) + + guard geometry.width >= 100, geometry.height >= 100 else { + return nil + } + + return geometry +} + +private func geometryFromAccessibilityAPI() -> WindowGeometry? { + let runningApps = NSWorkspace.shared.runningApplications.filter { app in + guard let name = app.localizedName else { + return false + } + return normalizedMpvName(name) + } + + for app in runningApps { + let appElement = AXUIElementCreateApplication(app.processIdentifier) + var windowsRef: CFTypeRef? + let status = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) + guard status == .success, let windows = windowsRef as? [AXUIElement], !windows.isEmpty else { + continue + } + + for window in windows { + var minimizedRef: CFTypeRef? + let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef) + if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized { + continue + } + + if let geometry = geometryFromAXWindow(window) { + return geometry + } + } + } + + return nil +} + +private func geometryFromCoreGraphics() -> WindowGeometry? { + // Keep the CG fallback for environments without Accessibility permissions. + // Use on-screen layer-0 windows to avoid off-screen helpers/shadows. + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? [] + + for window in windowList { + guard let ownerName = window[kCGWindowOwnerName as String] as? String, + normalizedMpvName(ownerName) else { + continue + } + + if let layer = window[kCGWindowLayer as String] as? Int, layer != 0 { + continue + } + if let alpha = window[kCGWindowAlpha as String] as? Double, alpha <= 0.01 { + continue + } + if let onScreen = window[kCGWindowIsOnscreen as String] as? Int, onScreen == 0 { + continue + } + + guard let bounds = window[kCGWindowBounds as String] as? [String: CGFloat] else { + continue + } + + let geometry = geometryFromRect( + x: bounds["X"] ?? 0, + y: bounds["Y"] ?? 0, + width: bounds["Width"] ?? 0, + height: bounds["Height"] ?? 0 + ) + + guard geometry.width >= 100, geometry.height >= 100 else { + continue + } + + return geometry + } + + return nil +} + +if let window = geometryFromAccessibilityAPI() ?? geometryFromCoreGraphics() { + print("\(window.x),\(window.y),\(window.width),\(window.height)") +} else { + print("not-found") +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index aba04fe..f902663 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -347,6 +347,7 @@ const shouldToggleMouseIgnore = !isLinuxPlatform; const INVISIBLE_POSITION_EDIT_TOGGLE_CODE = "KeyP"; const INVISIBLE_POSITION_STEP_PX = 1; const INVISIBLE_POSITION_STEP_FAST_PX = 4; +const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 4; let isOverSubtitle = false; let isDragging = false; @@ -1062,6 +1063,14 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( } } } + + if (isMacOSPlatform && vAlign === 0) { + const currentBottom = parseFloat(subtitleContainer.style.bottom); + if (Number.isFinite(currentBottom)) { + subtitleContainer.style.bottom = `${Math.max(0, currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX)}px`; + } + } + invisibleLayoutBaseLeftPx = parseFloat(subtitleContainer.style.left) || 0; invisibleLayoutBaseBottomPx = Number.isFinite(parseFloat(subtitleContainer.style.bottom)) ? parseFloat(subtitleContainer.style.bottom) diff --git a/src/window-trackers/macos-tracker.ts b/src/window-trackers/macos-tracker.ts index dfbf6aa..6533189 100644 --- a/src/window-trackers/macos-tracker.ts +++ b/src/window-trackers/macos-tracker.ts @@ -17,11 +17,132 @@ */ 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 | null = null; private pollInFlight = false; + private helperPath: string | null = null; + private helperType: "binary" | "swift" | null = null; + private lastExecErrorFingerprint: string | null = null; + private lastExecErrorLoggedAtMs = 0; + + constructor() { + super(); + 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 { + // 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. + const swiftPath = path.join( + __dirname, + "..", + "..", + "scripts", + "get-mpv-window-macos.swift", + ); + 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); @@ -36,42 +157,28 @@ export class MacOSWindowTracker extends BaseWindowTracker { } private pollGeometry(): void { - if (this.pollInFlight) { + if (this.pollInFlight || !this.helperPath || !this.helperType) { return; } this.pollInFlight = true; - const script = ` - set processNames to {"mpv", "MPV", "org.mpv.mpv"} - tell application "System Events" - repeat with procName in processNames - set procList to (every process whose name is procName) - repeat with p in procList - try - if (count of windows of p) > 0 then - set targetWindow to window 1 of p - set windowPos to position of targetWindow - set windowSize to size of targetWindow - return (item 1 of windowPos) & "," & (item 2 of windowPos) & "," & (item 1 of windowSize) & "," & (item 2 of windowSize) - end if - end try - end repeat - end repeat - end tell - return "not-found" - `; + // 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]; execFile( - "osascript", - ["-e", script], + command, + args, { encoding: "utf-8", timeout: 1000, maxBuffer: 1024 * 1024, }, - (err, stdout) => { + (err, stdout, stderr) => { if (err) { + this.maybeLogExecError(err, stderr || ""); this.updateGeometry(null); this.pollInFlight = false; return;