diff --git a/changes/fix-macos-overlay-layering.md b/changes/fix-macos-overlay-layering.md new file mode 100644 index 00000000..82188e47 --- /dev/null +++ b/changes/fix-macos-overlay-layering.md @@ -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. diff --git a/changes/native-updater-crash.md b/changes/native-updater-crash.md index 417ef56f..669a10c1 100644 --- a/changes/native-updater-crash.md +++ b/changes/native-updater-crash.md @@ -1,4 +1,4 @@ type: fixed 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. diff --git a/release/prerelease-notes.md b/release/prerelease-notes.md deleted file mode 100644 index 6edd4e2e..00000000 --- a/release/prerelease-notes.md +++ /dev/null @@ -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`. diff --git a/scripts/get-mpv-window-macos.swift b/scripts/get-mpv-window-macos.swift index 059c951b..3d9f2f1c 100644 --- a/scripts/get-mpv-window-macos.swift +++ b/scripts/get-mpv-window-macos.swift @@ -7,7 +7,7 @@ // It works with both bundled and unbundled mpv installations. // // 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 @@ -25,9 +25,15 @@ private struct WindowState { let focused: Bool } +private struct FrontmostApplicationState { + let pid: pid_t + let isMpv: Bool +} + private enum WindowLookupResult { case visible(WindowState) case minimized + case active } private let targetMpvSocketPath: String? = { @@ -146,8 +152,35 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? { return geometry } -private func frontmostApplicationPid() -> pid_t? { - NSWorkspace.shared.frontmostApplication?.processIdentifier +private func frontmostApplicationState() -> FrontmostApplicationState? { + 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? { @@ -158,7 +191,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? { return normalizedMpvName(name) } - let frontmostPid = frontmostApplicationPid() + let frontmost = frontmostApplicationState() var foundMinimizedTargetWindow = false for app in runningApps { @@ -198,7 +231,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? { return .visible( WindowState( 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. let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? [] - let frontmostPid = frontmostApplicationPid() + let frontmost = frontmostApplicationState() for window in windowList { guard let ownerName = window[kCGWindowOwnerName as String] as? String, @@ -260,7 +293,7 @@ private func windowStateFromCoreGraphics() -> WindowState? { return WindowState( geometry: geometry, - focused: frontmostPid == ownerPid + focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost) ) } @@ -274,6 +307,9 @@ private let lookupResult: WindowLookupResult? = { if let cgWindow = windowStateFromCoreGraphics() { return .visible(cgWindow) } + if isFrontmostTargetMpv(frontmostApplicationState()) { + return .active + } return nil }() @@ -285,6 +321,8 @@ if let result = lookupResult { ) case .minimized: print("minimized") + case .active: + print("active") } } else { print("not-found") diff --git a/scripts/get-mpv-window-macos.test.ts b/scripts/get-mpv-window-macos.test.ts index 0f955cf6..57d7f1c8 100644 --- a/scripts/get-mpv-window-macos.test.ts +++ b/scripts/get-mpv-window-macos.test.ts @@ -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', ); }); + +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'); +}); diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 26f40d10..9619dc90 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -42,6 +42,11 @@ function createMainWindowRecorder() { setAlwaysOnTop: (flag: boolean) => { 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 }) => { 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')); }); -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 tracker: WindowTrackerStub = { isTracking: () => true, getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => true, }; updateVisibleOverlayVisibility({ @@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority forceMousePassthrough: true, } 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('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('enforce-order')); }); @@ -916,7 +967,8 @@ test('macOS tracked visible overlay starts click-through without passively steal } as never); 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')); }); @@ -1009,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re 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 tracker: WindowTrackerStub = { isTracking: () => true, @@ -1017,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', ( isTargetWindowFocused: () => false, }; + window.show(); + calls.length = 0; + updateVisibleOverlayVisibility({ visibleOverlayVisible: true, 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('mouse-ignore:true:forward')); 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('ensure-level')); assert.ok(!calls.includes('enforce-order')); 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', () => { @@ -1141,7 +1250,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible } as never); 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')); }); @@ -1438,7 +1548,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine 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 tracker: WindowTrackerStub = { 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('mouse-ignore:true:forward')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); + 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('sync-shortcuts')); - assert.ok(!calls.includes('hide')); - assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); assert.ok(!calls.includes('loading-osd')); }); diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index e05cd7e5..1d984ba7 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void { 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 { const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window); if (!pendingTimeout) { @@ -99,17 +110,19 @@ export function updateVisibleOverlayVisibility(args: { args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function'; const isTrackedMacOSTargetMinimized = canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true; + const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.(); const hasTransientMacOSTrackerLoss = args.isMacOSPlatform && canReportMacOSTargetMinimized && !!windowTracker && !windowTracker.isTracking() && !isTrackedMacOSTargetMinimized && + trackedMacOSTargetFocused !== false && mainWindow.isVisible(); const isTrackedMacOSTargetFocused = hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker ? true - : (args.windowTracker.isTargetWindowFocused?.() ?? true); + : (trackedMacOSTargetFocused ?? true); const shouldReleaseMacOSOverlayLevel = args.isMacOSPlatform && !!args.windowTracker && @@ -159,14 +172,22 @@ export function updateVisibleOverlayVisibility(args: { mainWindow.setIgnoreMouseEvents(false); } + if (shouldReleaseMacOSOverlayLevel) { + releaseOverlayWindowLevel(mainWindow); + if (wasVisible) { + mainWindow.hide(); + } + return false; + } + if (shouldBindTrackedWindowsOverlay) { // On Windows, z-order is enforced by the OS via the owner window mechanism // (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv // without any manual z-order management. - } else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) { + } else if (!forceMousePassthrough || args.isMacOSPlatform) { args.ensureOverlayWindowLevel(mainWindow); } else { - mainWindow.setAlwaysOnTop(false); + releaseOverlayWindowLevel(mainWindow); } if (!wasVisible) { const hasWebContents = @@ -179,16 +200,20 @@ export function updateVisibleOverlayVisibility(args: { // skip — ready-to-show hasn't fired yet; the onWindowContentReady // callback will trigger another visibility update when the renderer // has painted its first frame. - } else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) { - setOverlayWindowOpacity(mainWindow, 0); + } else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) { + if (args.isWindowsPlatform) { + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.showInactive(); mainWindow.setIgnoreMouseEvents(true, { forward: true }); - scheduleWindowsOverlayReveal( - mainWindow, - shouldBindTrackedWindowsOverlay - ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) - : undefined, - ); + if (args.isWindowsPlatform) { + scheduleWindowsOverlayReveal( + mainWindow, + shouldBindTrackedWindowsOverlay + ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) + : undefined, + ); + } } else { if (args.isWindowsPlatform) { setOverlayWindowOpacity(mainWindow, 0); @@ -216,6 +241,11 @@ export function updateVisibleOverlayVisibility(args: { return !shouldReleaseMacOSOverlayLevel; }; + const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean => + shouldEnforceLayerOrder && + !args.isWindowsPlatform && + (!args.forceMousePassthrough || args.isMacOSPlatform === true); + const maybeShowOverlayLoadingOsd = (): void => { if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) { return; @@ -258,7 +288,7 @@ export function updateVisibleOverlayVisibility(args: { } args.syncPrimaryOverlayWindowLayer('visible'); const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); - if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) { + if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) { args.enforceOverlayLayerOrder(); } args.syncOverlayShortcuts(); @@ -315,7 +345,7 @@ export function updateVisibleOverlayVisibility(args: { } args.syncPrimaryOverlayWindowLayer('visible'); const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); - if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) { + if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) { args.enforceOverlayLayerOrder(); } args.syncOverlayShortcuts(); diff --git a/src/core/services/overlay-window-input.ts b/src/core/services/overlay-window-input.ts index 54e0c1b7..c98ef58e 100644 --- a/src/core/services/overlay-window-input.ts +++ b/src/core/services/overlay-window-input.ts @@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: { isOverlayVisible: (kind: OverlayWindowKind) => boolean; ensureOverlayWindowLevel: () => void; moveWindowTop: () => void; - onWindowsVisibleOverlayBlur?: () => void; + onVisibleOverlayBlur?: () => void; platform?: NodeJS.Platform; }): boolean { const platform = options.platform ?? process.platform; if (platform === 'win32' && options.kind === 'visible') { - options.onWindowsVisibleOverlayBlur?.(); + options.onVisibleOverlayBlur?.(); return false; } if (platform === 'darwin' && options.kind === 'visible') { + options.onVisibleOverlayBlur?.(); return false; } diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index b69e4346..49521ee7 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback moveWindowTop: () => { calls.push('move-top'); }, - onWindowsVisibleOverlayBlur: () => { - calls.push('windows-visible-blur'); + onVisibleOverlayBlur: () => { + calls.push('visible-blur'); }, platform: 'win32', }); 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', () => { @@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo 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 handled = handleOverlayWindowBlurred({ @@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib moveWindowTop: () => { calls.push('move-top'); }, - onWindowsVisibleOverlayBlur: () => { - calls.push('windows-visible-blur'); + onVisibleOverlayBlur: () => { + calls.push('visible-blur'); }, platform: 'darwin', }); assert.equal(handled, false); - assert.deepEqual(calls, []); + assert.deepEqual(calls, ['visible-blur']); }); test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => { diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index bb30f98f..4dccaebf 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -180,7 +180,7 @@ export function createOverlayWindow( moveWindowTop: () => { window.moveTop(); }, - onWindowsVisibleOverlayBlur: + onVisibleOverlayBlur: kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined, }); }); diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index 2ffbce95..73945717 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick>; type StatsWindowBoundsController = Pick; +type StatsWindowPresentationController = Pick & + Partial>; function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean { return ( @@ -104,6 +106,23 @@ export function promoteStatsWindowLevel( 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): { query: Record; } { diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index cc599afa..da2e0dd3 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, + presentStatsWindow, promoteStatsWindowLevel, resolveStatsWindowOuterBoundsForContent, 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']); }); + +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']); +}); diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index 83aade76..70a3057c 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, + presentStatsWindow, promoteStatsWindowLevel, resolveStatsWindowOuterBoundsForContent, shouldHideStatsWindowForInput, @@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo const bounds = options.resolveBounds(); let placementBounds = syncStatsWindowBounds(window, bounds); promoteStatsWindowLevel(window); - window.show(); + presentStatsWindow(window); placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; if ( !ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds }) ) { placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; } - window.focus(); options.onVisibilityChanged?.(true); promoteStatsWindowLevel(window); } diff --git a/src/main.ts b/src/main.ts index 383903f1..01413126 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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_FOREGROUND_POLL_INTERVAL_MS = 75; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; -let windowsVisibleOverlayBlurRefreshTimeouts: Array> = []; +let visibleOverlayBlurRefreshTimeouts: Array> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; let windowsVisibleOverlayZOrderSyncInFlight = false; let windowsVisibleOverlayZOrderSyncQueued = false; @@ -2124,11 +2124,11 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayBlurredAtMs = 0; -function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void { - for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) { +function clearVisibleOverlayBlurRefreshTimeouts(): void { + for (const timeout of visibleOverlayBlurRefreshTimeouts) { clearTimeout(timeout); } - windowsVisibleOverlayBlurRefreshTimeouts = []; + visibleOverlayBlurRefreshTimeouts = []; } function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { @@ -2329,20 +2329,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void { } function scheduleVisibleOverlayBlurRefresh(): void { - if (process.platform !== 'win32') { + if (process.platform !== 'win32' && process.platform !== 'darwin') { return; } - lastWindowsVisibleOverlayBlurredAtMs = Date.now(); - clearWindowsVisibleOverlayBlurRefreshTimeouts(); - for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { + if (process.platform === 'win32') { + lastWindowsVisibleOverlayBlurredAtMs = Date.now(); + } + clearVisibleOverlayBlurRefreshTimeouts(); + for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { const refreshTimeout = setTimeout(() => { - windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter( + visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter( (timeout) => timeout !== refreshTimeout, ); overlayVisibilityRuntime.updateVisibleOverlayVisibility(); }, delayMs); - windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout); + visibleOverlayBlurRefreshTimeouts.push(refreshTimeout); } } diff --git a/src/main/runtime/update/app-updater.test.ts b/src/main/runtime/update/app-updater.test.ts index a2c9f9ba..3cf4b647 100644 --- a/src/main/runtime/update/app-updater.test.ts +++ b/src/main/runtime/update/app-updater.test.ts @@ -18,8 +18,12 @@ type UpdaterLogger = { test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => { const logged: string[] = []; - const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = { + const updater: ElectronAutoUpdaterLike & { + autoInstallOnAppQuit: boolean; + logger?: UpdaterLogger | null; + } = { autoDownload: true, + autoInstallOnAppQuit: true, allowPrerelease: true, allowDowngrade: true, logger: null, @@ -31,6 +35,7 @@ test('configureAutoUpdater disables eager update behavior and suppresses info lo configureAutoUpdater(updater, (message) => logged.push(message)); assert.equal(updater.autoDownload, false); + assert.equal(updater.autoInstallOnAppQuit, false); assert.equal(updater.allowPrerelease, false); assert.equal(updater.allowDowngrade, false); 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.']); }); -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({ platform: 'darwin', isPackaged: true, execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', - readCodeSignature: () => - ['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'), + log: (message) => logged.push(message), + readCodeSignature: async () => 'Authority=Developer ID Application: Kyle Yasuda (EPJ9P4RWTC)', }); assert.equal(supported, true); + assert.deepEqual(logged, []); }); test('linux native updater is unsupported even for writable direct AppImage installs', async () => { diff --git a/src/main/runtime/update/app-updater.ts b/src/main/runtime/update/app-updater.ts index 0076a6dc..b00fa090 100644 --- a/src/main/runtime/update/app-updater.ts +++ b/src/main/runtime/update/app-updater.ts @@ -20,6 +20,7 @@ export interface ElectronUpdaterLoggerLike { export interface ElectronAutoUpdaterLike { autoDownload: boolean; + autoInstallOnAppQuit?: boolean; allowPrerelease: boolean; allowDowngrade: boolean; logger?: ElectronUpdaterLoggerLike | null; @@ -120,6 +121,8 @@ export function configureAutoUpdater( channel: UpdateChannel = 'stable', ): ElectronAutoUpdaterLike { updater.autoDownload = false; + // On macOS this avoids invoking Squirrel until the explicit restart/install step. + updater.autoInstallOnAppQuit = false; updater.allowPrerelease = channel === 'prerelease'; updater.allowDowngrade = false; updater.logger = { diff --git a/src/window-trackers/macos-tracker.test.ts b/src/window-trackers/macos-tracker.test.ts index fe6e1d82..d7154176 100644 --- a/src/window-trackers/macos-tracker.test.ts +++ b/src/window-trackers/macos-tracker.test.ts @@ -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 () => { let callIndex = 0; 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 () => { let callIndex = 0; const outputs = [ @@ -84,6 +173,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(tracker.isTracking(), false); assert.equal(tracker.getGeometry(), null); + assert.equal(tracker.isTargetWindowFocused(), false); }); test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => { diff --git a/src/window-trackers/macos-tracker.ts b/src/window-trackers/macos-tracker.ts index 7c3cd9c1..7656d792 100644 --- a/src/window-trackers/macos-tracker.ts +++ b/src/window-trackers/macos-tracker.ts @@ -49,11 +49,19 @@ export type MacOSHelperWindowState = geometry: WindowGeometry; focused: boolean; minimized?: false; + active?: false; + } + | { + geometry: null; + focused: true; + active: true; + minimized?: false; } | { geometry: null; focused: false; minimized: true; + active?: false; }; function runHelperWithExecFile( @@ -99,6 +107,13 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | minimized: true, }; } + if (trimmed === 'active') { + return { + geometry: null, + focused: true, + active: true, + }; + } if (!trimmed || trimmed === 'not-found') { return null; } @@ -327,6 +342,12 @@ export class MacOSWindowTracker extends BaseWindowTracker { this.registerTrackingMiss(this.minimizedTrackingLossGraceMs); return; } + if (parsed.active) { + this.resetTrackingLossState(); + this.targetWindowMinimized = false; + this.updateTargetWindowFocused(true); + return; + } this.resetTrackingLossState(); this.targetWindowMinimized = false; this.updateFocus(parsed.focused);