mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Fix macOS overlay binding and subtitle alignment
This commit is contained in:
@@ -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 -->
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
27
scripts/build-macos-helper.sh
Executable file
27
scripts/build-macos-helper.sh
Executable 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"
|
||||
165
scripts/get-mpv-window-macos.swift
Normal file
165
scripts/get-mpv-window-macos.swift
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof setInterval> | null = null;
|
||||
private pollInFlight = false;
|
||||
private helperPath: string | null = null;
|
||||
private helperType: "binary" | "swift" | null = null;
|
||||
private lastExecErrorFingerprint: string | null = null;
|
||||
private lastExecErrorLoggedAtMs = 0;
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user