Fix macOS overlay binding and subtitle alignment

This commit is contained in:
2026-02-11 18:03:57 -08:00
parent 1d36409fc7
commit ee21c77fd0
6 changed files with 382 additions and 25 deletions

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->

View File

@@ -4,7 +4,7 @@
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "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": "bash scripts/check-main-lines.sh",
"check:main-lines:baseline": "bash scripts/check-main-lines.sh 5300", "check:main-lines:baseline": "bash scripts/check-main-lines.sh 5300",
"check:main-lines:gate1": "bash scripts/check-main-lines.sh 4500", "check:main-lines:gate1": "bash scripts/check-main-lines.sh 4500",
@@ -85,7 +85,8 @@
"dist/**/*", "dist/**/*",
"vendor/texthooker-ui/docs/**/*", "vendor/texthooker-ui/docs/**/*",
"vendor/texthooker-ui/package.json", "vendor/texthooker-ui/package.json",
"package.json" "package.json",
"scripts/get-mpv-window-macos.swift"
], ],
"extraResources": [ "extraResources": [
{ {
@@ -95,6 +96,10 @@
{ {
"from": "assets", "from": "assets",
"to": "assets" "to": "assets"
},
{
"from": "dist/scripts/get-mpv-window-macos",
"to": "scripts/get-mpv-window-macos"
} }
] ]
} }

27
scripts/build-macos-helper.sh Executable file
View File

@@ -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"

View File

@@ -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")
}

View File

@@ -347,6 +347,7 @@ const shouldToggleMouseIgnore = !isLinuxPlatform;
const INVISIBLE_POSITION_EDIT_TOGGLE_CODE = "KeyP"; const INVISIBLE_POSITION_EDIT_TOGGLE_CODE = "KeyP";
const INVISIBLE_POSITION_STEP_PX = 1; const INVISIBLE_POSITION_STEP_PX = 1;
const INVISIBLE_POSITION_STEP_FAST_PX = 4; const INVISIBLE_POSITION_STEP_FAST_PX = 4;
const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 4;
let isOverSubtitle = false; let isOverSubtitle = false;
let isDragging = 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; invisibleLayoutBaseLeftPx = parseFloat(subtitleContainer.style.left) || 0;
invisibleLayoutBaseBottomPx = Number.isFinite(parseFloat(subtitleContainer.style.bottom)) invisibleLayoutBaseBottomPx = Number.isFinite(parseFloat(subtitleContainer.style.bottom))
? parseFloat(subtitleContainer.style.bottom) ? parseFloat(subtitleContainer.style.bottom)

View File

@@ -17,11 +17,132 @@
*/ */
import { execFile } from "child_process"; 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 { BaseWindowTracker } from "./base-tracker";
import { createLogger } from "../logger";
const log = createLogger("tracker").child("macos");
export class MacOSWindowTracker extends BaseWindowTracker { export class MacOSWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null; private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollInFlight = false; 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 { start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250); this.pollInterval = setInterval(() => this.pollGeometry(), 250);
@@ -36,42 +157,28 @@ export class MacOSWindowTracker extends BaseWindowTracker {
} }
private pollGeometry(): void { private pollGeometry(): void {
if (this.pollInFlight) { if (this.pollInFlight || !this.helperPath || !this.helperType) {
return; return;
} }
this.pollInFlight = true; this.pollInFlight = true;
const script = ` // Use Core Graphics API via Swift helper for reliable window detection
set processNames to {"mpv", "MPV", "org.mpv.mpv"} // This works with both bundled and unbundled mpv installations
tell application "System Events" const command = this.helperType === "binary" ? this.helperPath : "swift";
repeat with procName in processNames const args = this.helperType === "binary" ? [] : [this.helperPath];
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"
`;
execFile( execFile(
"osascript", command,
["-e", script], args,
{ {
encoding: "utf-8", encoding: "utf-8",
timeout: 1000, timeout: 1000,
maxBuffer: 1024 * 1024, maxBuffer: 1024 * 1024,
}, },
(err, stdout) => { (err, stdout, stderr) => {
if (err) { if (err) {
this.maybeLogExecError(err, stderr || "");
this.updateGeometry(null); this.updateGeometry(null);
this.pollInFlight = false; this.pollInFlight = false;
return; return;