fix(macos): hide overlay when mpv loses foreground and open stats inacti

- Add "active" Swift helper output when mpv is frontmost but geometry is temporarily unavailable, preserving overlay through transient tracker misses
- Use showInactive for overlay and stats window on macOS to avoid switching Spaces over fullscreen mpv
- Disable autoInstallOnAppQuit to prevent premature Squirrel install before user confirms restart
This commit is contained in:
2026-05-16 15:36:44 -07:00
parent 89723e2ccb
commit 3a7d650a70
18 changed files with 473 additions and 95 deletions
+8
View File
@@ -0,0 +1,8 @@
type: fixed
area: overlay
- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles.
- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal.
- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop.
- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground.
- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed type: fixed
area: updates area: updates
- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app. - Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart.
-36
View File
@@ -1,36 +0,0 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
## Highlights
### Added
**Auto-Update:** The tray and `subminer -u` now check for SubMiner releases and prompt you to install updates, covering the app itself, the command-line launcher, and Linux rofi themes. Update notifications are configurable, downloads are checksum-verified, and an opt-in prerelease channel lets you receive beta and RC builds.
**First-Run Setup:** The app can now install Bun and the `subminer` command-line launcher during first run on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim lets you type `subminer` in any terminal without manually adding `SubMiner.exe` to PATH.
### Fixed
**macOS Overlay:** Controls on the mpv window are now clickable before you hover the subtitle bars. Transient mpv window focus misses no longer hide the overlay; minimizing mpv still hides it as expected.
**Subtitle Sync:** The subtitle sync modal on macOS now opens reliably: no more flash-and-hide on the first attempt or stale modal state left after syncing.
**Updater:** Update checks on Linux now use GitHub release metadata instead of the native Electron updater, preventing tray app crashes. macOS builds that cannot auto-install updates show a manual install prompt instead of a non-functional restart prompt. macOS update dialogs are brought to the front when `subminer --update` is run from the launcher.
**Linux Command-Line Updater:** `subminer -u` now performs release updates directly from the launcher without requiring the tray app, and reports "up to date" without downloading assets when already on the latest version.
**Launcher Setup:** Linux first-run launcher installs now build with a valid Bun shebang. `subminer app --setup` correctly opens the setup flow even when SubMiner is already running in the background. On macOS, first-run setup recognizes existing launchers in Homebrew or user PATH directories, and manual installs avoid Homebrew-managed locations. The setup window now quits automatically after first-run setup completes, returning control to the terminal.
**Tray App:** Fixed several issues with tray-launched Yomitan settings: closing the window no longer quits the tray app, a close-only menu replaces the default native menu, and settings loading no longer blocks other tray actions. An in-page close button is now available on Hyprland, where native window controls may be absent. Disabled the embedded popup preview in the settings window to prevent renderer hangs. Fixed a startup race condition that could leave extension loading in an error state, and corrected focus handling for the session help modal when mpv is not running.
**Build:** One-shot `make clean build install` flows now correctly pick up the AppImage built in the same invocation.
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+45 -7
View File
@@ -7,7 +7,7 @@
// It works with both bundled and unbundled mpv installations. // It works with both bundled and unbundled mpv installations.
// //
// Usage: swift get-mpv-window-macos.swift // Usage: swift get-mpv-window-macos.swift
// Output: "x,y,width,height" or "not-found" // Output: "x,y,width,height,focused", "minimized", "active", or "not-found"
// //
import Cocoa import Cocoa
@@ -25,9 +25,15 @@ private struct WindowState {
let focused: Bool let focused: Bool
} }
private struct FrontmostApplicationState {
let pid: pid_t
let isMpv: Bool
}
private enum WindowLookupResult { private enum WindowLookupResult {
case visible(WindowState) case visible(WindowState)
case minimized case minimized
case active
} }
private let targetMpvSocketPath: String? = { private let targetMpvSocketPath: String? = {
@@ -146,8 +152,35 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
return geometry return geometry
} }
private func frontmostApplicationPid() -> pid_t? { private func frontmostApplicationState() -> FrontmostApplicationState? {
NSWorkspace.shared.frontmostApplication?.processIdentifier guard let app = NSWorkspace.shared.frontmostApplication else {
return nil
}
return FrontmostApplicationState(
pid: app.processIdentifier,
isMpv: app.localizedName.map(normalizedMpvName) ?? false
)
}
private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost else {
return false
}
if frontmost.pid == ownerPid {
return true
}
return frontmost.isMpv && windowHasTargetSocket(ownerPid)
}
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost else {
return false
}
return frontmost.isMpv && windowHasTargetSocket(frontmost.pid)
} }
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? { private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
@@ -158,7 +191,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
return normalizedMpvName(name) return normalizedMpvName(name)
} }
let frontmostPid = frontmostApplicationPid() let frontmost = frontmostApplicationState()
var foundMinimizedTargetWindow = false var foundMinimizedTargetWindow = false
for app in runningApps { for app in runningApps {
@@ -198,7 +231,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
return .visible( return .visible(
WindowState( WindowState(
geometry: geometry, geometry: geometry,
focused: frontmostPid == windowPid focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)
) )
) )
} }
@@ -217,7 +250,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows. // Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? [] let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
let frontmostPid = frontmostApplicationPid() let frontmost = frontmostApplicationState()
for window in windowList { for window in windowList {
guard let ownerName = window[kCGWindowOwnerName as String] as? String, guard let ownerName = window[kCGWindowOwnerName as String] as? String,
@@ -260,7 +293,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
return WindowState( return WindowState(
geometry: geometry, geometry: geometry,
focused: frontmostPid == ownerPid focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
) )
} }
@@ -274,6 +307,9 @@ private let lookupResult: WindowLookupResult? = {
if let cgWindow = windowStateFromCoreGraphics() { if let cgWindow = windowStateFromCoreGraphics() {
return .visible(cgWindow) return .visible(cgWindow)
} }
if isFrontmostTargetMpv(frontmostApplicationState()) {
return .active
}
return nil return nil
}() }()
@@ -285,6 +321,8 @@ if let result = lookupResult {
) )
case .minimized: case .minimized:
print("minimized") print("minimized")
case .active:
print("active")
} }
} else { } else {
print("not-found") print("not-found")
+41
View File
@@ -31,3 +31,44 @@ test('minimized Accessibility windows are validated by PID and socket before rep
'target socket must be validated before accepting a minimized window', 'target socket must be validated before accepting a minimized window',
); );
}); });
test('focused mpv window follows the frontmost mpv app signal', () => {
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
assert.notEqual(focusHelperIndex, -1);
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
assert.ok(
focusHelperBody.includes('frontmost.pid == ownerPid'),
'matching frontmost PID should mark the mpv window focused',
);
assert.ok(
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
'Accessibility path should use the shared focused mpv helper',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
'CoreGraphics path should use the shared focused mpv helper',
);
});
test('frontmost mpv app emits active state when geometry lookup misses', () => {
assert.ok(
source.includes('case active'),
'helper should expose an active state without window geometry',
);
assert.ok(
source.includes('frontmost.isMpv && windowHasTargetSocket(frontmost.pid)'),
'active state should be limited to the frontmost target mpv process',
);
assert.ok(
source.includes('return .active'),
'lookup should preserve active mpv state after geometry lookup misses',
);
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
});
+122 -11
View File
@@ -42,6 +42,11 @@ function createMainWindowRecorder() {
setAlwaysOnTop: (flag: boolean) => { setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`); calls.push(`always-on-top:${flag}`);
}, },
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`); calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
}, },
@@ -538,11 +543,12 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo
assert.ok(calls.includes('sync-windows-z-order')); assert.ok(calls.includes('sync-windows-z-order'));
}); });
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => { test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
isTracking: () => true, isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
}; };
updateVisibleOverlayVisibility({ updateVisibleOverlayVisibility({
@@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority
forceMousePassthrough: true, forceMousePassthrough: true,
} as never); } as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('always-on-top:false'));
});
test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false')); assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('ensure-level')); assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order')); assert.ok(!calls.includes('enforce-order'));
}); });
@@ -916,7 +967,8 @@ test('macOS tracked visible overlay starts click-through without passively steal
} as never); } as never);
assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show')); assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('focus'));
}); });
@@ -1009,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re
assert.deepEqual(osdMessages, []); assert.deepEqual(osdMessages, []);
}); });
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => { test('macOS tracked overlay hides when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
isTracking: () => true, isTracking: () => true,
@@ -1017,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
isTargetWindowFocused: () => false, isTargetWindowFocused: () => false,
}; };
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({ updateVisibleOverlayVisibility({
visibleOverlayVisible: true, visibleOverlayVisible: true,
mainWindow: window as never, mainWindow: window as never,
@@ -1046,12 +1101,66 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
assert.ok(calls.includes('sync-layer')); assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false')); assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('show')); assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts')); assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level')); assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order')); assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('hide')); assert.ok(!calls.includes('show'));
});
test('macOS tracked overlay passively reappears when mpv regains foreground', () => {
const { window, calls } = createMainWindowRecorder();
let targetFocused = false;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => targetFocused,
};
window.show();
calls.length = 0;
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
run();
assert.ok(calls.includes('hide'));
calls.length = 0;
targetFocused = true;
run();
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
}); });
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => { test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
@@ -1141,7 +1250,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
} as never); } as never);
assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show')); assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('focus'));
}); });
@@ -1438,7 +1548,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
}); });
test('macOS preserves visible overlay level during non-minimized tracker loss', () => { test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
isTracking: () => false, isTracking: () => false,
@@ -1479,11 +1589,12 @@ test('macOS preserves visible overlay level during non-minimized tracker loss',
assert.ok(calls.includes('sync-layer')); assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level')); assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('enforce-order')); assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts')); assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('hide')); assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('always-on-top:false')); assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('loading-osd')); assert.ok(!calls.includes('loading-osd'));
}); });
+43 -13
View File
@@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
opacityCapableWindow.setOpacity?.(opacity); opacityCapableWindow.setOpacity?.(opacity);
} }
function releaseOverlayWindowLevel(window: BrowserWindow): void {
window.setAlwaysOnTop(false);
const allWorkspacesWindow = window as BrowserWindow & {
setVisibleOnAllWorkspaces?: (
visible: boolean,
options?: { visibleOnFullScreen?: boolean },
) => void;
};
allWorkspacesWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
}
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void { function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window); const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
if (!pendingTimeout) { if (!pendingTimeout) {
@@ -99,17 +110,19 @@ export function updateVisibleOverlayVisibility(args: {
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function'; args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized = const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true; canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
const hasTransientMacOSTrackerLoss = const hasTransientMacOSTrackerLoss =
args.isMacOSPlatform && args.isMacOSPlatform &&
canReportMacOSTargetMinimized && canReportMacOSTargetMinimized &&
!!windowTracker && !!windowTracker &&
!windowTracker.isTracking() && !windowTracker.isTracking() &&
!isTrackedMacOSTargetMinimized && !isTrackedMacOSTargetMinimized &&
trackedMacOSTargetFocused !== false &&
mainWindow.isVisible(); mainWindow.isVisible();
const isTrackedMacOSTargetFocused = const isTrackedMacOSTargetFocused =
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
? true ? true
: (args.windowTracker.isTargetWindowFocused?.() ?? true); : (trackedMacOSTargetFocused ?? true);
const shouldReleaseMacOSOverlayLevel = const shouldReleaseMacOSOverlayLevel =
args.isMacOSPlatform && args.isMacOSPlatform &&
!!args.windowTracker && !!args.windowTracker &&
@@ -159,14 +172,22 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.setIgnoreMouseEvents(false); mainWindow.setIgnoreMouseEvents(false);
} }
if (shouldReleaseMacOSOverlayLevel) {
releaseOverlayWindowLevel(mainWindow);
if (wasVisible) {
mainWindow.hide();
}
return false;
}
if (shouldBindTrackedWindowsOverlay) { if (shouldBindTrackedWindowsOverlay) {
// On Windows, z-order is enforced by the OS via the owner window mechanism // On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv // (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management. // without any manual z-order management.
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) { } else if (!forceMousePassthrough || args.isMacOSPlatform) {
args.ensureOverlayWindowLevel(mainWindow); args.ensureOverlayWindowLevel(mainWindow);
} else { } else {
mainWindow.setAlwaysOnTop(false); releaseOverlayWindowLevel(mainWindow);
} }
if (!wasVisible) { if (!wasVisible) {
const hasWebContents = const hasWebContents =
@@ -179,16 +200,20 @@ export function updateVisibleOverlayVisibility(args: {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady // skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer // callback will trigger another visibility update when the renderer
// has painted its first frame. // has painted its first frame.
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) { } else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
setOverlayWindowOpacity(mainWindow, 0); if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive(); mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true }); mainWindow.setIgnoreMouseEvents(true, { forward: true });
scheduleWindowsOverlayReveal( if (args.isWindowsPlatform) {
mainWindow, scheduleWindowsOverlayReveal(
shouldBindTrackedWindowsOverlay mainWindow,
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) shouldBindTrackedWindowsOverlay
: undefined, ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
); : undefined,
);
}
} else { } else {
if (args.isWindowsPlatform) { if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
@@ -216,6 +241,11 @@ export function updateVisibleOverlayVisibility(args: {
return !shouldReleaseMacOSOverlayLevel; return !shouldReleaseMacOSOverlayLevel;
}; };
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
shouldEnforceLayerOrder &&
!args.isWindowsPlatform &&
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
const maybeShowOverlayLoadingOsd = (): void => { const maybeShowOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) { if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
return; return;
@@ -258,7 +288,7 @@ export function updateVisibleOverlayVisibility(args: {
} }
args.syncPrimaryOverlayWindowLayer('visible'); args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) { if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder(); args.enforceOverlayLayerOrder();
} }
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
@@ -315,7 +345,7 @@ export function updateVisibleOverlayVisibility(args: {
} }
args.syncPrimaryOverlayWindowLayer('visible'); args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) { if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder(); args.enforceOverlayLayerOrder();
} }
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
+3 -2
View File
@@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: {
isOverlayVisible: (kind: OverlayWindowKind) => boolean; isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void; ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void; moveWindowTop: () => void;
onWindowsVisibleOverlayBlur?: () => void; onVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
}): boolean { }): boolean {
const platform = options.platform ?? process.platform; const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') { if (platform === 'win32' && options.kind === 'visible') {
options.onWindowsVisibleOverlayBlur?.(); options.onVisibleOverlayBlur?.();
return false; return false;
} }
if (platform === 'darwin' && options.kind === 'visible') { if (platform === 'darwin' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false; return false;
} }
+7 -7
View File
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
moveWindowTop: () => { moveWindowTop: () => {
calls.push('move-top'); calls.push('move-top');
}, },
onWindowsVisibleOverlayBlur: () => { onVisibleOverlayBlur: () => {
calls.push('windows-visible-blur'); calls.push('visible-blur');
}, },
platform: 'win32', platform: 'win32',
}); });
assert.equal(handled, false); assert.equal(handled, false);
assert.deepEqual(calls, ['windows-visible-blur']); assert.deepEqual(calls, ['visible-blur']);
}); });
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => { test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
@@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => { test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
const calls: string[] = []; const calls: string[] = [];
const handled = handleOverlayWindowBlurred({ const handled = handleOverlayWindowBlurred({
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib
moveWindowTop: () => { moveWindowTop: () => {
calls.push('move-top'); calls.push('move-top');
}, },
onWindowsVisibleOverlayBlur: () => { onVisibleOverlayBlur: () => {
calls.push('windows-visible-blur'); calls.push('visible-blur');
}, },
platform: 'darwin', platform: 'darwin',
}); });
assert.equal(handled, false); assert.equal(handled, false);
assert.deepEqual(calls, []); assert.deepEqual(calls, ['visible-blur']);
}); });
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => { test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
+1 -1
View File
@@ -180,7 +180,7 @@ export function createOverlayWindow(
moveWindowTop: () => { moveWindowTop: () => {
window.moveTop(); window.moveTop();
}, },
onWindowsVisibleOverlayBlur: onVisibleOverlayBlur:
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined, kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
}); });
}); });
+19
View File
@@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>; Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>; type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
Partial<Pick<BrowserWindow, 'showInactive'>>;
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean { function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
return ( return (
@@ -104,6 +106,23 @@ export function promoteStatsWindowLevel(
window.moveTop(); window.moveTop();
} }
export function presentStatsWindow(
window: StatsWindowPresentationController,
platform: NodeJS.Platform = process.platform,
): void {
if (platform === 'darwin') {
if (window.showInactive) {
window.showInactive();
} else {
window.show();
}
return;
}
window.show();
window.focus();
}
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): { export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>; query: Record<string, string>;
} { } {
+43
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel, promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
@@ -230,3 +231,45 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']); assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
}); });
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'darwin',
);
assert.deepEqual(calls, ['show-inactive']);
});
test('presentStatsWindow shows and focuses on non-macOS platforms', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'linux',
);
assert.deepEqual(calls, ['show', 'focus']);
});
+2 -2
View File
@@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel, promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
@@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
const bounds = options.resolveBounds(); const bounds = options.resolveBounds();
let placementBounds = syncStatsWindowBounds(window, bounds); let placementBounds = syncStatsWindowBounds(window, bounds);
promoteStatsWindowLevel(window); promoteStatsWindowLevel(window);
window.show(); presentStatsWindow(window);
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
if ( if (
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds }) !ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
) { ) {
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
} }
window.focus();
options.onVisibilityChanged?.(true); options.onVisibilityChanged?.(true);
promoteStatsWindowLevel(window); promoteStatsWindowLevel(window);
} }
+13 -11
View File
@@ -2112,11 +2112,11 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
})(), })(),
); );
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const; const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = []; let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false; let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false; let windowsVisibleOverlayZOrderSyncQueued = false;
@@ -2124,11 +2124,11 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval>
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0; let lastWindowsVisibleOverlayBlurredAtMs = 0;
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void { function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) { for (const timeout of visibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout); clearTimeout(timeout);
} }
windowsVisibleOverlayBlurRefreshTimeouts = []; visibleOverlayBlurRefreshTimeouts = [];
} }
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
@@ -2329,20 +2329,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
} }
function scheduleVisibleOverlayBlurRefresh(): void { function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32') { if (process.platform !== 'win32' && process.platform !== 'darwin') {
return; return;
} }
lastWindowsVisibleOverlayBlurredAtMs = Date.now(); if (process.platform === 'win32') {
clearWindowsVisibleOverlayBlurRefreshTimeouts(); lastWindowsVisibleOverlayBlurredAtMs = Date.now();
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { }
clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => { const refreshTimeout = setTimeout(() => {
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter( visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout, (timeout) => timeout !== refreshTimeout,
); );
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, delayMs); }, delayMs);
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout); visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
} }
} }
+11 -4
View File
@@ -18,8 +18,12 @@ type UpdaterLogger = {
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => { test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
const logged: string[] = []; const logged: string[] = [];
const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = { const updater: ElectronAutoUpdaterLike & {
autoInstallOnAppQuit: boolean;
logger?: UpdaterLogger | null;
} = {
autoDownload: true, autoDownload: true,
autoInstallOnAppQuit: true,
allowPrerelease: true, allowPrerelease: true,
allowDowngrade: true, allowDowngrade: true,
logger: null, logger: null,
@@ -31,6 +35,7 @@ test('configureAutoUpdater disables eager update behavior and suppresses info lo
configureAutoUpdater(updater, (message) => logged.push(message)); configureAutoUpdater(updater, (message) => logged.push(message));
assert.equal(updater.autoDownload, false); assert.equal(updater.autoDownload, false);
assert.equal(updater.autoInstallOnAppQuit, false);
assert.equal(updater.allowPrerelease, false); assert.equal(updater.allowPrerelease, false);
assert.equal(updater.allowDowngrade, false); assert.equal(updater.allowDowngrade, false);
assert.ok(updater.logger); assert.ok(updater.logger);
@@ -180,16 +185,18 @@ test('mac native updater is unsupported for ad-hoc signed app bundles', async ()
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']); assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
}); });
test('mac native updater is supported for Developer ID signed app bundles', async () => { test('mac native updater supports Developer ID signed packaged app bundles', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({ const supported = await isNativeUpdaterSupported({
platform: 'darwin', platform: 'darwin',
isPackaged: true, isPackaged: true,
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
readCodeSignature: () => log: (message) => logged.push(message),
['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'), readCodeSignature: async () => 'Authority=Developer ID Application: Kyle Yasuda (EPJ9P4RWTC)',
}); });
assert.equal(supported, true); assert.equal(supported, true);
assert.deepEqual(logged, []);
}); });
test('linux native updater is unsupported even for writable direct AppImage installs', async () => { test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
+3
View File
@@ -20,6 +20,7 @@ export interface ElectronUpdaterLoggerLike {
export interface ElectronAutoUpdaterLike { export interface ElectronAutoUpdaterLike {
autoDownload: boolean; autoDownload: boolean;
autoInstallOnAppQuit?: boolean;
allowPrerelease: boolean; allowPrerelease: boolean;
allowDowngrade: boolean; allowDowngrade: boolean;
logger?: ElectronUpdaterLoggerLike | null; logger?: ElectronUpdaterLoggerLike | null;
@@ -120,6 +121,8 @@ export function configureAutoUpdater(
channel: UpdateChannel = 'stable', channel: UpdateChannel = 'stable',
): ElectronAutoUpdaterLike { ): ElectronAutoUpdaterLike {
updater.autoDownload = false; updater.autoDownload = false;
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
updater.autoInstallOnAppQuit = false;
updater.allowPrerelease = channel === 'prerelease'; updater.allowPrerelease = channel === 'prerelease';
updater.allowDowngrade = false; updater.allowDowngrade = false;
updater.logger = { updater.logger = {
+90
View File
@@ -10,6 +10,14 @@ test('parseMacOSHelperOutput parses minimized state', () => {
}); });
}); });
test('parseMacOSHelperOutput parses active focused state without geometry', () => {
assert.deepEqual(parseMacOSHelperOutput('active'), {
geometry: null,
focused: true,
active: true,
});
});
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => { test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
let callIndex = 0; let callIndex = 0;
const outputs = [ const outputs = [
@@ -55,6 +63,87 @@ test('MacOSWindowTracker keeps the last geometry through a single helper miss',
}); });
}); });
test('MacOSWindowTracker preserves target focus during transient helper misses', async () => {
let callIndex = 0;
const focusChanges: boolean[] = [];
const outputs = [
{ stdout: '10,20,1280,720,1', stderr: '' },
{ stdout: 'not-found', stderr: '' },
];
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper.swift',
helperType: 'swift',
}),
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
trackingLossGraceMs: 1_500,
});
tracker.onWindowFocusChange = (focused) => {
focusChanges.push(focused);
};
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTargetWindowFocused(), true);
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), {
x: 10,
y: 20,
width: 1280,
height: 720,
});
assert.equal(tracker.isTargetWindowFocused(), true);
assert.deepEqual(focusChanges, [true]);
});
test('MacOSWindowTracker keeps focused fullscreen target through active helper misses after grace', async () => {
let callIndex = 0;
let now = 1_000;
const outputs = [
{ stdout: '10,20,1280,720,1', stderr: '' },
{ stdout: 'active', stderr: '' },
{ stdout: 'active', stderr: '' },
];
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper.swift',
helperType: 'swift',
}),
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
trackingLossGraceMs: 500,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowFocused(), true);
now += 1_000;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
now += 1_000;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowFocused(), true);
assert.deepEqual(tracker.getGeometry(), {
x: 10,
y: 20,
width: 1280,
height: 720,
});
});
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => { test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
let callIndex = 0; let callIndex = 0;
const outputs = [ const outputs = [
@@ -84,6 +173,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), false); assert.equal(tracker.isTracking(), false);
assert.equal(tracker.getGeometry(), null); assert.equal(tracker.getGeometry(), null);
assert.equal(tracker.isTargetWindowFocused(), false);
}); });
test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => { test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => {
+21
View File
@@ -49,11 +49,19 @@ export type MacOSHelperWindowState =
geometry: WindowGeometry; geometry: WindowGeometry;
focused: boolean; focused: boolean;
minimized?: false; minimized?: false;
active?: false;
}
| {
geometry: null;
focused: true;
active: true;
minimized?: false;
} }
| { | {
geometry: null; geometry: null;
focused: false; focused: false;
minimized: true; minimized: true;
active?: false;
}; };
function runHelperWithExecFile( function runHelperWithExecFile(
@@ -99,6 +107,13 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
minimized: true, minimized: true,
}; };
} }
if (trimmed === 'active') {
return {
geometry: null,
focused: true,
active: true,
};
}
if (!trimmed || trimmed === 'not-found') { if (!trimmed || trimmed === 'not-found') {
return null; return null;
} }
@@ -327,6 +342,12 @@ export class MacOSWindowTracker extends BaseWindowTracker {
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs); this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
return; return;
} }
if (parsed.active) {
this.resetTrackingLossState();
this.targetWindowMinimized = false;
this.updateTargetWindowFocused(true);
return;
}
this.resetTrackingLossState(); this.resetTrackingLossState();
this.targetWindowMinimized = false; this.targetWindowMinimized = false;
this.updateFocus(parsed.focused); this.updateFocus(parsed.focused);