diff --git a/changes/split-main-runtime-modules.md b/changes/split-main-runtime-modules.md new file mode 100644 index 00000000..01c10c1f --- /dev/null +++ b/changes/split-main-runtime-modules.md @@ -0,0 +1,5 @@ +type: internal +area: runtime + +- Split main-process runtime wiring into focused modules without changing user-facing behavior. +- Hardened split runtime helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors. diff --git a/package.json b/package.json index 29f68be0..f506c8ee 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/shared/mpv-x11-backend.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/overlay-runtime.test.ts src/main/runtime/macos-mpv-focus.test.ts src/main/runtime/macos-modal-focus-handoff.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/visible-overlay-autoplay-readiness.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/core/utils/electron-backend.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/linux-overlay-pointer-interaction.test.ts src/main/runtime/linux-overlay-zorder-keepalive.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/overlay-content-measurement.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-config.test.ts src/core/services/overlay-window.test.ts src/main/main-wiring.test.ts src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/overlay-modal-input-state.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-layout-main-deps.test.ts src/main/runtime/overlay-window-layout.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/yomitan-extension-overlay-reload.test.ts src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts src/main/runtime/linux-visible-overlay-window-mode.test.ts src/main/runtime/linux-x11-cursor-point.test.ts src/renderer/renderer-init-order.test.ts", - "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/shared/setup-state.test.js dist/shared/mpv-x11-backend.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/yomitan-extension-loader.test.js dist/core/services/yomitan-settings.test.js dist/core/services/settings-window-z-order.test.js dist/core/services/hyprland-window-placement.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/__tests__/stats-server.test.js dist/main/runtime/stats-server-routing.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/overlay-runtime.test.js dist/main/runtime/macos-mpv-focus.test.js dist/main/runtime/macos-modal-focus-handoff.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-ready-gate.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/main/runtime/visible-overlay-autoplay-readiness.test.js dist/main/runtime/character-dictionary-manager-gate.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/core/utils/shortcut-config.test.js dist/core/utils/electron-backend.test.js dist/main/runtime/startup-mode-flags.test.js dist/main/runtime/linux-overlay-pointer-interaction.test.js dist/main/runtime/linux-overlay-zorder-keepalive.test.js dist/main/runtime/config-settings-window.test.js dist/main/runtime/settings-window-z-order.test.js dist/main/runtime/setup-window-factory.test.js dist/main/runtime/first-run-setup-plugin.test.js dist/main/runtime/first-run-setup-service.test.js dist/main/runtime/first-run-setup-window.test.js dist/main/runtime/command-line-launcher.test.js dist/main/runtime/log-export.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/tray-main-actions.test.js dist/main/runtime/tray-main-deps.test.js dist/main/runtime/tray-runtime-handlers.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/app-ready-main-deps.test.js dist/main/runtime/update/appimage-updater.test.js dist/main/runtime/update/fetch-adapter.test.js dist/main/runtime/update/release-metadata-policy.test.js dist/main/runtime/update/update-dialogs.test.js dist/main/runtime/update/support-assets.test.js dist/renderer/error-recovery.test.js dist/renderer/overlay-content-measurement.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/main/character-dictionary-runtime/term-building.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js dist/core/services/overlay-visibility.test.js dist/core/services/overlay-window-config.test.js dist/core/services/overlay-window.test.js dist/main/main-wiring.test.js dist/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/overlay-modal-input-state.test.js dist/main/runtime/overlay-window-factory-main-deps.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/overlay-window-layout-main-deps.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/overlay-window-runtime-handlers.test.js dist/main/runtime/yomitan-extension-overlay-reload.test.js dist/renderer/modals/subtitle-sidebar.test.js dist/renderer/overlay-mouse-ignore.test.js dist/main/runtime/linux-visible-overlay-window-mode.test.js dist/main/runtime/linux-x11-cursor-point.test.js dist/renderer/renderer-init-order.test.js", + "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/shared/mpv-x11-backend.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/overlay-runtime.test.ts src/main/runtime/macos-mpv-focus.test.ts src/main/runtime/macos-modal-focus-handoff.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/visible-overlay-autoplay-readiness.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/core/utils/electron-backend.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/linux-overlay-pointer-interaction.test.ts src/main/runtime/linux-overlay-zorder-keepalive.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/windows-mpv-plugin-detection.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/overlay-content-measurement.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-config.test.ts src/core/services/overlay-window.test.ts src/main/main-wiring.test.ts src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/overlay-modal-input-state.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-layout-main-deps.test.ts src/main/runtime/overlay-window-layout.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/yomitan-extension-overlay-reload.test.ts src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts src/main/runtime/linux-visible-overlay-window-mode.test.ts src/main/runtime/linux-x11-cursor-point.test.ts src/renderer/renderer-init-order.test.ts", + "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/shared/setup-state.test.js dist/shared/mpv-x11-backend.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/yomitan-extension-loader.test.js dist/core/services/yomitan-settings.test.js dist/core/services/settings-window-z-order.test.js dist/core/services/hyprland-window-placement.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/__tests__/stats-server.test.js dist/main/runtime/stats-server-routing.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/overlay-runtime.test.js dist/main/runtime/macos-mpv-focus.test.js dist/main/runtime/macos-modal-focus-handoff.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-ready-gate.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/main/runtime/visible-overlay-autoplay-readiness.test.js dist/main/runtime/character-dictionary-manager-gate.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/core/utils/shortcut-config.test.js dist/core/utils/electron-backend.test.js dist/main/runtime/startup-mode-flags.test.js dist/main/runtime/linux-overlay-pointer-interaction.test.js dist/main/runtime/linux-overlay-zorder-keepalive.test.js dist/main/runtime/config-settings-window.test.js dist/main/runtime/settings-window-z-order.test.js dist/main/runtime/setup-window-factory.test.js dist/main/runtime/first-run-setup-plugin.test.js dist/main/runtime/windows-mpv-plugin-detection.test.js dist/main/runtime/first-run-setup-service.test.js dist/main/runtime/first-run-setup-window.test.js dist/main/runtime/command-line-launcher.test.js dist/main/runtime/log-export.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/tray-main-actions.test.js dist/main/runtime/tray-main-deps.test.js dist/main/runtime/tray-runtime-handlers.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/app-ready-main-deps.test.js dist/main/runtime/update/appimage-updater.test.js dist/main/runtime/update/fetch-adapter.test.js dist/main/runtime/update/release-metadata-policy.test.js dist/main/runtime/update/update-dialogs.test.js dist/main/runtime/update/support-assets.test.js dist/renderer/error-recovery.test.js dist/renderer/overlay-content-measurement.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/main/character-dictionary-runtime/term-building.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js dist/core/services/overlay-visibility.test.js dist/core/services/overlay-window-config.test.js dist/core/services/overlay-window.test.js dist/main/main-wiring.test.js dist/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/overlay-modal-input-state.test.js dist/main/runtime/overlay-window-factory-main-deps.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/overlay-window-layout-main-deps.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/overlay-window-runtime-handlers.test.js dist/main/runtime/yomitan-extension-overlay-reload.test.js dist/renderer/modals/subtitle-sidebar.test.js dist/renderer/overlay-mouse-ignore.test.js dist/main/runtime/linux-visible-overlay-window-mode.test.js dist/main/runtime/linux-x11-cursor-point.test.js dist/renderer/renderer-init-order.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 99263ac1..3675fa0e 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -45,6 +45,7 @@ export interface MpvRuntimeClientLike { playNextSubtitle?: () => void; setSubVisibility?: (visible: boolean) => void; setSecondarySubVisibility?: (visible: boolean) => void; + setCurrentSecondarySubText?: (text: string) => void; } export function showMpvOsdRuntime( diff --git a/src/main.ts b/src/main.ts index 81d6b257..638ec8d6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -49,66 +49,18 @@ import { } from './main/runtime/linux-mpv-fullscreen-overlay-refresh'; import { resolveLinuxVisibleOverlayWindowModeAction, - shouldExitFullscreenOverrideForTrackedGeometry, type LinuxVisibleOverlayWindowMode, } from './main/runtime/linux-visible-overlay-window-mode'; -import { - ensureLinuxOverlayZOrderKeepAliveLoop, - shouldRunLinuxOverlayZOrderKeepAlive, - tickLinuxOverlayZOrderKeepAlive, -} from './main/runtime/linux-overlay-zorder-keepalive'; -import { - applyLinuxOverlayInputShape, - applyLinuxOverlayPointerInteractionMousePassthrough, - ensureLinuxOverlayPointerInteractionLoop, - type ForegroundSuppressionGraceState, - mapOverlayMeasurementForPointerInteraction, - resolveForegroundSuppressionWithGrace, - shouldPrimeLinuxOverlayInteractionFromMeasurement, - tickLinuxOverlayPointerInteraction, -} from './main/runtime/linux-overlay-pointer-interaction'; -import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point'; +import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive'; import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus'; import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff'; import { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state'; import { mergeAiConfig } from './ai/config'; - -function getPasswordStoreArg(argv: string[]): string | null { - let resolved: string | null = null; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg?.startsWith('--password-store')) { - continue; - } - - if (arg === '--password-store') { - const value = argv[i + 1]; - if (value && !value.startsWith('--')) { - resolved = value.trim(); - i += 1; - } - continue; - } - - const [prefix, value] = arg.split('=', 2); - if (prefix === '--password-store' && value && value.trim().length > 0) { - resolved = value.trim(); - } - } - return resolved; -} - -function normalizePasswordStoreArg(value: string): string { - const normalized = value.trim(); - if (normalized.toLowerCase() === 'gnome') { - return 'gnome-libsecret'; - } - return normalized; -} - -function getDefaultPasswordStore(): string { - return 'gnome-libsecret'; -} +import { + getDefaultPasswordStore, + getPasswordStoreArg, + normalizePasswordStoreArg, +} from './main/password-store-args'; protocol.registerSchemesAsPrivileged([ { @@ -124,12 +76,11 @@ protocol.registerSchemesAsPrivileged([ ]); import * as fs from 'fs'; -import { execFile, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import * as os from 'os'; import * as path from 'path'; import { MecabTokenizer } from './mecab-tokenizer'; import type { - CompiledSessionBinding, JimakuApiResponse, KikuFieldGroupingChoice, MpvSubtitleRenderMetrics, @@ -137,14 +88,11 @@ import type { RuntimeOptionState, SessionActionDispatchRequest, SecondarySubMode, - SubtitleCue, SubtitleData, SubtitleMiningContext, SubtitlePosition, OverlayNotificationPayload, - OverlayNotificationEventPayload, NotificationType, - UpdateChannel, WindowGeometry, } from './types'; import { OPEN_ANKI_CARD_ACTION_ID } from './types'; @@ -165,15 +113,7 @@ import { resolveDefaultLogFilePath, } from './shared/log-files'; import { createFatalErrorReporter } from './main/fatal-error'; -import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; -import { - bindWindowsOverlayAboveMpv, - clearWindowsOverlayOwner, - ensureWindowsOverlayTransparency, - findWindowsMpvTargetWindowHandle, - getWindowsForegroundProcessName, - setWindowsOverlayOwner, -} from './window-trackers/windows-helper'; +import { ensureWindowsOverlayTransparency } from './window-trackers/windows-helper'; import { commandNeedsOverlayStartupPrereqs, commandNeedsOverlayRuntime, @@ -253,9 +193,6 @@ import { createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler, createBuildSendToActiveOverlayWindowMainDepsHandler, createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler, - createBuildEnforceOverlayLayerOrderMainDepsHandler, - createBuildEnsureOverlayWindowLevelMainDepsHandler, - createBuildUpdateVisibleOverlayBoundsMainDepsHandler, createTrayRuntimeHandlers, createOverlayVisibilityRuntime, createBroadcastRuntimeOptionsChangedHandler, @@ -266,10 +203,6 @@ import { createRestorePreviousSecondarySubVisibilityHandler, createSendToActiveOverlayWindowHandler, createSetOverlayDebugVisualizationEnabledHandler, - createEnforceOverlayLayerOrderHandler, - createEnsureOverlayWindowLevelHandler, - createUpdateVisibleOverlayBoundsHandler, - hasLiveOverlayWindowBoundsMismatch, createLoadSubtitlePositionHandler, createSaveSubtitlePositionHandler, createAppendClipboardVideoToQueueHandler, @@ -357,8 +290,6 @@ import { cycleSecondarySubMode as cycleSecondarySubModeCore, deleteYomitanDictionaryByTitle, destroyYomitanSettingsWindow, - enforceOverlayLayerOrder as enforceOverlayLayerOrderCore, - ensureOverlayWindowLevel as ensureOverlayWindowLevelCore, getYomitanDictionaryInfo, handleMineSentenceDigit as handleMineSentenceDigitCore, handleMultiCopyDigit as handleMultiCopyDigitCore, @@ -385,17 +316,13 @@ import { runStartupBootstrapRuntime, saveJellyfinSubtitleDelay, saveSubtitlePosition as saveSubtitlePositionCore, - addYomitanNoteViaSearch, clearYomitanParserCachesForWindow, getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore, - syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, sendMpvCommandRuntime, setMpvSubVisibilityRuntime, setOverlayDebugVisualizationEnabledRuntime, - syncOverlayWindowLayer, setVisibleOverlayVisible as setVisibleOverlayVisibleCore, showMpvOsdRuntime, - startOverlayWindowTracker as startOverlayWindowTrackerCore, tokenizeSubtitle as tokenizeSubtitleCore, triggerFieldGrouping as triggerFieldGroupingCore, upsertYomitanDictionarySettings, @@ -405,14 +332,10 @@ import { acquireYoutubeSubtitleTrack, acquireYoutubeSubtitleTracks, } from './core/services/youtube/generate'; -import { hasHyprlandWindowPlacementBoundsMismatch } from './core/services/hyprland-window-placement'; -import { normalizeOverlayWindowBoundsForPlatform } from './core/services/overlay-window-bounds'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { probeYoutubeTracks } from './core/services/youtube/track-probe'; -import { startStatsServer } from './core/services/stats-server'; import { destroyStatsWindow, - promoteStatsOverlayAbovePlayback, registerStatsOverlayToggle, toggleStatsOverlay as toggleStatsOverlayWindow, withStatsWindowLayerSuspendedForNativeDialog, @@ -430,7 +353,7 @@ import { createYoutubePrimarySubtitleNotificationRuntime, } from './main/runtime/youtube-primary-subtitle-notification'; import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate'; -import { selectAutoplayStartupCue } from './main/runtime/autoplay-subtitle-primer'; +import { createAutoplaySubtitlePrimingRuntime } from './main/runtime/autoplay-subtitle-priming-runtime'; import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release'; import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness'; import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection'; @@ -446,8 +369,8 @@ import { detectInstalledFirstRunPluginCandidates, detectInstalledMpvPlugin, removeLegacyMpvPluginCandidates, - resolvePackagedRuntimePluginPath, } from './main/runtime/first-run-setup-plugin'; +import { createWindowsMpvPluginDetectionRuntime } from './main/runtime/windows-mpv-plugin-detection'; import { applyWindowsMpvShortcuts, detectWindowsMpvShortcuts, @@ -480,21 +403,14 @@ import { shouldQuitOnMpvShutdownForTrayState, shouldQuitOnWindowAllClosedForTrayState, } from './main/runtime/startup-tray-policy'; -import { exportLogsArchive } from './main/runtime/log-export'; +import { createLogExportTrayRuntime } from './main/runtime/log-export-tray'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps'; import { createRunStatsCliCommandHandler, writeStatsCliCommandResponse, } from './main/runtime/stats-cli-command'; -import { - isBackgroundStatsServerProcessAlive, - readBackgroundStatsServerState, - removeBackgroundStatsServerState, - resolveBackgroundStatsServerUrl, - writeBackgroundStatsServerState, -} from './main/runtime/stats-daemon'; -import { createEnsureStatsServerUrlHandler } from './main/runtime/stats-server-routing'; +import { createStatsServerRuntime } from './main/runtime/stats-server-runtime'; import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { @@ -506,10 +422,6 @@ import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter'; import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; -import { - buildPluginSessionBindingsArtifact, - compileSessionBindings, -} from './core/services/session-bindings'; import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions'; import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; import { createMainRuntimeRegistry } from './main/runtime/registry'; @@ -555,7 +467,7 @@ import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerMo import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; -import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact'; +import { createSessionBindingsRuntime } from './main/runtime/session-bindings-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createFrequencyDictionaryRuntimeService, @@ -567,7 +479,6 @@ import { } from './main/jlpt-runtime'; import { createMediaRuntimeService } from './main/media-runtime'; import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime'; -import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-overlay-visibility'; import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime'; import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime'; import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup'; @@ -582,55 +493,22 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { openCharacterDictionaryManagerWithConfigGate } from './main/runtime/character-dictionary-manager-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; -import { - primeVisibleOverlaySubtitleFromMpv, - resolveCurrentSubtitleForRenderer, -} from './main/runtime/current-subtitle-snapshot'; -import { restoreLinuxOverlayWindowShape } from './main/runtime/linux-overlay-window-shape'; +import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; +import { createOverlayGeometryRuntime } from './main/runtime/overlay-geometry-runtime'; +import { createVisibleOverlayInteractionRuntime } from './main/runtime/visible-overlay-interaction-runtime'; import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; -import { - createElectronAppUpdater, - isNativeUpdaterSupported, -} from './main/runtime/update/app-updater'; -import { createCurlFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter'; -import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor'; -import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor'; -import { - fetchLatestStableRelease, - fetchReleaseAssetBuffer, - fetchReleaseAssetText, - findReleaseAsset, - parseSha256Sums, - type GitHubRelease, -} from './main/runtime/update/release-assets'; -import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy'; -import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater'; import { INSTALL_UPDATE_ACTION_ID, - notifyUpdateAvailable, UPDATE_AVAILABLE_NOTIFICATION_ID, } from './main/runtime/update/update-notifications'; -import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd'; -import { createMaybeStartOverlayLoadingOsdHandler } from './main/runtime/overlay-loading-osd-start'; -import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position'; -import { createOverlayNotificationDelivery } from './main/runtime/overlay-notification-delivery'; -import { - getPlaybackFeedbackNotificationOptions, - notifyConfiguredStatus, - type ConfiguredStatusNotificationOptions, -} from './main/runtime/configured-status-notification'; -import { resolveOverlayReadinessNotificationType } from './main/runtime/notification-routing'; -import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs'; +import { createOverlayNotificationsRuntime } from './main/runtime/overlay-notifications-runtime'; +import { type ConfiguredStatusNotificationOptions } from './main/runtime/configured-status-notification'; import { runUpdateCliCommand, writeUpdateCliCommandResponse, } from './main/runtime/update/update-cli-command'; -import { - createFileUpdateStateStore, - createUpdateService, -} from './main/runtime/update/update-service'; -import { updateSupportAssetsFromRelease } from './main/runtime/update/support-assets'; +import { createUpdateServiceRuntime } from './main/runtime/update/update-service-runtime'; import { createRefreshSubtitlePrefetchFromActiveTrackHandler, createResolveActiveSubtitleSidebarSourceHandler, @@ -642,10 +520,7 @@ import { createCreateJellyfinSetupWindowHandler, } from './main/runtime/setup-window-factory'; import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime'; -import { - hasLiveSeparateWindow, - shouldSuppressVisibleOverlayRaiseForSeparateWindow, -} from './main/runtime/settings-window-z-order'; +import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order'; import { isSameYoutubeMediaPath, isYoutubeMediaPath, @@ -655,10 +530,7 @@ import { import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; -import { - getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, - shouldForceOverrideYomitanAnkiServer, -} from './main/runtime/yomitan-anki-server'; +import { createYomitanAnkiServerSyncRuntime } from './main/runtime/yomitan-anki-server-sync'; import { type AnilistMediaGuessRuntimeState, type StartupState, @@ -691,13 +563,14 @@ import { createSubtitlePrefetchService, type SubtitlePrefetchService, } from './core/services/subtitle-prefetch'; -import { - buildSubtitleSidebarSourceKey, - resolveSubtitleSourcePath, -} from './main/runtime/subtitle-prefetch-source'; +import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; +import { + loadSubtitleSourceText, + extractInternalSubtitleTrackToTempFile, +} from './main/runtime/internal-subtitle-extraction'; import { applyCharacterDictionarySelection } from './main/character-dictionary-selection'; -import { codecToExtension, getSubsyncConfig } from './subsync/utils'; +import { getSubsyncConfig } from './subsync/utils'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -743,7 +616,6 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60; const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000; const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; const TRAY_TOOLTIP = 'SubMiner'; -const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner'; const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js'); let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState = @@ -1070,45 +942,49 @@ const reportFatalError = createFatalErrorReporter({ }); let forceQuitTimer: ReturnType | null = null; -let statsServer: ReturnType | null = null; -const statsDaemonStatePath = path.join(USER_DATA_PATH, 'stats-daemon.json'); - -function readLiveBackgroundStatsDaemonState(): { - pid: number; - port: number; - startedAtMs: number; -} | null { - const state = readBackgroundStatsServerState(statsDaemonStatePath); - if (!state) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return null; - } - if (state.pid === process.pid && !statsServer) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return null; - } - if (!isBackgroundStatsServerProcessAlive(state.pid)) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return null; - } - return state; -} - -function clearOwnedBackgroundStatsDaemonState(): void { - const state = readBackgroundStatsServerState(statsDaemonStatePath); - if (state?.pid === process.pid) { - removeBackgroundStatsServerState(statsDaemonStatePath); - } -} - -function stopStatsServer(): void { - if (!statsServer) { - return; - } - statsServer.close(); - statsServer = null; - clearOwnedBackgroundStatsDaemonState(); -} +const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); +const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); +const statsServerRuntime = createStatsServerRuntime({ + userDataPath: USER_DATA_PATH, + statsDistPath, + getResolvedConfig: () => getResolvedConfig(), + getImmersionTracker: () => appState.immersionTracker, + setAppStateStatsServer: (server) => { + appState.statsServer = server; + }, + getMpvSocketPath: () => appState.mpvSocketPath, + getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (w) => { + appState.yomitanParserWindow = w; + }, + getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (p) => { + appState.yomitanParserReadyPromise = p; + }, + getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (p) => { + appState.yomitanParserInitPromise = p; + }, + getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(), + getAnilistRateLimiter: () => anilistRateLimiter, + resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, + trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => { + appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds); + }, + resolveSentenceSearchHeadwords: (term) => resolveSentenceSearchHeadwords(term), + ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), + setStatsStartupInProgress: (inProgress) => { + appState.statsStartupInProgress = inProgress; + }, +}); +const { + stopStatsServer, + ensureStatsServerStarted, + ensureBackgroundStatsServerStarted, + stopBackgroundStatsServer, +} = statsServerRuntime; function requestAppQuit(): void { destroyYomitanSettingsWindow(appState.yomitanSettingsWindow); @@ -1233,7 +1109,10 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ while (Date.now() < deadline) { const tracker = appState.windowTracker; const trackerGeometry = tracker?.getGeometry() ?? null; - if (trackerGeometry && geometryMatches(lastOverlayWindowGeometry, trackerGeometry)) { + if ( + trackerGeometry && + geometryMatches(overlayGeometryRuntime.getLastOverlayWindowGeometry(), trackerGeometry) + ) { return; } await new Promise((resolve) => setTimeout(resolve, 50)); @@ -1343,87 +1222,15 @@ const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelection clearScheduled: (timer) => clearTimeout(timer), }); -function resolveBundledMpvRuntimePluginEntrypoint(): string | undefined { - return ( - resolvePackagedRuntimePluginPath({ - dirname: __dirname, - appPath: app.getAppPath(), - resourcesPath: process.resourcesPath, - }) ?? undefined - ); -} - -function detectWindowsInstalledMpvPlugin(mpvExecutablePath: string) { - return detectInstalledMpvPlugin({ - platform: 'win32', - homeDir: os.homedir(), - appDataDir: app.getPath('appData'), - mpvExecutablePath, - }); -} - -function logInstalledMpvPluginDetected(detection: { path: string | null; version: string | null }) { - if (!detection.path) return; - logger.warn( - `SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`, - ); -} - -async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch( - mpvPath: string, - detection: { path: string | null; version: string | null }, -): Promise<'removed' | 'continue' | 'cancel'> { - const response = await dialog.showMessageBox({ - type: 'warning', - title: 'SubMiner mpv plugin detected', - message: [ - 'SubMiner detected an installed mpv plugin at:', - detection.path ?? 'unknown path', - '', - "This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.", - `Detected plugin version: ${detection.version ?? 'unknown or legacy'}`, - ].join('\n'), - detail: - 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.', - buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'], - defaultId: 0, - cancelId: 2, - }); - - if (response.response === 2) { - return 'cancel'; - } - if (response.response === 1) { - return 'continue'; - } - - const result = await removeLegacyMpvPluginCandidates({ - candidates: detectInstalledFirstRunPluginCandidates({ - platform: 'win32', - homeDir: os.homedir(), - appDataDir: app.getPath('appData'), - mpvExecutablePath: mpvPath, - }), - trashItem: (candidatePath) => shell.trashItem(candidatePath), - }); - if (result.ok) { - await dialog.showMessageBox({ - type: 'info', - title: 'Legacy mpv plugin removed', - message: - 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.', - }); - return 'removed'; - } - - await dialog.showMessageBox({ - type: 'error', - title: 'Could not remove legacy mpv plugin', - message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.', - detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'), - }); - return 'cancel'; -} +const { + resolveBundledMpvRuntimePluginEntrypoint, + detectWindowsInstalledMpvPlugin, + logInstalledMpvPluginDetected, + promptForLegacyMpvPluginRemovalBeforeWindowsLaunch, +} = createWindowsMpvPluginDetectionRuntime({ + mainDirname: __dirname, + logWarn: (message) => logger.warn(message), +}); const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ platform: process.platform, @@ -1931,7 +1738,6 @@ const subtitleProcessingController = createSubtitleProcessingController( ); let subtitlePrefetchService: SubtitlePrefetchService | null = null; -let subtitlePrefetchRefreshTimer: ReturnType | null = null; let lastObservedTimePos = 0; let lastObservedPrimarySubtitleTrackId: number | null = null; let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null = @@ -1940,170 +1746,51 @@ let linuxVisibleOverlayWindowMode: LinuxVisibleOverlayWindowMode = 'managed'; let linuxTrackedMpvFullscreen = false; let linuxTrackedMpvFullscreenChangedAtMs = 0; let linuxVisibleOverlayOwnerBindingKey: string | null = null; -const linuxVisibleOverlayOwnerBindingQueues = new WeakMap>(); let linuxVisibleOverlayWindowModeSwitchToken = 0; let subtitleSidebarRequestedOpen = false; const SEEK_THRESHOLD_SECONDS = 3; -const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2; -let autoplaySubtitlePrimedMediaPath: string | null = null; -let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType | null = null; -const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100; -function getCurrentAutoplayMediaPath(): string | null { - return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null; -} +const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({ + getCurrentMediaPath: () => appState.currentMediaPath, + getMpvClient: () => appState.mpvClient, + setCurrentSubText: (text) => { + appState.currentSubText = text; + }, + getCurrentSubText: () => appState.currentSubText, + getCurrentSubtitleData: () => appState.currentSubtitleData, + setActiveParsedSubtitleMediaPath: (mediaPath) => { + appState.activeParsedSubtitleMediaPath = mediaPath; + }, + subtitleProcessingController, + emitSubtitlePayload: (payload) => emitSubtitlePayload(payload), + getSubtitlePrefetchService: () => subtitlePrefetchService, + getLastObservedTimePos: () => lastObservedTimePos, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + emitSecondarySubtitle: (text) => { + broadcastToOverlayWindows('secondary-subtitle:set', text); + }, + initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) => + subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey), + refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), + logDebug: (message) => { + logger.debug(message); + }, +}); -function isCurrentAutoplayMediaPath(mediaPath: string): boolean { - return getCurrentAutoplayMediaPath() === mediaPath; -} - -function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean { - if (autoplaySubtitlePrimedMediaPath === mediaPath) { - return false; - } - autoplaySubtitlePrimedMediaPath = mediaPath; - return true; -} - -function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean { - if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) { - return false; - } - if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) { - return false; - } - - appState.currentSubText = text; - subtitlePrefetchService?.pause(); - const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text); - if (cachedPayload) { - subtitleProcessingController.onSubtitleChange(text); - emitSubtitlePayload(cachedPayload); - return true; - } - - subtitleProcessingController.onSubtitleChange(text); - return true; -} - -async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise { - const client = appState.mpvClient; - if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) { - return; - } - - const subTextRaw = await client.requestProperty('sub-text').catch((error) => { - logger.debug( - `[autoplay-subtitle-prime] failed to read sub-text: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - return null; - }); - const text = typeof subTextRaw === 'string' ? subTextRaw : ''; - emitAutoplayPrimedSubtitle(mediaPath, text); -} - -async function primeCurrentSubtitleForVisibleOverlay(): Promise { - await primeVisibleOverlaySubtitleFromMpv({ - getMpvClient: () => appState.mpvClient, - setCurrentSubText: (text) => { - appState.currentSubText = text; - }, - getCurrentSubtitleData: () => appState.currentSubtitleData, - consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text), - onSubtitleChange: (text) => { - subtitlePrefetchService?.pause(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - subtitleProcessingController.onSubtitleChange(text); - }, - refreshCurrentSubtitle: (text) => { - subtitlePrefetchService?.pause(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - subtitleProcessingController.refreshCurrentSubtitle(text); - }, - deferUncachedRefresh: true, - emitSubtitle: (payload) => emitSubtitlePayload(payload), - setCurrentSecondarySubText: (text) => { - if (appState.mpvClient) { - appState.mpvClient.currentSecondarySubText = text; - } - }, - emitSecondarySubtitle: (text) => { - broadcastToOverlayWindows('secondary-subtitle:set', text); - }, - logDebug: (message) => { - logger.debug(message); - }, - }); +function primeCurrentSubtitleForVisibleOverlay(): Promise { + return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); } function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { - if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { - return; - } - clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer); - visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; + autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); } function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { - if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { - return; - } - if (!overlayManager.getVisibleOverlayVisible() || !appState.currentSubText.trim()) { - return; - } - - visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => { - visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; - if (!overlayManager.getVisibleOverlayVisible()) { - return; - } - const text = appState.currentSubText; - if (!text.trim()) { - return; - } - subtitlePrefetchService?.pause(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - subtitleProcessingController.refreshCurrentSubtitle(text); - }, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS); - visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.(); + autoplaySubtitlePrimingRuntime.scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); } -async function primeAutoplaySubtitleFromParsedCues( - mediaPath: string, - cues: SubtitleCue[], -): Promise { - if ( - cues.length === 0 || - autoplaySubtitlePrimedMediaPath === mediaPath || - !isCurrentAutoplayMediaPath(mediaPath) - ) { - return; - } - - const client = appState.mpvClient; - const timePosRaw = await client?.requestProperty('time-pos').catch(() => null); - const currentTimeSeconds = Number( - timePosRaw ?? client?.currentTimePos ?? lastObservedTimePos ?? 0, - ); - const cue = selectAutoplayStartupCue( - cues, - Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0, - AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS, - ); - if (!cue) { - return; - } - - emitAutoplayPrimedSubtitle(mediaPath, cue.text); -} - -function clearScheduledSubtitlePrefetchRefresh(): void { - if (subtitlePrefetchRefreshTimer) { - clearTimeout(subtitlePrefetchRefreshTimer); - subtitlePrefetchRefreshTimer = null; - } +function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { + autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs); } function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void { @@ -2134,15 +1821,17 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ if (!cues?.length) { appState.activeParsedSubtitleMediaPath = null; } - const mediaPath = getCurrentAutoplayMediaPath(); + const mediaPath = autoplaySubtitlePrimingRuntime.getCurrentAutoplayMediaPath(); if (mediaPath && cues?.length) { - void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => { - logger.debug( - `[autoplay-subtitle-prime] failed to prime from parsed cues: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - }); + void autoplaySubtitlePrimingRuntime + .primeAutoplaySubtitleFromParsedCues(mediaPath, cues) + .catch((error) => { + logger.debug( + `[autoplay-subtitle-prime] failed to prime from parsed cues: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); } }, }); @@ -2152,22 +1841,6 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), }); -async function refreshSubtitleSidebarFromSource( - sourcePath: string, - mediaPath?: string, -): Promise { - const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim()); - if (!normalizedSourcePath) { - return; - } - const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath(); - await subtitlePrefetchInitController.initSubtitlePrefetch( - normalizedSourcePath, - lastObservedTimePos, - normalizedSourcePath, - ); - appState.activeParsedSubtitleMediaPath = nextMediaPath; -} const refreshSubtitlePrefetchFromActiveTrackHandler = createRefreshSubtitlePrefetchFromActiveTrackHandler({ getMpvClient: () => appState.mpvClient, @@ -2177,21 +1850,16 @@ const refreshSubtitlePrefetchFromActiveTrackHandler = resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), }); -function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { - clearScheduledSubtitlePrefetchRefresh(); - subtitlePrefetchRefreshTimer = setTimeout(() => { - subtitlePrefetchRefreshTimer = null; - void refreshSubtitlePrefetchFromActiveTrackHandler(); - }, delayMs); -} const subtitlePrefetchRuntime = { cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(), initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch, refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) => - refreshSubtitleSidebarFromSource(sourcePath, mediaPath), + autoplaySubtitlePrimingRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath), refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), - scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs), - clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(), + scheduleSubtitlePrefetchRefresh: (delayMs?: number) => + autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs), + clearScheduledSubtitlePrefetchRefresh: () => + autoplaySubtitlePrimingRuntime.clearScheduledSubtitlePrefetchRefresh(), } as const; const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( @@ -2670,14 +2338,18 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getForceMousePassthrough: () => appState.statsOverlayVisible, getNonNativeInputRegionActive: () => - process.platform === 'linux' && linuxOverlayInputShapeActive, + process.platform === 'linux' && + visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(), getSuspendVisibleOverlay: () => appState.statsOverlayVisible, - getOverlayInteractionActive: () => visibleOverlayInteractionActive, + getOverlayInteractionActive: () => + visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(), getWindowTracker: () => appState.windowTracker, - getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, + getLastKnownWindowsForegroundProcessName: () => + visibleOverlayInteractionRuntime.getLastWindowsVisibleOverlayForegroundProcessName(), getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(), - getMacOSForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive, + getMacOSForegroundProbeActive: () => + visibleOverlayInteractionRuntime.getMacOSVisibleOverlayForegroundProbeActive(), getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown: boolean) => { appState.trackerNotReadyWarningShown = shown; @@ -2723,142 +2395,73 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( })(), ); -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; -const LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 1_500; -const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200; -// Ignore transient "neither mpv nor overlay is the active window" blips before suppressing -// subtitle pointer interaction. Right after playback starts the overlay can briefly become the -// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s). -const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500; -const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500; -const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200; -let visibleOverlayBlurRefreshTimeouts: Array> = []; -let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; -let windowsVisibleOverlayZOrderSyncInFlight = false; -let windowsVisibleOverlayZOrderSyncQueued = false; -let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; -let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; -let lastWindowsVisibleOverlayBlurredAtMs = 0; -let lastLinuxVisibleOverlayFollowedMpvAtMs = 0; -const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = { - lossSinceMs: null, -}; -let visibleOverlayInteractionActive = false; -let linuxOverlayInputShapeActive = false; -let linuxVisibleOverlayStartupInputPrimed = false; -let linuxVisibleOverlayStartupInputGraceUntilMs = 0; -// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal -// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor -// moves off measured subtitle/sidebar rects onto the popup. -let linuxOverlayInteractiveHint = false; -let macOSVisibleOverlayForegroundProbeActive = false; -let macOSVisibleOverlayForegroundProbeToken = 0; -let macOSVisibleOverlayForegroundProbeTimeout: ReturnType | null = null; - -const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({ - setStatsOverlayVisibleState: (visible) => { +const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime({ + overlayManager: { + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }, + overlayContentMeasurementStore: { + clear: (layer) => overlayContentMeasurementStore.clear(layer), + getLatestByLayer: (layer) => overlayContentMeasurementStore.getLatestByLayer(layer), + }, + logger: { + info: (message, ...args) => logger.info(message, ...args), + warn: (message, ...args) => logger.warn(message, ...args), + debug: (message, ...args) => logger.debug(message, ...args), + }, + updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + getModalInputExclusive: () => overlayModalInputState.getModalInputExclusive(), + getStatsOverlayVisible: () => appState.statsOverlayVisible, + setStatsOverlayVisible: (visible) => { appState.statsOverlayVisible = visible; }, - resetVisibleOverlayInteraction: () => { - visibleOverlayInteractionActive = false; + getWindowTracker: () => appState.windowTracker, + setWindowTracker: (tracker) => { + appState.windowTracker = tracker; }, - getMainWindow: () => overlayManager.getMainWindow(), - updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + setTrackerNotReadyWarningShown: (shown) => { + appState.trackerNotReadyWarningShown = shown; + }, + getMpvSocketPath: () => appState.mpvSocketPath, + getBackendOverride: () => appState.backendOverride, + getInitialArgs: () => appState.initialArgs, + getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode, + setLinuxVisibleOverlayOwnerBindingKey: (key) => { + linuxVisibleOverlayOwnerBindingKey = key; + }, + bindVisibleOverlayToTrackedX11Window: (window) => bindVisibleOverlayToTrackedX11Window(window), + updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry), + refreshCurrentSubtitle: () => { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + }, + getOverlayWindows: () => getOverlayWindows(), + syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), + resetLastOverlayWindowGeometry: () => overlayGeometryRuntime.resetLastOverlayWindowGeometry(), + enforceOverlayLayerOrder: () => { + enforceOverlayLayerOrder(); + }, + getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(), }); +function handleStatsOverlayVisibilityChanged(visible: boolean): void { + visibleOverlayInteractionRuntime.handleStatsOverlayVisibilityChanged(visible); +} + function resetVisibleOverlayInputState(): void { - visibleOverlayInteractionActive = false; - linuxOverlayInputShapeActive = false; - resetLinuxVisibleOverlayStartupInputPrimer(); - linuxOverlayInteractiveHint = false; - overlayContentMeasurementStore.clear('visible'); - const mainWindow = overlayManager.getMainWindow(); - if (process.platform === 'linux' && mainWindow && !mainWindow.isDestroyed()) { - restoreLinuxOverlayWindowShape(mainWindow); - } + visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); } function restoreVisibleOverlayWindowShapeForShow(): void { - if (process.platform !== 'linux') { - return; - } - restoreLinuxOverlayWindowShape(overlayManager.getMainWindow()); -} - -function clearVisibleOverlayBlurRefreshTimeouts(): void { - for (const timeout of visibleOverlayBlurRefreshTimeouts) { - clearTimeout(timeout); - } - visibleOverlayBlurRefreshTimeouts = []; -} - -function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { - for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) { - clearTimeout(timeout); - } - windowsVisibleOverlayZOrderRetryTimeouts = []; -} - -function finishMacOSVisibleOverlayForegroundProbe(token: number): void { - if (token !== macOSVisibleOverlayForegroundProbeToken) { - return; - } - if (macOSVisibleOverlayForegroundProbeTimeout !== null) { - clearTimeout(macOSVisibleOverlayForegroundProbeTimeout); - macOSVisibleOverlayForegroundProbeTimeout = null; - } - if (!macOSVisibleOverlayForegroundProbeActive) { - return; - } - macOSVisibleOverlayForegroundProbeActive = false; - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); -} - -function startMacOSVisibleOverlayForegroundProbe(): void { - if (process.platform !== 'darwin') { - return; - } - const tracker = appState.windowTracker; - if (!tracker) { - return; - } - - macOSVisibleOverlayForegroundProbeActive = true; - const token = ++macOSVisibleOverlayForegroundProbeToken; - if (macOSVisibleOverlayForegroundProbeTimeout !== null) { - clearTimeout(macOSVisibleOverlayForegroundProbeTimeout); - } - macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => { - finishMacOSVisibleOverlayForegroundProbe(token); - }, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS); - - void tracker - .refreshNow() - .catch((error) => { - logger.warn('Failed to refresh macOS frontmost app after overlay blur', error); - }) - .finally(() => { - finishMacOSVisibleOverlayForegroundProbe(token); - }); + visibleOverlayInteractionRuntime.restoreVisibleOverlayWindowShapeForShow(); } function getNativeWindowHandleDecimal(window: BrowserWindow): string { - const handle = window.getNativeWindowHandle(); - return handle.length >= 8 - ? handle.readBigUInt64LE(0).toString() - : BigInt(handle.readUInt32LE(0)).toString(); -} - -function getWindowsNativeWindowHandle(window: BrowserWindow): string { - return getNativeWindowHandleDecimal(window); + return visibleOverlayInteractionRuntime.getNativeWindowHandleDecimal(window); } function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number { - const handle = window.getNativeWindowHandle(); - return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0); + return visibleOverlayInteractionRuntime.getWindowsNativeWindowHandleNumber(window); } function enqueueVisibleOverlayX11OwnerBindingOperation( @@ -2866,540 +2469,79 @@ function enqueueVisibleOverlayX11OwnerBindingOperation( args: string[], onError?: (error: Error) => void, ): void { - const previous = linuxVisibleOverlayOwnerBindingQueues.get(window) ?? Promise.resolve(); - const operation = previous - .catch(() => {}) - .then( - () => - new Promise((resolve) => { - if (window.isDestroyed()) { - resolve(); - return; - } - execFile('xprop', args, { timeout: 1500 }, (error) => { - if (error) { - onError?.(error); - } - resolve(); - }); - }), - ); - const queued = operation.finally(() => { - if (linuxVisibleOverlayOwnerBindingQueues.get(window) === queued) { - linuxVisibleOverlayOwnerBindingQueues.delete(window); - } - }); - linuxVisibleOverlayOwnerBindingQueues.set(window, queued); + visibleOverlayInteractionRuntime.enqueueVisibleOverlayX11OwnerBindingOperation( + window, + args, + onError, + ); } function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void { - if (window.isDestroyed()) return; - enqueueVisibleOverlayX11OwnerBindingOperation(window, [ - '-id', - getNativeWindowHandleDecimal(window), - '-remove', - 'WM_TRANSIENT_FOR', - ]); -} - -function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | null): number | null { - if (process.platform !== 'win32') { - return null; - } - - try { - if (targetMpvSocketPath) { - const windowTracker = appState.windowTracker as { - getTargetWindowHandle?: () => number | null; - } | null; - const trackedHandle = windowTracker?.getTargetWindowHandle?.(); - if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) { - return trackedHandle; - } - return null; - } - return findWindowsMpvTargetWindowHandle(); - } catch { - return null; - } + visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window); } function createOverlayWindowTracker(override?: string | null, targetMpvSocketPath?: string | null) { - if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { - return null; - } - return createWindowTrackerCore(override, targetMpvSocketPath); + return visibleOverlayInteractionRuntime.createOverlayWindowTracker(override, targetMpvSocketPath); } function bindVisibleOverlayOwner(): void { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - if (process.platform === 'linux') { - bindVisibleOverlayToTrackedX11Window(mainWindow); - return; - } - if (process.platform !== 'win32') return; - const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); - const targetSocketPath = appState.mpvSocketPath; - const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath); - if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) { - return; - } - if (targetSocketPath) { - return; - } - const tracker = appState.windowTracker; - const mpvResult = tracker - ? (() => { - try { - const win32 = - require('./window-trackers/win32') as typeof import('./window-trackers/win32'); - const poll = win32.findMpvWindows(); - const focused = poll.matches.find((m) => m.isForeground); - return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null; - } catch { - return null; - } - })() - : null; - if (!mpvResult) return; - if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) { - logger.warn('Failed to set overlay owner via koffi'); - } + visibleOverlayInteractionRuntime.bindVisibleOverlayOwner(); } function releaseVisibleOverlayOwner(): void { - const mainWindow = overlayManager.getMainWindow(); - if (process.platform === 'linux') { - linuxVisibleOverlayOwnerBindingKey = null; - if (mainWindow && !mainWindow.isDestroyed()) { - clearVisibleOverlayX11OwnerBinding(mainWindow); - } - return; - } - if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; - const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); - if (!clearWindowsOverlayOwner(overlayHwnd)) { - logger.warn('Failed to clear overlay owner via koffi'); - } -} - -function startOverlayWindowTrackerForCurrentSocket(): void { - startOverlayWindowTrackerCore({ - backendOverride: appState.backendOverride, - getMpvSocketPath: () => appState.mpvSocketPath, - createWindowTracker: createOverlayWindowTracker, - setWindowTracker: (tracker) => { - appState.windowTracker = tracker; - }, - updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), - isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), - refreshCurrentSubtitle: () => { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - }, - getOverlayWindows: () => getOverlayWindows(), - syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), - bindOverlayOwner: () => bindVisibleOverlayOwner(), - releaseOverlayOwner: () => releaseVisibleOverlayOwner(), - }); + visibleOverlayInteractionRuntime.releaseVisibleOverlayOwner(); } function retargetOverlayWindowTrackerForMpvSocket( nextSocketPath: string, previousSocketPath: string, ): void { - if (nextSocketPath === previousSocketPath || !appState.overlayRuntimeInitialized) { - return; - } - - const previousTracker = appState.windowTracker; - if (previousTracker) { - try { - previousTracker.stop(); - } catch (error) { - logger.warn('Failed to stop previous overlay window tracker before retargeting', error); - } - } - - releaseVisibleOverlayOwner(); - appState.windowTracker = null; - appState.trackerNotReadyWarningShown = false; - lastOverlayWindowGeometry = null; - startOverlayWindowTrackerForCurrentSocket(); - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - overlayShortcutsRuntime.syncOverlayShortcuts(); - logger.info( - `Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`, + visibleOverlayInteractionRuntime.retargetOverlayWindowTrackerForMpvSocket( + nextSocketPath, + previousSocketPath, ); } -async function syncWindowsVisibleOverlayToMpvZOrder(): Promise { - if (process.platform !== 'win32') { - return false; - } - - const mainWindow = overlayManager.getMainWindow(); - if ( - !mainWindow || - mainWindow.isDestroyed() || - !mainWindow.isVisible() || - !overlayManager.getVisibleOverlayVisible() - ) { - return false; - } - - const windowTracker = appState.windowTracker; - if (!windowTracker) { - return false; - } - - if ( - typeof windowTracker.isTargetWindowMinimized === 'function' && - windowTracker.isTargetWindowMinimized() - ) { - return false; - } - - if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) { - return false; - } - - const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); - const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath); - if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) { - (mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1); - return true; - } - return false; -} - function requestWindowsVisibleOverlayZOrderSync(): void { - if (process.platform !== 'win32') { - return; - } - - if (windowsVisibleOverlayZOrderSyncInFlight) { - windowsVisibleOverlayZOrderSyncQueued = true; - return; - } - - windowsVisibleOverlayZOrderSyncInFlight = true; - void syncWindowsVisibleOverlayToMpvZOrder() - .catch((error) => { - logger.warn('Failed to bind Windows overlay z-order to mpv', error); - }) - .finally(() => { - windowsVisibleOverlayZOrderSyncInFlight = false; - if (!windowsVisibleOverlayZOrderSyncQueued) { - return; - } - - windowsVisibleOverlayZOrderSyncQueued = false; - requestWindowsVisibleOverlayZOrderSync(); - }); + visibleOverlayInteractionRuntime.requestWindowsVisibleOverlayZOrderSync(); } function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void { - if (process.platform !== 'win32') { - return; - } - - clearWindowsVisibleOverlayZOrderRetryTimeouts(); - for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) { - const retryTimeout = setTimeout(() => { - windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter( - (timeout) => timeout !== retryTimeout, - ); - requestWindowsVisibleOverlayZOrderSync(); - }, delayMs); - windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout); - } + visibleOverlayInteractionRuntime.scheduleWindowsVisibleOverlayZOrderSyncBurst(); } function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean { - return ( - process.platform === 'win32' && - lastWindowsVisibleOverlayBlurredAtMs > 0 && - Date.now() - lastWindowsVisibleOverlayBlurredAtMs <= - WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS - ); -} - -function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean { - if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) { - return false; - } - - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) { - return false; - } - - const windowTracker = appState.windowTracker; - if (!windowTracker) { - return false; - } - - if ( - typeof windowTracker.isTargetWindowMinimized === 'function' && - windowTracker.isTargetWindowMinimized() - ) { - return false; - } - - const overlayFocused = mainWindow.isFocused(); - const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false; - return !overlayFocused && !trackerFocused; -} - -function maybePollWindowsVisibleOverlayForegroundProcess(): void { - if (!shouldPollWindowsVisibleOverlayForegroundProcess()) { - lastWindowsVisibleOverlayForegroundProcessName = null; - return; - } - - const processName = getWindowsForegroundProcessName(); - const normalizedProcessName = processName?.trim().toLowerCase() ?? null; - const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName; - lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName; - - if (normalizedProcessName !== previousProcessName) { - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - } - if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') { - requestWindowsVisibleOverlayZOrderSync(); - } -} - -function ensureWindowsVisibleOverlayForegroundPollLoop(): void { - if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) { - return; - } - - windowsVisibleOverlayForegroundPollInterval = setInterval(() => { - maybePollWindowsVisibleOverlayForegroundProcess(); - }, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS); + return visibleOverlayInteractionRuntime.hasWindowsVisibleOverlayFocusHandoffGrace(); } function clearWindowsVisibleOverlayForegroundPollLoop(): void { - if (windowsVisibleOverlayForegroundPollInterval === null) { - return; - } - - clearInterval(windowsVisibleOverlayForegroundPollInterval); - windowsVisibleOverlayForegroundPollInterval = null; + visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(); } function scheduleVisibleOverlayBlurRefresh(): void { - if (process.platform !== 'win32' && process.platform !== 'darwin') { - return; - } - - if (process.platform === 'win32') { - lastWindowsVisibleOverlayBlurredAtMs = Date.now(); - } - startMacOSVisibleOverlayForegroundProbe(); - clearVisibleOverlayBlurRefreshTimeouts(); - for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { - const refreshTimeout = setTimeout(() => { - visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter( - (timeout) => timeout !== refreshTimeout, - ); - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - }, delayMs); - visibleOverlayBlurRefreshTimeouts.push(refreshTimeout); - } -} - -ensureWindowsVisibleOverlayForegroundPollLoop(); - -const linuxX11CursorPointReader = createLinuxX11CursorPointReader(); - -function getLinuxOverlayPointerMeasurement() { - const measurement = overlayContentMeasurementStore.getLatestByLayer('visible'); - return mapOverlayMeasurementForPointerInteraction(measurement); -} - -function shouldSuspendLinuxOverlayPointerInteraction(): boolean { - return overlayModalInputState.getModalInputExclusive() || appState.statsOverlayVisible; -} - -function shouldSuppressLinuxOverlayPointerInteraction(): boolean { - return resolveForegroundSuppressionWithGrace({ - hasForegroundSeparateWindow: hasLiveSeparateWindow(getOverlayForegroundSeparateWindows()), - isTrackingMpvWindow: Boolean(appState.windowTracker?.isTracking()), - isMpvWindowFocused: appState.windowTracker?.isTargetWindowFocused?.() !== false, - isOverlayWindowFocused: overlayManager.getMainWindow()?.isFocused() === true, - nowMs: Date.now(), - graceMs: LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS, - state: linuxPointerForegroundSuppressionGrace, - }); -} - -function shouldUseLinuxOverlayInputShape(): boolean { - // Electron's setShape is a *bounding* shape: outside the given rects no pixels are drawn, so - // it clips the visible subtitle (and makes a dragged subtitle vanish behind the shaped - // region). There is no input-only region API on Linux, so selective hit-testing is handled by - // the main-process cursor poll instead. Keep this off to avoid clipping the overlay. - return false; -} - -function hasLinuxVisibleOverlayStartupInputGrace(): boolean { - return ( - process.platform === 'linux' && - linuxVisibleOverlayStartupInputGraceUntilMs > 0 && - Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs - ); -} - -function clearLinuxVisibleOverlayStartupInputGrace(): void { - linuxVisibleOverlayStartupInputGraceUntilMs = 0; + visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(); } function resetLinuxVisibleOverlayStartupInputPrimer(): void { - linuxVisibleOverlayStartupInputPrimed = false; - clearLinuxVisibleOverlayStartupInputGrace(); + visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer(); } function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean { - if (!shouldUseLinuxOverlayInputShape()) { - linuxOverlayInputShapeActive = false; - return false; - } - - const result = applyLinuxOverlayInputShape({ - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getMainWindow: () => overlayManager.getMainWindow(), - getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, - getRendererInteractiveHint: () => linuxOverlayInteractiveHint, - shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, - shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, - }); - linuxOverlayInputShapeActive = result.active; - return result.handled; -} - -function updateLinuxOverlayPointerInteractionActive(active: boolean): void { - visibleOverlayInteractionActive = active; - if ( - process.platform === 'linux' && - applyLinuxOverlayPointerInteractionMousePassthrough({ - active, - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getMainWindow: () => overlayManager.getMainWindow(), - shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, - shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, - updateVisibleOverlayVisibility: () => - overlayVisibilityRuntime.updateVisibleOverlayVisibility(), - }) - ) { - return; - } - - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + return visibleOverlayInteractionRuntime.applyLinuxOverlayInputShapeFromLatestMeasurement(); } function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void { - if (process.platform !== 'linux') return; - if (linuxVisibleOverlayStartupInputPrimed) return; - if (shouldUseLinuxOverlayInputShape()) return; - if ( - !shouldPrimeLinuxOverlayInteractionFromMeasurement({ - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getMainWindow: () => overlayManager.getMainWindow(), - getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, - shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, - shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, - }) - ) { - return; - } - - linuxVisibleOverlayStartupInputPrimed = true; - linuxVisibleOverlayStartupInputGraceUntilMs = - Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS; - updateLinuxOverlayPointerInteractionActive(true); + visibleOverlayInteractionRuntime.primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); } -const linuxOverlayZOrderKeepAliveDeps = { - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getMainWindow: () => overlayManager.getMainWindow(), - isTrackingMpvWindow: () => Boolean(appState.windowTracker?.isTracking()), - isMpvWindowFocused: () => appState.windowTracker?.isTargetWindowFocused?.() !== false, - isOverlayWindowFocused: () => overlayManager.getMainWindow()?.isFocused() === true, - shouldSuppressReassert: () => - overlayModalInputState.getModalInputExclusive() || - appState.statsOverlayVisible || - hasLiveSeparateWindow(getOverlayForegroundSeparateWindows()) || - (visibleOverlayInteractionActive && overlayManager.getMainWindow()?.isFocused() !== true), - raiseMpvWindow: () => { - if ( - lastLinuxVisibleOverlayFollowedMpvAtMs > 0 && - Date.now() - lastLinuxVisibleOverlayFollowedMpvAtMs <= - LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS - ) { - return Promise.resolve(false); - } - lastLinuxVisibleOverlayFollowedMpvAtMs = Date.now(); - return appState.windowTracker?.raiseTargetWindow?.() ?? Promise.resolve(false); - }, - releaseOverlayLayerOrder: () => { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - mainWindow.setAlwaysOnTop(false); - mainWindow.setFullScreen?.(false); - mainWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false }); - if (linuxVisibleOverlayWindowMode === 'fullscreen-override' && mainWindow.isVisible()) { - mainWindow.hide(); - } - }, - enforceOverlayLayerOrder: () => { - enforceOverlayLayerOrder(); - }, - focusOverlayWindow: () => { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isFocused()) return; - mainWindow.focus(); - }, -}; - function requestLinuxOverlayZOrderFollow(): void { - if (!shouldRunLinuxOverlayZOrderKeepAlive()) return; - void tickLinuxOverlayZOrderKeepAlive(linuxOverlayZOrderKeepAliveDeps).catch((error) => { - logger.debug( - 'Failed to follow tracked mpv behind focused overlay:', - error instanceof Error ? error.message : String(error), - ); - }); + visibleOverlayInteractionRuntime.requestLinuxOverlayZOrderFollow(); } -ensureLinuxOverlayZOrderKeepAliveLoop(linuxOverlayZOrderKeepAliveDeps); - -const linuxOverlayPointerInteractionDeps = { - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getMainWindow: () => overlayManager.getMainWindow(), - getCursorScreenPoint: () => - linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()), - getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, - getRendererInteractiveHint: () => - linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(), - shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, - shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, - shouldUseInputShape: shouldUseLinuxOverlayInputShape, - getInteractionActive: () => visibleOverlayInteractionActive, - setInteractionActive: updateLinuxOverlayPointerInteractionActive, -}; - function tickLinuxOverlayPointerInteractionNow(): void { - if (applyLinuxOverlayInputShapeFromLatestMeasurement()) { - return; - } - tickLinuxOverlayPointerInteraction(linuxOverlayPointerInteractionDeps); + visibleOverlayInteractionRuntime.tickLinuxOverlayPointerInteractionNow(); } -ensureLinuxOverlayPointerInteractionLoop(linuxOverlayPointerInteractionDeps); - const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler( { getRuntimeOptionsManager: () => appState.runtimeOptionsManager, @@ -3430,177 +2572,58 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { overlayManager.broadcastToOverlayWindows(channel, ...args); } -function isVisibleOverlayContentReady(): boolean { - const overlayWindow = overlayManager.getMainWindow(); - return Boolean( - overlayManager.getVisibleOverlayVisible() && - overlayWindow && - isOverlayWindowReadyForNotification(overlayWindow), - ); -} - -function getConfiguredStatusNotificationType(): NotificationType { - const configuredType = getResolvedConfig().ankiConnect.behavior.notificationType; - return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady()); -} - -function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean { - if (window.isDestroyed() || !isOverlayWindowContentReady(window)) { - return false; - } - if (window.webContents.isLoading()) { - return false; - } - const currentURL = window.webContents.getURL(); - return currentURL !== '' && currentURL !== 'about:blank'; -} - -const overlayNotificationDelivery = createOverlayNotificationDelivery({ - hasReadyOverlayWindow: () => isVisibleOverlayContentReady(), - send: (payload) => { - broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload); - }, - scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs), - clearFlushRetry: (handle) => clearTimeout(handle as ReturnType), +const overlayNotificationsRuntime = createOverlayNotificationsRuntime({ + getResolvedConfig: () => getResolvedConfig(), + getMainOverlayWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), + showMpvOsd: (message) => showMpvOsd(message), + getMpvClient: () => appState.mpvClient, + getAnkiIntegration: () => appState.ankiIntegration, + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, }); -let overlayLoadingOsdController: ReturnType | null = null; +const { + flushQueuedOverlayNotifications, + openAnkiCardFromNotification, + toggleNotificationHistoryPanel, + showConfiguredPlaybackFeedback, + maybeStartOverlayLoadingOsd, +} = overlayNotificationsRuntime; -function flushQueuedOverlayNotifications(): void { - overlayNotificationDelivery.flush(); -} - -function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void { - overlayNotificationDelivery.send(payload); +// Hoisted wrappers: these names are referenced (directly or via deps object +// literals) during module initialization before this point, so they must stay +// hoisted function declarations that delegate to the runtime lazily. +function getConfiguredStatusNotificationType(): NotificationType { + return overlayNotificationsRuntime.getConfiguredStatusNotificationType(); } function showOverlayNotification(payload: OverlayNotificationPayload): void { - sendOverlayNotificationEvent( - withConfiguredOverlayNotificationPosition(payload, getResolvedConfig()), - ); -} - -function dismissOverlayNotification(id: string): void { - sendOverlayNotificationEvent({ id, dismiss: true }); -} - -async function openAnkiCardFromNotification(noteId: number): Promise { - const activeIntegrationOpen = appState.ankiIntegration?.openNoteInAnki(noteId); - if (activeIntegrationOpen) { - await activeIntegrationOpen; - return; - } - - const resolvedConfig = getResolvedConfig(); - const effectiveAnkiConfig = - appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ?? - resolvedConfig.ankiConnect; - const fallbackClient = new AnkiConnectClient( - effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url, - ); - await fallbackClient.openNoteInBrowser(noteId); -} - -function toggleNotificationHistoryPanel(): void { - broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle); + overlayNotificationsRuntime.showOverlayNotification(payload); } function showConfiguredStatusNotification( message: string, options: ConfiguredStatusNotificationOptions = {}, ): void { - notifyConfiguredStatus( - message, - { - getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType, - isOverlayReady: () => isVisibleOverlayContentReady(), - showOsd: (text) => showMpvOsd(text), - showOverlayNotification, - showDesktopNotification: (title, notificationOptions) => - showDesktopNotification(title, notificationOptions), - }, - options, - ); -} - -function showConfiguredPlaybackFeedback( - message: string, - options: ConfiguredStatusNotificationOptions = {}, -): void { - showConfiguredStatusNotification(message, { - ...getPlaybackFeedbackNotificationOptions(message), - ...options, - delivery: 'feedback', - }); + overlayNotificationsRuntime.showConfiguredStatusNotification(message, options); } function showSubsyncStatusNotification(message: string): void { - const syncing = message.startsWith('Subsync: syncing'); - const failed = message.toLowerCase().includes('failed'); - showConfiguredStatusNotification(message, { - id: 'subsync-status', - title: 'Subsync', - variant: failed ? 'error' : syncing ? 'progress' : 'info', - persistent: syncing, - desktop: !syncing, - }); + overlayNotificationsRuntime.showSubsyncStatusNotification(message); } function showYoutubeFlowStatusNotification(message: string): void { - const progress = - message.startsWith('Downloading subtitles') || - message.startsWith('Loading subtitles') || - message.startsWith('Getting subtitles') || - message === 'Opening YouTube video'; - showConfiguredStatusNotification(message, { - id: 'youtube-subtitles-status', - title: 'YouTube subtitles', - variant: progress ? 'progress' : 'info', - persistent: progress, - desktop: !progress, - }); + overlayNotificationsRuntime.showYoutubeFlowStatusNotification(message); } -function getOverlayLoadingOsdController(): ReturnType { - if (!overlayLoadingOsdController) { - overlayLoadingOsdController = createOverlayLoadingOsdController({ - showOsd: (message) => { - showMpvOsd(message); - }, - clearOsd: () => { - sendMpvCommandRuntime(appState.mpvClient, ['show-text', '', '1']); - }, - setInterval: (callback, delayMs) => { - const timer = setInterval(callback, delayMs); - timer.unref?.(); - return timer; - }, - clearInterval: (timer) => { - clearInterval(timer as ReturnType); - }, - }); - } - return overlayLoadingOsdController; -} - -function showOverlayLoadingStatusNotification(message: string): void { - void message; - getOverlayLoadingOsdController().start(); +function showOverlayLoadingStatusNotification(_message: string): void { + overlayNotificationsRuntime.showOverlayLoadingStatusNotification(); } function dismissOverlayLoadingStatusNotification(): void { - getOverlayLoadingOsdController().stop(); - sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-overlay-loading-ready']); - dismissOverlayNotification('overlay-loading-status'); + overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); } -const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({ - getVisibleOverlayRequested: () => overlayManager.getVisibleOverlayVisible(), - isOverlayContentReady: () => isVisibleOverlayContentReady(), - startOverlayLoadingOsd: () => { - showOverlayLoadingStatusNotification('Overlay loading...'); - }, -}); - const buildBroadcastRuntimeOptionsChangedMainDepsHandler = createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ broadcastRuntimeOptionsChangedRuntime, @@ -4750,149 +3773,6 @@ const { }); registerProtocolUrlHandlersHandler(); -const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); -const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); - -const startLocalStatsServer = (): void => { - const tracker = appState.immersionTracker; - if (!tracker) { - throw new Error('Immersion tracker failed to initialize.'); - } - if (!statsServer) { - const yomitanDeps = { - getYomitanExt: () => appState.yomitanExt, - getYomitanSession: () => appState.yomitanSession, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (w: BrowserWindow | null) => { - appState.yomitanParserWindow = w; - }, - getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, - setYomitanParserReadyPromise: (p: Promise | null) => { - appState.yomitanParserReadyPromise = p; - }, - getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, - setYomitanParserInitPromise: (p: Promise | null) => { - appState.yomitanParserInitPromise = p; - }, - }; - const yomitanLogger = createLogger('main:yomitan-stats'); - statsServer = startStatsServer({ - port: getResolvedConfig().stats.serverPort, - staticDir: statsDistPath, - tracker, - knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), - mpvSocketPath: appState.mpvSocketPath, - getAnkiConnectConfig: () => getResolvedConfig().ankiConnect, - getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime, - getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, - getStatsMiningAlassPath: () => getResolvedConfig().subsync.alass_path, - anilistRateLimiter, - resolveAnkiNoteId: (noteId: number) => - appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, - resolveSentenceSearchHeadwords, - addYomitanNote: async (word: string) => { - const ankiConnectConfig = getResolvedConfig().ankiConnect; - const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765'; - await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { - forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig), - deck: ankiConnectConfig.deck, - }); - const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); - if (result.noteId && result.duplicateNoteIds.length > 0) { - appState.ankiIntegration?.trackDuplicateNoteIdsForNote( - result.noteId, - result.duplicateNoteIds, - ); - } - return result.noteId; - }, - }); - appState.statsServer = statsServer; - } - appState.statsServer = statsServer; -}; - -const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({ - currentPid: process.pid, - readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath), - removeBackgroundState: () => { - removeBackgroundStatsServerState(statsDaemonStatePath); - }, - isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid), - hasLocalStatsServer: () => statsServer !== null, - startLocalStatsServer, - getConfiguredPort: () => getResolvedConfig().stats.serverPort, -}); - -const ensureBackgroundStatsServerStarted = (): { - url: string; - runningInCurrentProcess: boolean; -} => { - const liveDaemon = readLiveBackgroundStatsDaemonState(); - if (liveDaemon && liveDaemon.pid !== process.pid) { - return { - url: resolveBackgroundStatsServerUrl(liveDaemon), - runningInCurrentProcess: false, - }; - } - - appState.statsStartupInProgress = true; - try { - ensureImmersionTrackerStarted(); - } finally { - appState.statsStartupInProgress = false; - } - - const port = getResolvedConfig().stats.serverPort; - const result = ensureStatsServerStarted(); - if (result.source === 'local') { - writeBackgroundStatsServerState(statsDaemonStatePath, { - pid: process.pid, - port, - startedAtMs: Date.now(), - }); - } - return { url: result.url, runningInCurrentProcess: result.source === 'local' }; -}; - -const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => { - const state = readBackgroundStatsServerState(statsDaemonStatePath); - if (!state) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: true }; - } - if (!isBackgroundStatsServerProcessAlive(state.pid)) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: true }; - } - - try { - process.kill(state.pid, 'SIGTERM'); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: true }; - } - if ((error as NodeJS.ErrnoException)?.code === 'EPERM') { - throw new Error( - `Insufficient permissions to stop background stats server (pid ${state.pid}).`, - ); - } - throw error; - } - - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - if (!isBackgroundStatsServerProcessAlive(state.pid)) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: false }; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - throw new Error('Timed out stopping background stats server.'); -}; - const resolveLegacyVocabularyPos = async (row: { headword: string; word: string; @@ -5405,7 +4285,7 @@ const { topX: frequencyDictionary.topX, mode: frequencyDictionary.mode, }; - autoplaySubtitlePrimedMediaPath = null; + autoplaySubtitlePrimingRuntime.resetAutoplaySubtitlePrime(); lastObservedTimePos = 0; appState.currentSubText = ''; appState.currentSubAssText = ''; @@ -5498,7 +4378,8 @@ const { syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) => syncLinuxVisibleOverlayMpvFullscreenMode(nextFullscreen), getOverlayInteractionActive: () => - visibleOverlayInteractionActive || linuxOverlayInputShapeActive, + visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() || + visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), }, cancelLinuxMpvFullscreenOverlayRefreshBurst, @@ -5721,7 +4602,8 @@ signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease( }, getCurrentMediaPath: () => appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, - primeCurrentSubtitle: (mediaPath) => primeCurrentSubtitleForAutoplay(mediaPath), + primeCurrentSubtitle: (mediaPath) => + autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForAutoplay(mediaPath), signalAutoplayReady: () => signalCurrentSubtitleAutoplayReady(), warn: (message, error) => logger.warn(message, error), }); @@ -5782,236 +4664,68 @@ function updateMpvSubtitleRenderMetrics(patch: Partial updateMpvSubtitleRenderMetricsHandler(patch); } -let lastOverlayWindowGeometry: WindowGeometry | null = null; - -function getOverlayGeometryFallback(): WindowGeometry { - const cursorPoint = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPoint); - const bounds = display.workArea; - return { - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - }; -} +const overlayGeometryRuntime = createOverlayGeometryRuntime({ + overlayManager: { + getMainWindow: () => overlayManager.getMainWindow(), + getModalWindow: () => overlayManager.getModalWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + setOverlayWindowBounds: (geometry) => overlayManager.setOverlayWindowBounds(geometry), + setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry), + }, + getTrackedWindowGeometry: () => appState.windowTracker?.getGeometry() ?? null, + getTrackedWindowMediaSourceId: () => appState.windowTracker?.getTargetWindowMediaSourceId?.(), + getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(), + getStatsOverlayVisible: () => appState.statsOverlayVisible, + getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(), + getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode, + getLinuxTrackedMpvFullscreen: () => linuxTrackedMpvFullscreen, + getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxTrackedMpvFullscreenChangedAtMs, + syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) => + syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen), + getLinuxVisibleOverlayOwnerBindingKey: () => linuxVisibleOverlayOwnerBindingKey, + setLinuxVisibleOverlayOwnerBindingKey: (key) => { + linuxVisibleOverlayOwnerBindingKey = key; + }, + clearVisibleOverlayX11OwnerBinding: (window) => clearVisibleOverlayX11OwnerBinding(window), + getNativeWindowHandleDecimal: (window) => getNativeWindowHandleDecimal(window), + enqueueVisibleOverlayX11OwnerBindingOperation: (window, args, onError) => + enqueueVisibleOverlayX11OwnerBindingOperation(window, args, onError), + scheduleWindowsVisibleOverlayZOrderSyncBurst: () => + scheduleWindowsVisibleOverlayZOrderSyncBurst(), + logDebug: (message, ...args) => logger.debug(message, ...args), +}); function getCurrentOverlayGeometry(): WindowGeometry { - if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry; - const trackerGeometry = appState.windowTracker?.getGeometry(); - if (trackerGeometry) return trackerGeometry; - return getOverlayGeometryFallback(); + return overlayGeometryRuntime.getCurrentOverlayGeometry(); } function getCurrentTrackedOverlayGeometry(): WindowGeometry | null { - return appState.windowTracker?.getGeometry() ?? null; + return overlayGeometryRuntime.getCurrentTrackedOverlayGeometry(); } function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean { - if (!a || !b) return false; - return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + return overlayGeometryRuntime.geometryMatches(a, b); } -function applyOverlayRegions(geometry: WindowGeometry): void { - lastOverlayWindowGeometry = geometry; - maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry); - overlayManager.setOverlayWindowBounds(geometry); - overlayManager.setModalWindowBounds(geometry); -} - -function shouldExitLinuxFullscreenOverrideForGeometry(geometry: WindowGeometry): boolean { - if (!shouldRunLinuxOverlayZOrderKeepAlive()) { - return false; - } - if ( - linuxTrackedMpvFullscreenChangedAtMs > 0 && - Date.now() - linuxTrackedMpvFullscreenChangedAtMs < - LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS - ) { - return false; - } - - const displayBounds = screen.getDisplayMatching(geometry).bounds; - return shouldExitFullscreenOverrideForTrackedGeometry({ - currentMode: linuxVisibleOverlayWindowMode, - trackedFullscreen: linuxTrackedMpvFullscreen, - geometry, - displayBounds, - }); -} - -function maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry: WindowGeometry): void { - if (!shouldExitLinuxFullscreenOverrideForGeometry(geometry)) { - return; - } - - logger.debug( - 'Tracked mpv geometry no longer covers its display; exiting Linux fullscreen overlay override', - ); - syncLinuxVisibleOverlayMpvFullscreenMode(false); -} - -function hasHyprlandOverlayWindowPlacementMismatch(geometry: WindowGeometry): boolean { - if (process.platform !== 'linux') { - return false; - } - - return [overlayManager.getMainWindow(), overlayManager.getModalWindow()].some((window) => { - if (!window || window.isDestroyed()) { - return false; - } - return hasHyprlandWindowPlacementBoundsMismatch({ - title: window.getTitle(), - bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window), - }); - }); -} - -const buildUpdateVisibleOverlayBoundsMainDepsHandler = - createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ - getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry, - shouldRefreshUnchangedGeometry: (geometry) => - shouldExitLinuxFullscreenOverrideForGeometry(geometry) || - (process.platform === 'linux' && - (hasLiveOverlayWindowBoundsMismatch( - [overlayManager.getMainWindow(), overlayManager.getModalWindow()], - geometry, - ) || - hasHyprlandOverlayWindowPlacementMismatch(geometry))), - setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), - afterSetOverlayWindowBounds: () => { - if (!overlayManager.getVisibleOverlayVisible()) { - return; - } - if (process.platform === 'win32') { - scheduleWindowsVisibleOverlayZOrderSyncBurst(); - return; - } - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) { - return; - } - if (process.platform === 'linux') { - restoreLinuxOverlayWindowShape(mainWindow); - } - ensureOverlayWindowLevel(mainWindow); - }, - }); -const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); -const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( - updateVisibleOverlayBoundsMainDeps, -); - -const buildEnsureOverlayWindowLevelMainDepsHandler = - createBuildEnsureOverlayWindowLevelMainDepsHandler({ - shouldSuppressOverlayWindowLevel: (window) => { - const mainWindow = overlayManager.getMainWindow(); - return ( - (appState.statsOverlayVisible && window === mainWindow) || - shouldSuppressVisibleOverlayRaiseForSeparateWindow({ - window, - mainWindow, - separateWindows: getOverlayForegroundSeparateWindows(), - }) - ); - }, - ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), - afterEnsureOverlayWindowLevel: () => { - const mainWindow = overlayManager.getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow); - } - promoteStatsOverlayAbovePlayback(); - }, - }); -const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler(); -const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler( - ensureOverlayWindowLevelMainDeps, -); - function syncPrimaryOverlayWindowLayer(layer: 'visible'): void { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - syncOverlayWindowLayer(mainWindow, layer); -} - -function moveVisibleOverlayAboveTrackedPlaybackWindow(window: BrowserWindow): void { - if (process.platform !== 'linux') return; - if (window !== overlayManager.getMainWindow()) return; - - bindVisibleOverlayToTrackedX11Window(window); - - const mediaSourceId = appState.windowTracker?.getTargetWindowMediaSourceId?.(); - if (!mediaSourceId) return; - - try { - window.moveAbove(mediaSourceId); - } catch (error) { - logger.debug( - 'Failed to move visible overlay above tracked playback window:', - error instanceof Error ? error.message : String(error), - ); - } + overlayGeometryRuntime.syncPrimaryOverlayWindowLayer(layer); } function bindVisibleOverlayToTrackedX11Window(window: BrowserWindow): void { - const targetWindowId = appState.windowTracker?.getTargetWindowNativeId?.(); - if (!targetWindowId) { - if (linuxVisibleOverlayOwnerBindingKey !== null) { - clearVisibleOverlayX11OwnerBinding(window); - } - linuxVisibleOverlayOwnerBindingKey = null; - return; - } - - const overlayWindowId = getNativeWindowHandleDecimal(window); - const bindingKey = `${overlayWindowId}:${targetWindowId}`; - if (linuxVisibleOverlayOwnerBindingKey === bindingKey) { - return; - } - linuxVisibleOverlayOwnerBindingKey = bindingKey; - - enqueueVisibleOverlayX11OwnerBindingOperation( - window, - [ - '-id', - overlayWindowId, - '-f', - 'WM_TRANSIENT_FOR', - '32x', - '-set', - 'WM_TRANSIENT_FOR', - targetWindowId, - ], - (error) => { - if (linuxVisibleOverlayOwnerBindingKey === bindingKey) { - linuxVisibleOverlayOwnerBindingKey = null; - } - logger.debug( - 'Failed to bind visible overlay as transient for tracked X11 playback window:', - error instanceof Error ? error.message : String(error), - ); - }, - ); + overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window); } -const buildEnforceOverlayLayerOrderMainDepsHandler = - createBuildEnforceOverlayLayerOrderMainDepsHandler({ - enforceOverlayLayerOrderCore: (params) => - enforceOverlayLayerOrderCore({ - visibleOverlayVisible: params.visibleOverlayVisible, - mainWindow: params.mainWindow as BrowserWindow | null, - ensureOverlayWindowLevel: (window) => - params.ensureOverlayWindowLevel(window as BrowserWindow), - }), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getMainWindow: () => overlayManager.getMainWindow(), - ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow), - }); -const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler(); -const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( - enforceOverlayLayerOrderMainDeps, -); +function updateVisibleOverlayBounds(geometry: WindowGeometry): void { + overlayGeometryRuntime.updateVisibleOverlayBounds(geometry); +} + +function ensureOverlayWindowLevel(window: unknown): void { + overlayGeometryRuntime.ensureOverlayWindowLevel(window); +} + +function enforceOverlayLayerOrder(): void { + overlayGeometryRuntime.enforceOverlayLayerOrder(); +} async function loadYomitanExtension(): Promise { const extension = await yomitanExtensionRuntime.loadYomitanExtension(); @@ -6029,11 +4743,17 @@ async function ensureYomitanExtensionLoaded(): Promise { return extension; } -let lastSyncedYomitanAnkiSettingsKey: string | null = null; - -function getPreferredYomitanAnkiServerUrl(): string { - return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect); -} +const { syncYomitanDefaultProfileAnkiServer } = createYomitanAnkiServerSyncRuntime({ + isExternalReadOnlyMode: () => yomitanProfilePolicy.isExternalReadOnlyMode(), + getResolvedConfig: () => getResolvedConfig(), + getYomitanParserRuntimeDeps: () => getYomitanParserRuntimeDeps(), + logError: (message, ...args) => { + logger.error(message, ...args); + }, + logInfo: (message, ...args) => { + logger.info(message, ...args); + }, +}); function getYomitanParserRuntimeDeps() { return { @@ -6054,43 +4774,6 @@ function getYomitanParserRuntimeDeps() { }; } -async function syncYomitanDefaultProfileAnkiServer(): Promise { - if (yomitanProfilePolicy.isExternalReadOnlyMode()) { - return; - } - - const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); - const ankiConnectConfig = getResolvedConfig().ankiConnect; - const targetDeck = ankiConnectConfig?.deck?.trim() ?? ''; - const targetSettingsKey = `${targetUrl}\n${targetDeck}`; - if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) { - return; - } - - const synced = await syncYomitanDefaultAnkiServerCore( - targetUrl, - getYomitanParserRuntimeDeps(), - { - error: (message, ...args) => { - logger.error(message, ...args); - }, - info: (message, ...args) => { - logger.info(message, ...args); - }, - }, - { - forceOverride: ankiConnectConfig - ? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig) - : false, - deck: targetDeck, - }, - ); - - if (synced) { - lastSyncedYomitanAnkiSettingsKey = targetSettingsKey; - } -} - function createModalWindow(): BrowserWindow { const existingWindow = overlayManager.getModalWindow(); if (existingWindow && !existingWindow.isDestroyed()) { @@ -6222,52 +4905,11 @@ function openYomitanSettings(): boolean { return true; } -function describeUnknownError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -async function exportLogsFromTray(): Promise { - try { - await flushMpvLog(); - } catch (error) { - logger.warn('Failed to flush mpv log before exporting logs from tray.', error); - } - - try { - const result = exportLogsArchive({ - platform: process.platform, - homeDir: os.homedir(), - appDataDir: app.getPath('appData'), - }); - logger.info( - `Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`, - ); - void dialog - .showMessageBox({ - type: 'info', - title: 'SubMiner logs exported', - message: 'SubMiner log export created.', - detail: result.zipPath, - buttons: ['OK', 'Show in Folder'], - defaultId: 0, - cancelId: 0, - }) - .then((response) => { - if (response.response === 1) { - shell.showItemInFolder(result.zipPath); - } - }); - } catch (error) { - const message = describeUnknownError(error); - logger.warn('Failed to export logs from tray.', error); - void dialog.showMessageBox({ - type: 'error', - title: 'SubMiner log export failed', - message: 'Could not export SubMiner logs.', - detail: message, - }); - } -} +const { exportLogsFromTray } = createLogExportTrayRuntime({ + flushMpvLog: () => flushMpvLog(), + logInfo: (message) => logger.info(message), + logWarn: (message, details) => logger.warn(message, details), +}); const { getConfiguredShortcuts, @@ -6329,53 +4971,20 @@ const { }, }); -function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' { - if (process.platform === 'darwin') return 'darwin'; - if (process.platform === 'win32') return 'win32'; - return 'linux'; -} - -function compileCurrentSessionBindings(): { - bindings: CompiledSessionBinding[]; - warnings: ReturnType['warnings']; -} { - return compileSessionBindings({ - keybindings: appState.keybindings, - shortcuts: getConfiguredShortcuts(), - statsToggleKey: getResolvedConfig().stats.toggleKey, - statsMarkWatchedKey: getResolvedConfig().stats.markWatchedKey, - platform: resolveSessionBindingPlatform(), - rawConfig: getResolvedConfig(), - }); -} - -function persistSessionBindings( - bindings: CompiledSessionBinding[], - warnings: ReturnType['warnings'] = [], -): void { - const artifact = buildPluginSessionBindingsArtifact({ - bindings, - warnings, - numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs, - }); - writeSessionBindingsArtifact(CONFIG_DIR, artifact); - appState.sessionBindings = bindings; - appState.sessionBindingsInitialized = true; - if (appState.mpvClient?.connected) { - sendMpvCommandRuntime(appState.mpvClient, [ - 'script-message', - 'subminer-reload-session-bindings', - ]); - } -} - -function refreshCurrentSessionBindings(): void { - const compiled = compileCurrentSessionBindings(); - for (const warning of compiled.warnings) { - logger.warn(`[session-bindings] ${warning.message}`); - } - persistSessionBindings(compiled.bindings, compiled.warnings); -} +const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({ + configDir: CONFIG_DIR, + getKeybindings: () => appState.keybindings, + getConfiguredShortcuts: () => getConfiguredShortcuts(), + getResolvedConfig: () => getResolvedConfig(), + getMpvClient: () => appState.mpvClient, + setSessionBindings: (bindings) => { + appState.sessionBindings = bindings; + }, + setSessionBindingsInitialized: (initialized) => { + appState.sessionBindingsInitialized = initialized; + }, + logWarn: (message) => logger.warn(message), +}); const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ appendToMpvLogMainDeps: { @@ -6401,153 +5010,19 @@ flushPendingMpvLogWrites = () => { void flushMpvLog(); }; -const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json')); -let updateService: ReturnType | null = null; -const globalFetchForUpdater = createGlobalFetch(); -const curlFetch = createCurlFetch(); - -function createNativeUpdaterHttpExecutor() { - if (process.platform === 'win32') { - return createFetchHttpExecutor(); - } - return createCurlHttpExecutor(); -} - -function getFetchForUpdater() { - if (process.platform === 'win32') return globalFetchForUpdater; - return curlFetch; -} - -async function updateLauncherFromSelectedRelease( - launcherPath?: string, - channel: UpdateChannel = getResolvedConfig().updates.channel, - release: GitHubRelease | null = null, -) { - const fetchForUpdater = getFetchForUpdater(); - if (!release) { - return { status: 'missing-asset', message: `No ${channel} GitHub release found.` }; - } - const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt'); - if (!sumsAsset) { - return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' }; - } - const sums = parseSha256Sums( - await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url), - ); - const launcherResult = await updateLauncherFromRelease({ - release, - sha256Sums: sums, - launcherPath, - downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url), - }); - const supportResults = await updateSupportAssetsFromRelease({ - release, - sha256Sums: sums, - downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url), - }); - for (const result of supportResults) { - if (result.status === 'protected' && result.command) { - logger.warn(`Rofi theme update requires manual command: ${result.command}`); - } else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') { - logger.warn(`Rofi theme update skipped: ${result.message ?? result.status}`); - } - } - return launcherResult; -} - -function getUpdateService() { - if (updateService) return updateService; - const appUpdater = createElectronAppUpdater({ - currentVersion: app.getVersion(), - isPackaged: app.isPackaged, - log: (message) => logger.info(message), - getChannel: () => getResolvedConfig().updates.channel, - configureHttpExecutor: createNativeUpdaterHttpExecutor, - disableDifferentialDownload: true, - isNativeUpdaterSupported: () => - isNativeUpdaterSupported({ - platform: process.platform, - isPackaged: app.isPackaged, - execPath: process.execPath, - env: process.env, - log: (message) => logger.warn(message), - }), - }); - const updateDialogPresenter = createUpdateDialogPresenter({ - platform: process.platform, - focusApp: async () => { - if (process.platform !== 'darwin') { - app.focus({ steal: true }); - return; - } - try { - await app.dock?.show(); - } catch (error) { - logger.warn('Failed to show macOS dock before update dialog', error); - } - // app.focus({ steal: true }) alone does not reliably activate the process - // when SubMiner was reached via `subminer -u` (single-instance forwarding - // from a CLI-spawned child). osascript's `activate` uses LaunchServices, - // which is the only path that reliably brings the running app forward. - await new Promise((resolve) => { - execFile( - '/usr/bin/osascript', - ['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`], - { timeout: 2000 }, - (error) => { - if (error) { - logger.warn( - `Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`, - ); - } - resolve(); - }, - ); - }); - app.focus({ steal: true }); - }, - withStatsWindowLayerSuspended: (showDialog) => - withStatsWindowLayerSuspendedForNativeDialog(showDialog), - showMessageBox: (options) => dialog.showMessageBox(options), - }); - updateService = createUpdateService({ - getConfig: () => getResolvedConfig().updates, - getCurrentVersion: () => app.getVersion(), - now: () => Date.now(), - readState: () => updateStateStore.readState(), - writeState: (state) => updateStateStore.writeState(state), - checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel), - shouldFetchReleaseMetadata: ({ request, appUpdate }) => - shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request), - fetchLatestStableRelease: (channel) => - fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }), - updateLauncher: (launcherPath, channel, release) => - updateLauncherFromSelectedRelease(launcherPath, channel, release), - showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version), - showUpdateAvailableDialog: (version) => - updateDialogPresenter.showUpdateAvailableDialog(version), - showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message), - showManualUpdateRequiredDialog: (version) => - updateDialogPresenter.showManualUpdateRequiredDialog(version), - downloadAppUpdate: () => appUpdater.downloadUpdate(), - showRestartDialog: () => updateDialogPresenter.showRestartDialog(), - quitAndInstall: () => appUpdater.quitAndInstall(), - notifyUpdateAvailable: (version) => - notifyUpdateAvailable( - { notificationType: getResolvedConfig().updates.notificationType, version }, - { - showSystemNotification: (title, body) => showDesktopNotification(title, { body }), - showOverlayNotification, - showOsdNotification: (message) => { - showMpvOsd(message); - }, - log: (message) => logger.warn(message), - }, - ), - log: (message) => logger.warn(message), - }); - return updateService; -} +const { getUpdateService } = createUpdateServiceRuntime({ + userDataPath: USER_DATA_PATH, + getUpdatesConfig: () => getResolvedConfig().updates, + logInfo: (message) => logger.info(message), + logWarn: (message, details) => logger.warn(message, details), + showOverlayNotification, + showDesktopNotification: (title, options) => showDesktopNotification(title, options), + showMpvOsd: (message) => { + showMpvOsd(message); + }, + withStatsWindowLayerSuspendedForNativeDialog: (showDialog) => + withStatsWindowLayerSuspendedForNativeDialog(showDialog), +}); const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ cycleSecondarySubModeMainDeps: { @@ -6725,118 +5200,6 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand appendClipboardVideoToQueueMainDeps, ); -async function loadSubtitleSourceText(source: string): Promise { - if (/^https?:\/\//i.test(source)) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 4000); - try { - const response = await fetch(source, { signal: controller.signal }); - if (!response.ok) { - throw new Error(`Failed to download subtitle source (${response.status})`); - } - return await response.text(); - } finally { - clearTimeout(timeoutId); - } - } - - const filePath = resolveSubtitleSourcePath(source); - return fs.promises.readFile(filePath, 'utf8'); -} - -type MpvSubtitleTrackLike = { - type?: unknown; - id?: unknown; - selected?: unknown; - external?: unknown; - codec?: unknown; - 'ff-index'?: unknown; - 'external-filename'?: unknown; -}; - -function parseTrackId(value: unknown): number | null { - if (typeof value === 'number' && Number.isInteger(value)) { - return value; - } - if (typeof value === 'string') { - const parsed = Number(value.trim()); - return Number.isInteger(parsed) ? parsed : null; - } - return null; -} - -function buildFfmpegSubtitleExtractionArgs( - videoPath: string, - ffIndex: number, - outputPath: string, -): string[] { - return [ - '-hide_banner', - '-nostdin', - '-y', - '-loglevel', - 'error', - '-an', - '-vn', - '-i', - videoPath, - '-map', - `0:${ffIndex}`, - '-f', - path.extname(outputPath).slice(1), - outputPath, - ]; -} - -async function extractInternalSubtitleTrackToTempFile( - ffmpegPath: string, - videoPath: string, - track: MpvSubtitleTrackLike, -): Promise<{ path: string; cleanup: () => Promise } | null> { - const ffIndex = parseTrackId(track['ff-index']); - const codec = typeof track.codec === 'string' ? track.codec : null; - const extension = codecToExtension(codec ?? undefined); - if (ffIndex === null || extension === null) { - return null; - } - - const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-')); - const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); - - try { - await new Promise((resolve, reject) => { - const child = spawn( - ffmpegPath, - buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath), - ); - let stderr = ''; - child.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on('error', (error) => { - reject(error); - }); - child.on('close', (code) => { - if (code === 0) { - resolve(); - return; - } - reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`)); - }); - }); - } catch (error) { - await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); - throw error; - } - - return { - path: outputPath, - cleanup: async () => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - }, - }; -} - async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise { await dispatchSessionActionCore(request, { toggleStatsOverlay: () => @@ -6989,13 +5352,13 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ if (!mainWindow || senderWindow !== mainWindow) { return; } - if (visibleOverlayInteractionActive === active) { + if (visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() === active) { if (active && process.platform === 'darwin' && !mainWindow.isFocused()) { overlayVisibilityRuntime.updateVisibleOverlayVisibility(); } return; } - visibleOverlayInteractionActive = active; + visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active); overlayVisibilityRuntime.updateVisibleOverlayVisibility(); }, onOverlayInteractiveHint: (interactive, senderWindow) => { @@ -7003,7 +5366,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ if (!mainWindow || senderWindow !== mainWindow) { return; } - linuxOverlayInteractiveHint = interactive; + visibleOverlayInteractionRuntime.setLinuxOverlayInteractiveHint(interactive); applyLinuxOverlayInputShapeFromLatestMeasurement(); }, handleOverlayNotificationAction: (notificationId, actionId, noteId) => { diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 12bbd9ed..21d7a9a9 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -7,6 +7,10 @@ function readMainSource(): string { return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8'); } +function readSource(relPath: string): string { + return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8'); +} + test('manual watched session action starts immersion tracker before marking watched', () => { const source = readMainSource(); const actionBlock = source.match( @@ -91,15 +95,15 @@ test('mpv startup signals start overlay loading OSD before readiness work', () = }); test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => { - const source = readMainSource(); + const source = readSource('src/main/runtime/overlay-notifications-runtime.ts'); const dismissBlock = source.match( - /function dismissOverlayLoadingStatusNotification\(\): void \{(?[\s\S]*?)\n\}/, + /function dismissOverlayLoadingStatusNotification\(\): void \{(?[\s\S]*?)\n \}/, )?.groups?.body; assert.ok(dismissBlock); assert.match( dismissBlock, - /sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/, + /sendMpvCommandRuntime\(deps\.getMpvClient\(\), \[\s*'script-message',\s*'subminer-overlay-loading-ready',\s*\]\);/, ); }); @@ -146,9 +150,9 @@ test('all visible overlay hide paths clear stale overlay input state', () => { }); test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => { - const source = readMainSource(); + const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts'); const actionBlock = source.match( - /async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise \{(?[\s\S]*?)\n\}/, + /async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise \{(?[\s\S]*?)\n \}/, )?.groups?.body; assert.ok(actionBlock); @@ -157,13 +161,14 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () = /const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/, ); assert.ok( - actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') < - actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'), + actionBlock.indexOf('deps.initSubtitlePrefetch(') < + actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'), ); }); test('update overlay notification action triggers install flow', () => { const source = readMainSource(); + const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts'); assert.match( source, @@ -173,13 +178,16 @@ test('update overlay notification action triggers install flow', () => { assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/); assert.match(source, /installWhenAvailable:\s*true/); assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/); - assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/); - assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/); + assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/); assert.match( - source, + runtimeSource, + /deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/, + ); + assert.match( + runtimeSource, /new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/, ); - assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/); + assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/); }); test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => { @@ -203,9 +211,9 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni }); test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => { - const source = readMainSource(); + const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts'); const actionBlock = source.match( - /function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?[\s\S]*?)\n\}/, + /function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?[\s\S]*?)\n \}/, )?.groups?.body; assert.ok(actionBlock); @@ -346,18 +354,18 @@ test('warm tokenization release can signal readiness before the first subtitle a }); test('stats server Yomitan note creation honors configured Anki server override policy', () => { - const source = readMainSource(); + const source = readSource('src/main/runtime/stats-server-runtime.ts'); const startStatsServerBlock = source.match( - /statsServer = startStatsServer\(\{(?[\s\S]*?)\n \}\);/, + /statsServer = startStatsServer\(\{(?[\s\S]*?)\n \}\);/, )?.groups?.body; const addYomitanNoteBlock = startStatsServerBlock?.match( - /addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + /addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?[\s\S]*?)\n \},/, )?.groups?.body; assert.ok(addYomitanNoteBlock); assert.match( addYomitanNoteBlock, - /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/, + /const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/, ); assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/); assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/); @@ -365,11 +373,12 @@ test('stats server Yomitan note creation honors configured Anki server override test('Linux visible overlay recreation clears stale input state before creating replacement window', () => { const source = readMainSource(); + const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts'); const actionBlock = source.match( /function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?[\s\S]*?)\n\}/, )?.groups?.body; - const resetBlock = source.match( - /function resetVisibleOverlayInputState\(\): void \{(?[\s\S]*?)\n\}/, + const resetBlock = runtimeSource.match( + /function resetVisibleOverlayInputState\(\): void \{(?[\s\S]*?)\n \}/, )?.groups?.body; assert.ok(actionBlock); @@ -459,17 +468,17 @@ test('manual visible overlay hide dismisses loading OSD', () => { }); test('configured overlay notifications require visible ready overlay window', () => { - const source = readMainSource(); + const source = readSource('src/main/runtime/overlay-notifications-runtime.ts'); const readinessBlock = source.match( - /function isVisibleOverlayContentReady\(\): boolean \{(?[\s\S]*?)\n\}/, + /function isVisibleOverlayContentReady\(\): boolean \{(?[\s\S]*?)\n \}/, )?.groups?.body; const statusBlock = source.match( - /function showConfiguredStatusNotification\([\s\S]*?\): void \{(?[\s\S]*?)\n\}/, + /function showConfiguredStatusNotification\([\s\S]*?\): void \{(?[\s\S]*?)\n \}/, )?.groups?.body; assert.ok(readinessBlock); assert.ok(statusBlock); - assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/); + assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/); assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/); assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/); assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/); @@ -498,8 +507,9 @@ test('manual visible overlay show primes current subtitle from mpv before relyin test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => { const source = readMainSource(); - const resetBlock = source.match( - /function resetVisibleOverlayInputState\(\): void \{(?[\s\S]*?)\n\}/, + const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts'); + const resetBlock = runtimeSource.match( + /function resetVisibleOverlayInputState\(\): void \{(?[\s\S]*?)\n \}/, )?.groups?.body; const setBlock = source.match( /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, @@ -509,6 +519,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape' assert.ok(setBlock); assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/); assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); + assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); assert.match( setBlock, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, @@ -516,9 +527,9 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape' }); test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => { - const source = readMainSource(); + const source = readSource('src/main/runtime/overlay-geometry-runtime.ts'); const afterBoundsBlock = source.match( - /afterSetOverlayWindowBounds:\s*\(\) => \{(?[\s\S]*?)\n \},/, + /afterSetOverlayWindowBounds:\s*\(\) => \{(?[\s\S]*?)\n \},/, )?.groups?.body; assert.ok(afterBoundsBlock); diff --git a/src/main/password-store-args.test.ts b/src/main/password-store-args.test.ts new file mode 100644 index 00000000..f8fecfc1 --- /dev/null +++ b/src/main/password-store-args.test.ts @@ -0,0 +1,7 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getPasswordStoreArg } from './password-store-args'; + +test('getPasswordStoreArg ignores split-form whitespace-only values', () => { + assert.equal(getPasswordStoreArg(['SubMiner.AppImage', '--password-store', ' ']), null); +}); diff --git a/src/main/password-store-args.ts b/src/main/password-store-args.ts new file mode 100644 index 00000000..be9ec2fd --- /dev/null +++ b/src/main/password-store-args.ts @@ -0,0 +1,40 @@ +const PASSWORD_STORE_ARG = '--password-store'; +const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret'; + +export function getPasswordStoreArg(argv: string[]): string | null { + let resolved: string | null = null; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg?.startsWith(PASSWORD_STORE_ARG)) { + continue; + } + + if (arg === PASSWORD_STORE_ARG) { + const value = argv[i + 1]; + const trimmed = value?.trim(); + if (trimmed && !trimmed.startsWith('--')) { + resolved = trimmed; + i += 1; + } + continue; + } + + const [prefix, value] = arg.split('=', 2); + if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) { + resolved = value.trim(); + } + } + return resolved; +} + +export function normalizePasswordStoreArg(value: string): string { + const normalized = value.trim(); + if (normalized.toLowerCase() === 'gnome') { + return DEFAULT_LINUX_PASSWORD_STORE; + } + return normalized; +} + +export function getDefaultPasswordStore(): string { + return DEFAULT_LINUX_PASSWORD_STORE; +} diff --git a/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts b/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts new file mode 100644 index 00000000..7b39309c --- /dev/null +++ b/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createAutoplaySubtitlePrimingRuntime, + setMpvCurrentSecondarySubText, +} from './autoplay-subtitle-priming-runtime'; + +test('setMpvCurrentSecondarySubText uses client setter when available', () => { + const calls: string[] = []; + const client = { + currentSecondarySubText: '', + setCurrentSecondarySubText: (text: string) => { + calls.push(text); + }, + }; + + setMpvCurrentSecondarySubText(client, 'secondary'); + + assert.deepEqual(calls, ['secondary']); + assert.equal(client.currentSecondarySubText, ''); +}); + +test('setMpvCurrentSecondarySubText updates client property when setter is unavailable', () => { + const client = { + currentSecondarySubText: '', + }; + + setMpvCurrentSecondarySubText(client, 'secondary'); + + assert.equal(client.currentSecondarySubText, 'secondary'); +}); + +test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback', async () => { + const logs: string[] = []; + const runtime = createAutoplaySubtitlePrimingRuntime({ + getCurrentMediaPath: () => null, + getMpvClient: () => null, + setCurrentSubText: () => {}, + getCurrentSubText: () => '', + getCurrentSubtitleData: () => null, + setActiveParsedSubtitleMediaPath: () => {}, + subtitleProcessingController: { + consumeCachedSubtitle: () => null, + onSubtitleChange: () => {}, + refreshCurrentSubtitle: () => {}, + }, + emitSubtitlePayload: () => {}, + getSubtitlePrefetchService: () => null, + getLastObservedTimePos: () => 0, + getVisibleOverlayVisible: () => false, + emitSecondarySubtitle: () => {}, + initSubtitlePrefetch: async () => {}, + refreshSubtitlePrefetchFromActiveTrack: async () => { + throw new Error('refresh failed'); + }, + logDebug: (message) => logs.push(message), + }); + + runtime.scheduleSubtitlePrefetchRefresh(0); + await new Promise((resolve) => setTimeout(resolve, 5)); + + assert.deepEqual(logs, [ + '[autoplay-subtitle-prime] subtitle prefetch refresh failed: refresh failed', + ]); +}); diff --git a/src/main/runtime/autoplay-subtitle-priming-runtime.ts b/src/main/runtime/autoplay-subtitle-priming-runtime.ts new file mode 100644 index 00000000..2a2ecc2b --- /dev/null +++ b/src/main/runtime/autoplay-subtitle-priming-runtime.ts @@ -0,0 +1,278 @@ +import type { SubtitleCue, SubtitleData } from '../../types'; +import { selectAutoplayStartupCue } from './autoplay-subtitle-primer'; +import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot'; +import { resolveSubtitleSourcePath } from './subtitle-prefetch-source'; + +const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2; +const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100; + +type AutoplaySubtitlePrimingMpvClient = { + connected?: boolean; + requestProperty: (name: string) => Promise; + currentVideoPath?: string; + currentTimePos?: number; + currentSecondarySubText?: string; + setCurrentSecondarySubText?: (text: string) => void; +}; + +type AutoplaySubtitlePrimingPrefetchService = { + pause: () => void; + onSeek: (timePos: number) => void; +}; + +export interface AutoplaySubtitlePrimingRuntimeDeps { + getCurrentMediaPath: () => string | null | undefined; + getMpvClient: () => AutoplaySubtitlePrimingMpvClient | null; + setCurrentSubText: (text: string) => void; + getCurrentSubText: () => string; + getCurrentSubtitleData: () => SubtitleData | null; + setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void; + subtitleProcessingController: { + consumeCachedSubtitle: (text: string) => SubtitleData | null; + onSubtitleChange: (text: string) => void; + refreshCurrentSubtitle: (text: string) => void; + }; + emitSubtitlePayload: (payload: SubtitleData) => void; + getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null; + getLastObservedTimePos: () => number; + getVisibleOverlayVisible: () => boolean; + emitSecondarySubtitle: (text: string) => void; + initSubtitlePrefetch: ( + sourcePath: string, + currentTimePos: number, + sourceKey?: string, + ) => Promise; + refreshSubtitlePrefetchFromActiveTrack: () => Promise; + logDebug: (message: string) => void; +} + +export function setMpvCurrentSecondarySubText( + client: Pick< + AutoplaySubtitlePrimingMpvClient, + 'currentSecondarySubText' | 'setCurrentSecondarySubText' + >, + text: string, +): void { + if (typeof client.setCurrentSecondarySubText === 'function') { + client.setCurrentSecondarySubText(text); + return; + } + client.currentSecondarySubText = text; +} + +export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) { + const { subtitleProcessingController, emitSubtitlePayload } = deps; + + let subtitlePrefetchRefreshTimer: ReturnType | null = null; + let autoplaySubtitlePrimedMediaPath: string | null = null; + let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType | null = + null; + + function getCurrentAutoplayMediaPath(): string | null { + return ( + deps.getCurrentMediaPath()?.trim() || deps.getMpvClient()?.currentVideoPath?.trim() || null + ); + } + + function isCurrentAutoplayMediaPath(mediaPath: string): boolean { + return getCurrentAutoplayMediaPath() === mediaPath; + } + + function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean { + if (autoplaySubtitlePrimedMediaPath === mediaPath) { + return false; + } + autoplaySubtitlePrimedMediaPath = mediaPath; + return true; + } + + function resetAutoplaySubtitlePrime(): void { + autoplaySubtitlePrimedMediaPath = null; + } + + function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean { + if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) { + return false; + } + if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) { + return false; + } + + deps.setCurrentSubText(text); + deps.getSubtitlePrefetchService()?.pause(); + const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text); + if (cachedPayload) { + subtitleProcessingController.onSubtitleChange(text); + emitSubtitlePayload(cachedPayload); + return true; + } + + subtitleProcessingController.onSubtitleChange(text); + return true; + } + + async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise { + const client = deps.getMpvClient(); + if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) { + return; + } + + const subTextRaw = await client.requestProperty('sub-text').catch((error) => { + deps.logDebug( + `[autoplay-subtitle-prime] failed to read sub-text: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return null; + }); + const text = typeof subTextRaw === 'string' ? subTextRaw : ''; + emitAutoplayPrimedSubtitle(mediaPath, text); + } + + async function primeCurrentSubtitleForVisibleOverlay(): Promise { + await primeVisibleOverlaySubtitleFromMpv({ + getMpvClient: () => deps.getMpvClient(), + setCurrentSubText: (text) => { + deps.setCurrentSubText(text); + }, + getCurrentSubtitleData: () => deps.getCurrentSubtitleData(), + consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text), + onSubtitleChange: (text) => { + deps.getSubtitlePrefetchService()?.pause(); + deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos()); + subtitleProcessingController.onSubtitleChange(text); + }, + refreshCurrentSubtitle: (text) => { + deps.getSubtitlePrefetchService()?.pause(); + deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos()); + subtitleProcessingController.refreshCurrentSubtitle(text); + }, + deferUncachedRefresh: true, + emitSubtitle: (payload) => emitSubtitlePayload(payload), + setCurrentSecondarySubText: (text) => { + const client = deps.getMpvClient(); + if (client) { + setMpvCurrentSecondarySubText(client, text); + } + }, + emitSecondarySubtitle: (text) => { + deps.emitSecondarySubtitle(text); + }, + logDebug: (message) => { + deps.logDebug(message); + }, + }); + } + + function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { + if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { + return; + } + clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer); + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; + } + + function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { + if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { + return; + } + if (!deps.getVisibleOverlayVisible() || !deps.getCurrentSubText().trim()) { + return; + } + + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => { + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; + if (!deps.getVisibleOverlayVisible()) { + return; + } + const text = deps.getCurrentSubText(); + if (!text.trim()) { + return; + } + deps.getSubtitlePrefetchService()?.pause(); + deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos()); + subtitleProcessingController.refreshCurrentSubtitle(text); + }, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS); + visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.(); + } + + async function primeAutoplaySubtitleFromParsedCues( + mediaPath: string, + cues: SubtitleCue[], + ): Promise { + if ( + cues.length === 0 || + autoplaySubtitlePrimedMediaPath === mediaPath || + !isCurrentAutoplayMediaPath(mediaPath) + ) { + return; + } + + const client = deps.getMpvClient(); + const timePosRaw = await client?.requestProperty('time-pos').catch(() => null); + const currentTimeSeconds = Number( + timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0, + ); + const cue = selectAutoplayStartupCue( + cues, + Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0, + AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS, + ); + if (!cue) { + return; + } + + emitAutoplayPrimedSubtitle(mediaPath, cue.text); + } + + function clearScheduledSubtitlePrefetchRefresh(): void { + if (subtitlePrefetchRefreshTimer) { + clearTimeout(subtitlePrefetchRefreshTimer); + subtitlePrefetchRefreshTimer = null; + } + } + + async function refreshSubtitleSidebarFromSource( + sourcePath: string, + mediaPath?: string, + ): Promise { + const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim()); + if (!normalizedSourcePath) { + return; + } + const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath(); + await deps.initSubtitlePrefetch( + normalizedSourcePath, + deps.getLastObservedTimePos(), + normalizedSourcePath, + ); + deps.setActiveParsedSubtitleMediaPath(nextMediaPath); + } + + function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { + clearScheduledSubtitlePrefetchRefresh(); + subtitlePrefetchRefreshTimer = setTimeout(() => { + subtitlePrefetchRefreshTimer = null; + void deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => { + deps.logDebug( + `[autoplay-subtitle-prime] subtitle prefetch refresh failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + }, delayMs); + } + + return { + getCurrentAutoplayMediaPath, + resetAutoplaySubtitlePrime, + primeCurrentSubtitleForAutoplay, + primeCurrentSubtitleForVisibleOverlay, + cancelVisibleOverlaySubtitleRefreshAfterFirstPaint, + scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint, + primeAutoplaySubtitleFromParsedCues, + clearScheduledSubtitlePrefetchRefresh, + refreshSubtitleSidebarFromSource, + scheduleSubtitlePrefetchRefresh, + }; +} diff --git a/src/main/runtime/first-run-setup-plugin.ts b/src/main/runtime/first-run-setup-plugin.ts index e2644c5c..465a2ff1 100644 --- a/src/main/runtime/first-run-setup-plugin.ts +++ b/src/main/runtime/first-run-setup-plugin.ts @@ -180,6 +180,21 @@ export function detectInstalledFirstRunPluginCandidates(options: { return candidates; } +export function detectWindowsMpvPluginRemovalCandidates(options: { + homeDir: string; + appDataDir: string; + mpvExecutablePath: string; + existsSync?: (candidate: string) => boolean; +}): InstalledFirstRunPluginCandidate[] { + return detectInstalledFirstRunPluginCandidates({ + platform: 'win32', + homeDir: options.homeDir, + appDataDir: options.appDataDir, + mpvExecutablePath: options.mpvExecutablePath, + existsSync: options.existsSync, + }); +} + function parseInstalledPluginVersion(content: string): string | null { const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/); return match?.[1] ?? null; diff --git a/src/main/runtime/internal-subtitle-extraction.test.ts b/src/main/runtime/internal-subtitle-extraction.test.ts new file mode 100644 index 00000000..658d4ea8 --- /dev/null +++ b/src/main/runtime/internal-subtitle-extraction.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import test from 'node:test'; +import { + buildFfmpegSubtitleExtractionArgs, + extractInternalSubtitleTrackToTempFile, + parseTrackId, +} from './internal-subtitle-extraction'; + +test('buildFfmpegSubtitleExtractionArgs rejects output paths without an extension', () => { + assert.throws( + () => buildFfmpegSubtitleExtractionArgs('/tmp/video.mkv', 2, '/tmp/subtitle-output'), + /outputPath.*file extension/, + ); +}); + +test('parseTrackId rejects negative track ids', () => { + assert.equal(parseTrackId(-1), null); + assert.equal(parseTrackId(' -2 '), null); +}); + +test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-')); + const videoPath = path.join(root, 'video.mkv'); + fs.writeFileSync(videoPath, ''); + + try { + await assert.rejects( + () => + extractInternalSubtitleTrackToTempFile( + process.execPath, + videoPath, + { 'ff-index': 0, codec: 'ass' }, + { + extractionTimeoutMs: 20, + spawnArgsOverride: ['-e', 'setTimeout(() => {}, 1000);'], + }, + ), + /ffmpeg extraction timed out/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/main/runtime/internal-subtitle-extraction.ts b/src/main/runtime/internal-subtitle-extraction.ts new file mode 100644 index 00000000..d303466d --- /dev/null +++ b/src/main/runtime/internal-subtitle-extraction.ts @@ -0,0 +1,147 @@ +import * as fs from 'fs'; +import { spawn } from 'node:child_process'; +import * as os from 'os'; +import * as path from 'path'; + +import { resolveSubtitleSourcePath } from './subtitle-prefetch-source'; +import { codecToExtension } from '../../subsync/utils'; + +export async function loadSubtitleSourceText(source: string): Promise { + if (/^https?:\/\//i.test(source)) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 4000); + try { + const response = await fetch(source, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to download subtitle source (${response.status})`); + } + return await response.text(); + } finally { + clearTimeout(timeoutId); + } + } + + const filePath = resolveSubtitleSourcePath(source); + return fs.promises.readFile(filePath, 'utf8'); +} + +export type MpvSubtitleTrackLike = { + type?: unknown; + id?: unknown; + selected?: unknown; + external?: unknown; + codec?: unknown; + 'ff-index'?: unknown; + 'external-filename'?: unknown; +}; + +const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000; + +export function parseTrackId(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; + } + return null; +} + +export function buildFfmpegSubtitleExtractionArgs( + videoPath: string, + ffIndex: number, + outputPath: string, +): string[] { + const outputFormat = path.extname(outputPath).slice(1); + if (!outputFormat) { + throw new Error(`outputPath must include a file extension for ffmpeg format: ${outputPath}`); + } + return [ + '-hide_banner', + '-nostdin', + '-y', + '-loglevel', + 'error', + '-an', + '-vn', + '-i', + videoPath, + '-map', + `0:${ffIndex}`, + '-f', + outputFormat, + outputPath, + ]; +} + +export async function extractInternalSubtitleTrackToTempFile( + ffmpegPath: string, + videoPath: string, + track: MpvSubtitleTrackLike, + options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {}, +): Promise<{ path: string; cleanup: () => Promise } | null> { + const ffIndex = parseTrackId(track['ff-index']); + const codec = typeof track.codec === 'string' ? track.codec : null; + const extension = codecToExtension(codec ?? undefined); + if (ffIndex === null || extension === null) { + return null; + } + + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-')); + const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); + + try { + await new Promise((resolve, reject) => { + let settled = false; + const child = spawn( + ffmpegPath, + options.spawnArgsOverride ?? + buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath), + ); + const extractionTimeoutMs = options.extractionTimeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS; + const timeoutId = setTimeout(() => { + if (settled) { + return; + } + settled = true; + child.kill('SIGKILL'); + reject(new Error(`ffmpeg extraction timed out after ${extractionTimeoutMs}ms`)); + }, extractionTimeoutMs); + const settle = (callback: () => void): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeoutId); + callback(); + }; + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('error', (error) => { + settle(() => reject(error)); + }); + child.on('close', (code) => { + settle(() => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`)); + }); + }); + }); + } catch (error) { + await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + throw error; + } + + return { + path: outputPath, + cleanup: async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }, + }; +} diff --git a/src/main/runtime/log-export-dialogs.test.ts b/src/main/runtime/log-export-dialogs.test.ts new file mode 100644 index 00000000..ee5a6ffd --- /dev/null +++ b/src/main/runtime/log-export-dialogs.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { showLogExportErrorDialog, showLogExportSuccessDialog } from './log-export-dialogs'; + +test('showLogExportSuccessDialog handles dialog rejection', async () => { + const warnings: string[] = []; + + await showLogExportSuccessDialog({ + zipPath: '/tmp/subminer-logs.zip', + showMessageBox: async () => { + throw new Error('dialog failed'); + }, + showItemInFolder: () => { + throw new Error('unexpected shell call'); + }, + logWarn: (message) => warnings.push(message), + }); + + assert.deepEqual(warnings, ['Failed to show log export success dialog.']); +}); + +test('showLogExportErrorDialog handles dialog rejection', async () => { + const warnings: string[] = []; + + await showLogExportErrorDialog({ + message: 'export failed', + showMessageBox: async () => { + throw new Error('dialog failed'); + }, + logWarn: (message) => warnings.push(message), + }); + + assert.deepEqual(warnings, ['Failed to show log export error dialog.']); +}); diff --git a/src/main/runtime/log-export-dialogs.ts b/src/main/runtime/log-export-dialogs.ts new file mode 100644 index 00000000..f28a6e0d --- /dev/null +++ b/src/main/runtime/log-export-dialogs.ts @@ -0,0 +1,46 @@ +import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'; + +type ShowMessageBox = (options: MessageBoxOptions) => Promise; + +export async function showLogExportSuccessDialog(options: { + zipPath: string; + showMessageBox: ShowMessageBox; + showItemInFolder: (path: string) => void; + logWarn: (message: string, details?: unknown) => void; +}): Promise { + const successDialog = await options + .showMessageBox({ + type: 'info', + title: 'SubMiner logs exported', + message: 'SubMiner log export created.', + detail: options.zipPath, + buttons: ['OK', 'Show in Folder'], + defaultId: 0, + cancelId: 0, + }) + .catch((dialogError) => { + options.logWarn('Failed to show log export success dialog.', dialogError); + return undefined; + }); + + if (successDialog?.response === 1) { + options.showItemInFolder(options.zipPath); + } +} + +export async function showLogExportErrorDialog(options: { + message: string; + showMessageBox: ShowMessageBox; + logWarn: (message: string, details?: unknown) => void; +}): Promise { + await options + .showMessageBox({ + type: 'error', + title: 'SubMiner log export failed', + message: 'Could not export SubMiner logs.', + detail: options.message, + }) + .catch((dialogError) => { + options.logWarn('Failed to show log export error dialog.', dialogError); + }); +} diff --git a/src/main/runtime/log-export-tray.ts b/src/main/runtime/log-export-tray.ts new file mode 100644 index 00000000..9fa40b3f --- /dev/null +++ b/src/main/runtime/log-export-tray.ts @@ -0,0 +1,53 @@ +import { app, dialog, shell } from 'electron'; +import * as os from 'os'; +import { showLogExportErrorDialog, showLogExportSuccessDialog } from './log-export-dialogs'; +import { exportLogsArchive } from './log-export'; + +export interface LogExportTrayRuntimeDeps { + flushMpvLog: () => Promise; + logInfo: (message: string) => void; + logWarn: (message: string, details?: unknown) => void; +} + +export function createLogExportTrayRuntime(deps: LogExportTrayRuntimeDeps): { + exportLogsFromTray: () => Promise; +} { + function describeUnknownError(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + + async function exportLogsFromTray(): Promise { + try { + await deps.flushMpvLog(); + } catch (error) { + deps.logWarn('Failed to flush mpv log before exporting logs from tray.', error); + } + + try { + const result = exportLogsArchive({ + platform: process.platform, + homeDir: os.homedir(), + appDataDir: app.getPath('appData'), + }); + deps.logInfo( + `Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`, + ); + await showLogExportSuccessDialog({ + zipPath: result.zipPath, + showMessageBox: (options) => dialog.showMessageBox(options), + showItemInFolder: (zipPath) => shell.showItemInFolder(zipPath), + logWarn: deps.logWarn, + }); + } catch (error) { + const message = describeUnknownError(error); + deps.logWarn('Failed to export logs from tray.', error); + await showLogExportErrorDialog({ + message, + showMessageBox: (options) => dialog.showMessageBox(options), + logWarn: deps.logWarn, + }); + } + } + + return { exportLogsFromTray }; +} diff --git a/src/main/runtime/overlay-geometry-runtime.ts b/src/main/runtime/overlay-geometry-runtime.ts new file mode 100644 index 00000000..1b4a0449 --- /dev/null +++ b/src/main/runtime/overlay-geometry-runtime.ts @@ -0,0 +1,319 @@ +import { type BrowserWindow, screen } from 'electron'; +import type { WindowGeometry } from '../../types'; +import { hasHyprlandWindowPlacementBoundsMismatch } from '../../core/services/hyprland-window-placement'; +import { normalizeOverlayWindowBoundsForPlatform } from '../../core/services/overlay-window-bounds'; +import { + enforceOverlayLayerOrder as enforceOverlayLayerOrderCore, + ensureOverlayWindowLevel as ensureOverlayWindowLevelCore, + syncOverlayWindowLayer, +} from '../../core/services/overlay-window'; +import { promoteStatsOverlayAbovePlayback } from '../../core/services/stats-window.js'; +import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape'; +import { shouldRunLinuxOverlayZOrderKeepAlive } from './linux-overlay-zorder-keepalive'; +import { + shouldExitFullscreenOverrideForTrackedGeometry, + type LinuxVisibleOverlayWindowMode, +} from './linux-visible-overlay-window-mode'; +import { + createEnforceOverlayLayerOrderHandler, + createEnsureOverlayWindowLevelHandler, + createUpdateVisibleOverlayBoundsHandler, + hasLiveOverlayWindowBoundsMismatch, +} from './overlay-window-layout'; +import { + createBuildEnforceOverlayLayerOrderMainDepsHandler, + createBuildEnsureOverlayWindowLevelMainDepsHandler, + createBuildUpdateVisibleOverlayBoundsMainDepsHandler, +} from './overlay-window-layout-main-deps'; +import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order'; + +const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200; + +export interface OverlayGeometryRuntimeDeps { + overlayManager: { + getMainWindow: () => BrowserWindow | null; + getModalWindow: () => BrowserWindow | null; + getVisibleOverlayVisible: () => boolean; + setOverlayWindowBounds: (geometry: WindowGeometry) => void; + setModalWindowBounds: (geometry: WindowGeometry) => void; + }; + getTrackedWindowGeometry: () => WindowGeometry | null; + getTrackedWindowMediaSourceId: () => string | null | undefined; + getTrackedWindowNativeId: () => string | null | undefined; + getStatsOverlayVisible: () => boolean; + getOverlayForegroundSeparateWindows: () => BrowserWindow[]; + getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode; + getLinuxTrackedMpvFullscreen: () => boolean; + getLinuxTrackedMpvFullscreenChangedAtMs: () => number; + syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) => void; + getLinuxVisibleOverlayOwnerBindingKey: () => string | null; + setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void; + clearVisibleOverlayX11OwnerBinding: (window: BrowserWindow) => void; + getNativeWindowHandleDecimal: (window: BrowserWindow) => string; + enqueueVisibleOverlayX11OwnerBindingOperation: ( + window: BrowserWindow, + args: string[], + onError?: (error: Error) => void, + ) => void; + scheduleWindowsVisibleOverlayZOrderSyncBurst: () => void; + logDebug: (message: string, ...args: unknown[]) => void; +} + +export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) { + const { overlayManager } = deps; + + let lastOverlayWindowGeometry: WindowGeometry | null = null; + + function getOverlayGeometryFallback(): WindowGeometry { + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const bounds = display.workArea; + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; + } + + function getCurrentOverlayGeometry(): WindowGeometry { + if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry; + const trackerGeometry = deps.getTrackedWindowGeometry(); + if (trackerGeometry) return trackerGeometry; + return getOverlayGeometryFallback(); + } + + function getCurrentTrackedOverlayGeometry(): WindowGeometry | null { + return deps.getTrackedWindowGeometry(); + } + + function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean { + if (!a || !b) return false; + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + } + + function applyOverlayRegions(geometry: WindowGeometry): void { + lastOverlayWindowGeometry = geometry; + maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry); + overlayManager.setOverlayWindowBounds(geometry); + overlayManager.setModalWindowBounds(geometry); + } + + function shouldExitLinuxFullscreenOverrideForGeometry(geometry: WindowGeometry): boolean { + if (!shouldRunLinuxOverlayZOrderKeepAlive()) { + return false; + } + if ( + deps.getLinuxTrackedMpvFullscreenChangedAtMs() > 0 && + Date.now() - deps.getLinuxTrackedMpvFullscreenChangedAtMs() < + LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS + ) { + return false; + } + + const displayBounds = screen.getDisplayMatching(geometry).bounds; + return shouldExitFullscreenOverrideForTrackedGeometry({ + currentMode: deps.getLinuxVisibleOverlayWindowMode(), + trackedFullscreen: deps.getLinuxTrackedMpvFullscreen(), + geometry, + displayBounds, + }); + } + + function maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry: WindowGeometry): void { + if (!shouldExitLinuxFullscreenOverrideForGeometry(geometry)) { + return; + } + + deps.logDebug( + 'Tracked mpv geometry no longer covers its display; exiting Linux fullscreen overlay override', + ); + deps.syncLinuxVisibleOverlayMpvFullscreenMode(false); + } + + function hasHyprlandOverlayWindowPlacementMismatch(geometry: WindowGeometry): boolean { + if (process.platform !== 'linux') { + return false; + } + + return [overlayManager.getMainWindow(), overlayManager.getModalWindow()].some((window) => { + if (!window || window.isDestroyed()) { + return false; + } + return hasHyprlandWindowPlacementBoundsMismatch({ + title: window.getTitle(), + bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window), + }); + }); + } + + const buildUpdateVisibleOverlayBoundsMainDepsHandler = + createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ + getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry, + shouldRefreshUnchangedGeometry: (geometry) => + shouldExitLinuxFullscreenOverrideForGeometry(geometry) || + (process.platform === 'linux' && + (hasLiveOverlayWindowBoundsMismatch( + [overlayManager.getMainWindow(), overlayManager.getModalWindow()], + geometry, + ) || + hasHyprlandOverlayWindowPlacementMismatch(geometry))), + setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), + afterSetOverlayWindowBounds: () => { + if (!overlayManager.getVisibleOverlayVisible()) { + return; + } + if (process.platform === 'win32') { + deps.scheduleWindowsVisibleOverlayZOrderSyncBurst(); + return; + } + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + if (process.platform === 'linux') { + restoreLinuxOverlayWindowShape(mainWindow); + } + ensureOverlayWindowLevel(mainWindow); + }, + }); + const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); + const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( + updateVisibleOverlayBoundsMainDeps, + ); + + const buildEnsureOverlayWindowLevelMainDepsHandler = + createBuildEnsureOverlayWindowLevelMainDepsHandler({ + shouldSuppressOverlayWindowLevel: (window) => { + const mainWindow = overlayManager.getMainWindow(); + return ( + (deps.getStatsOverlayVisible() && window === mainWindow) || + shouldSuppressVisibleOverlayRaiseForSeparateWindow({ + window, + mainWindow, + separateWindows: deps.getOverlayForegroundSeparateWindows(), + }) + ); + }, + ensureOverlayWindowLevelCore: (window) => + ensureOverlayWindowLevelCore(window as BrowserWindow), + afterEnsureOverlayWindowLevel: () => { + const mainWindow = overlayManager.getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow); + } + promoteStatsOverlayAbovePlayback(); + }, + }); + const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler(); + const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler( + ensureOverlayWindowLevelMainDeps, + ); + + function syncPrimaryOverlayWindowLayer(layer: 'visible'): void { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + syncOverlayWindowLayer(mainWindow, layer); + } + + function moveVisibleOverlayAboveTrackedPlaybackWindow(window: BrowserWindow): void { + if (process.platform !== 'linux') return; + if (window !== overlayManager.getMainWindow()) return; + + bindVisibleOverlayToTrackedX11Window(window); + + const mediaSourceId = deps.getTrackedWindowMediaSourceId(); + if (!mediaSourceId) return; + + try { + window.moveAbove(mediaSourceId); + } catch (error) { + deps.logDebug( + 'Failed to move visible overlay above tracked playback window:', + error instanceof Error ? error.message : String(error), + ); + } + } + + function bindVisibleOverlayToTrackedX11Window(window: BrowserWindow): void { + const targetWindowId = deps.getTrackedWindowNativeId(); + if (!targetWindowId) { + if (deps.getLinuxVisibleOverlayOwnerBindingKey() !== null) { + deps.clearVisibleOverlayX11OwnerBinding(window); + } + deps.setLinuxVisibleOverlayOwnerBindingKey(null); + return; + } + + const overlayWindowId = deps.getNativeWindowHandleDecimal(window); + const bindingKey = `${overlayWindowId}:${targetWindowId}`; + if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) { + return; + } + deps.setLinuxVisibleOverlayOwnerBindingKey(bindingKey); + + deps.enqueueVisibleOverlayX11OwnerBindingOperation( + window, + [ + '-id', + overlayWindowId, + '-f', + 'WM_TRANSIENT_FOR', + '32x', + '-set', + 'WM_TRANSIENT_FOR', + targetWindowId, + ], + (error) => { + if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) { + deps.setLinuxVisibleOverlayOwnerBindingKey(null); + } + deps.logDebug( + 'Failed to bind visible overlay as transient for tracked X11 playback window:', + error instanceof Error ? error.message : String(error), + ); + }, + ); + } + + const buildEnforceOverlayLayerOrderMainDepsHandler = + createBuildEnforceOverlayLayerOrderMainDepsHandler({ + enforceOverlayLayerOrderCore: (params) => + enforceOverlayLayerOrderCore({ + visibleOverlayVisible: params.visibleOverlayVisible, + mainWindow: params.mainWindow as BrowserWindow | null, + ensureOverlayWindowLevel: (window) => + params.ensureOverlayWindowLevel(window as BrowserWindow), + }), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow), + }); + const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler(); + const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( + enforceOverlayLayerOrderMainDeps, + ); + + return { + getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry, + resetLastOverlayWindowGeometry: () => { + lastOverlayWindowGeometry = null; + }, + getOverlayGeometryFallback, + getCurrentOverlayGeometry, + getCurrentTrackedOverlayGeometry, + geometryMatches, + applyOverlayRegions, + shouldExitLinuxFullscreenOverrideForGeometry, + maybeExitLinuxFullscreenOverrideForTrackedGeometry, + hasHyprlandOverlayWindowPlacementMismatch, + moveVisibleOverlayAboveTrackedPlaybackWindow, + bindVisibleOverlayToTrackedX11Window, + syncPrimaryOverlayWindowLayer, + updateVisibleOverlayBounds, + ensureOverlayWindowLevel, + enforceOverlayLayerOrder, + }; +} + +export type OverlayGeometryRuntime = ReturnType; diff --git a/src/main/runtime/overlay-notifications-runtime.ts b/src/main/runtime/overlay-notifications-runtime.ts new file mode 100644 index 00000000..b16105f8 --- /dev/null +++ b/src/main/runtime/overlay-notifications-runtime.ts @@ -0,0 +1,253 @@ +import type { BrowserWindow } from 'electron'; +import type { + NotificationType, + OverlayNotificationEventPayload, + OverlayNotificationPayload, + ResolvedConfig, +} from '../../types'; +import type { AnkiIntegration } from '../../anki-integration'; +import type { RuntimeOptionsManager } from '../../runtime-options'; +import { AnkiConnectClient } from '../../anki-connect'; +import { DEFAULT_CONFIG } from '../../config'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { showDesktopNotification } from '../../core/utils'; +import { + isOverlayWindowContentReady, + sendMpvCommandRuntime, + type MpvIpcClient, +} from '../../core/services'; +import { createOverlayLoadingOsdController } from './overlay-loading-osd'; +import { createMaybeStartOverlayLoadingOsdHandler } from './overlay-loading-osd-start'; +import { withConfiguredOverlayNotificationPosition } from './overlay-notification-position'; +import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; +import { + getPlaybackFeedbackNotificationOptions, + notifyConfiguredStatus, + type ConfiguredStatusNotificationOptions, +} from './configured-status-notification'; +import { resolveOverlayReadinessNotificationType } from './notification-routing'; + +export interface OverlayNotificationsRuntimeDeps { + getResolvedConfig: () => ResolvedConfig; + getMainOverlayWindow: () => BrowserWindow | null; + getVisibleOverlayVisible: () => boolean; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; + showMpvOsd: (message: string) => void; + getMpvClient: () => MpvIpcClient | null; + getAnkiIntegration: () => AnkiIntegration | null; + getRuntimeOptionsManager: () => RuntimeOptionsManager | null; +} + +export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRuntimeDeps): { + isVisibleOverlayContentReady: () => boolean; + getConfiguredStatusNotificationType: () => NotificationType; + flushQueuedOverlayNotifications: () => void; + showOverlayNotification: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification: (id: string) => void; + openAnkiCardFromNotification: (noteId: number) => Promise; + toggleNotificationHistoryPanel: () => void; + showConfiguredStatusNotification: ( + message: string, + options?: ConfiguredStatusNotificationOptions, + ) => void; + showConfiguredPlaybackFeedback: ( + message: string, + options?: ConfiguredStatusNotificationOptions, + ) => void; + showSubsyncStatusNotification: (message: string) => void; + showYoutubeFlowStatusNotification: (message: string) => void; + showOverlayLoadingStatusNotification: () => void; + dismissOverlayLoadingStatusNotification: () => void; + maybeStartOverlayLoadingOsd: (mediaPath?: string | null) => void; +} { + function isVisibleOverlayContentReady(): boolean { + const overlayWindow = deps.getMainOverlayWindow(); + return Boolean( + deps.getVisibleOverlayVisible() && + overlayWindow && + isOverlayWindowReadyForNotification(overlayWindow), + ); + } + + function getConfiguredStatusNotificationType(): NotificationType { + const configuredType = deps.getResolvedConfig().ankiConnect.behavior.notificationType; + return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady()); + } + + function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean { + if (window.isDestroyed() || !isOverlayWindowContentReady(window)) { + return false; + } + if (window.webContents.isLoading()) { + return false; + } + const currentURL = window.webContents.getURL(); + return currentURL !== '' && currentURL !== 'about:blank'; + } + + const overlayNotificationDelivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => isVisibleOverlayContentReady(), + send: (payload) => { + deps.broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload); + }, + scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs), + clearFlushRetry: (handle) => clearTimeout(handle as ReturnType), + }); + let overlayLoadingOsdController: ReturnType | null = + null; + + function flushQueuedOverlayNotifications(): void { + overlayNotificationDelivery.flush(); + } + + function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void { + overlayNotificationDelivery.send(payload); + } + + function showOverlayNotification(payload: OverlayNotificationPayload): void { + sendOverlayNotificationEvent( + withConfiguredOverlayNotificationPosition(payload, deps.getResolvedConfig()), + ); + } + + function dismissOverlayNotification(id: string): void { + sendOverlayNotificationEvent({ id, dismiss: true }); + } + + async function openAnkiCardFromNotification(noteId: number): Promise { + const activeIntegrationOpen = deps.getAnkiIntegration()?.openNoteInAnki(noteId); + if (activeIntegrationOpen) { + await activeIntegrationOpen; + return; + } + + const resolvedConfig = deps.getResolvedConfig(); + const effectiveAnkiConfig = + deps.getRuntimeOptionsManager()?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ?? + resolvedConfig.ankiConnect; + const fallbackClient = new AnkiConnectClient( + effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url, + ); + await fallbackClient.openNoteInBrowser(noteId); + } + + function toggleNotificationHistoryPanel(): void { + deps.broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle); + } + + function showConfiguredStatusNotification( + message: string, + options: ConfiguredStatusNotificationOptions = {}, + ): void { + notifyConfiguredStatus( + message, + { + getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType, + isOverlayReady: () => isVisibleOverlayContentReady(), + showOsd: (text) => deps.showMpvOsd(text), + showOverlayNotification, + showDesktopNotification: (title, notificationOptions) => + showDesktopNotification(title, notificationOptions), + }, + options, + ); + } + + function showConfiguredPlaybackFeedback( + message: string, + options: ConfiguredStatusNotificationOptions = {}, + ): void { + showConfiguredStatusNotification(message, { + ...getPlaybackFeedbackNotificationOptions(message), + ...options, + delivery: 'feedback', + }); + } + + function showSubsyncStatusNotification(message: string): void { + const syncing = message.startsWith('Subsync: syncing'); + const failed = message.toLowerCase().includes('failed'); + showConfiguredStatusNotification(message, { + id: 'subsync-status', + title: 'Subsync', + variant: failed ? 'error' : syncing ? 'progress' : 'info', + persistent: syncing, + desktop: !syncing, + }); + } + + function showYoutubeFlowStatusNotification(message: string): void { + const progress = + message.startsWith('Downloading subtitles') || + message.startsWith('Loading subtitles') || + message.startsWith('Getting subtitles') || + message === 'Opening YouTube video'; + showConfiguredStatusNotification(message, { + id: 'youtube-subtitles-status', + title: 'YouTube subtitles', + variant: progress ? 'progress' : 'info', + persistent: progress, + desktop: !progress, + }); + } + + function getOverlayLoadingOsdController(): ReturnType { + if (!overlayLoadingOsdController) { + overlayLoadingOsdController = createOverlayLoadingOsdController({ + showOsd: (message) => { + deps.showMpvOsd(message); + }, + clearOsd: () => { + sendMpvCommandRuntime(deps.getMpvClient(), ['show-text', '', '1']); + }, + setInterval: (callback, delayMs) => { + const timer = setInterval(callback, delayMs); + timer.unref?.(); + return timer; + }, + clearInterval: (timer) => { + clearInterval(timer as ReturnType); + }, + }); + } + return overlayLoadingOsdController; + } + + function showOverlayLoadingStatusNotification(): void { + getOverlayLoadingOsdController().start(); + } + + function dismissOverlayLoadingStatusNotification(): void { + getOverlayLoadingOsdController().stop(); + sendMpvCommandRuntime(deps.getMpvClient(), [ + 'script-message', + 'subminer-overlay-loading-ready', + ]); + dismissOverlayNotification('overlay-loading-status'); + } + + const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({ + getVisibleOverlayRequested: () => deps.getVisibleOverlayVisible(), + isOverlayContentReady: () => isVisibleOverlayContentReady(), + startOverlayLoadingOsd: () => { + showOverlayLoadingStatusNotification(); + }, + }); + + return { + isVisibleOverlayContentReady, + getConfiguredStatusNotificationType, + flushQueuedOverlayNotifications, + showOverlayNotification, + dismissOverlayNotification, + openAnkiCardFromNotification, + toggleNotificationHistoryPanel, + showConfiguredStatusNotification, + showConfiguredPlaybackFeedback, + showSubsyncStatusNotification, + showYoutubeFlowStatusNotification, + showOverlayLoadingStatusNotification, + dismissOverlayLoadingStatusNotification, + maybeStartOverlayLoadingOsd, + }; +} diff --git a/src/main/runtime/session-bindings-runtime.test.ts b/src/main/runtime/session-bindings-runtime.test.ts new file mode 100644 index 00000000..5d17d86d --- /dev/null +++ b/src/main/runtime/session-bindings-runtime.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import type { CompiledSessionBinding, ResolvedConfig } from '../../types'; +import { createSessionBindingsRuntime } from './session-bindings-runtime'; + +test('persistSessionBindings logs and does not publish bindings when artifact write fails', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-')); + const configDir = path.join(root, 'config-file'); + fs.writeFileSync(configDir, 'not a directory'); + const calls: string[] = []; + const runtime = createSessionBindingsRuntime({ + configDir, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never, + getResolvedConfig: () => + ({ + stats: { toggleKey: 's', markWatchedKey: 'w' }, + }) as ResolvedConfig, + getMpvClient: () => null, + setSessionBindings: () => calls.push('setSessionBindings'), + setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'), + logWarn: (message) => calls.push(`warn:${message}`), + }); + + try { + assert.throws( + () => runtime.persistSessionBindings([] as CompiledSessionBinding[]), + /ENOTDIR|EEXIST/, + ); + assert.deepEqual(calls, ['warn:[session-bindings] Failed to write session bindings artifact']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('persistSessionBindings keeps saved bindings when mpv reload notification fails', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-')); + const calls: string[] = []; + const runtime = createSessionBindingsRuntime({ + configDir: root, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never, + getResolvedConfig: () => + ({ + stats: { toggleKey: 's', markWatchedKey: 'w' }, + }) as ResolvedConfig, + getMpvClient: () => + ({ + connected: true, + send: () => { + throw new Error('mpv unavailable'); + }, + }) as never, + setSessionBindings: () => calls.push('setSessionBindings'), + setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'), + logWarn: (message) => calls.push(`warn:${message}`), + }); + + try { + assert.doesNotThrow(() => runtime.persistSessionBindings([] as CompiledSessionBinding[])); + assert.deepEqual(calls, [ + 'setSessionBindings', + 'setSessionBindingsInitialized', + 'warn:[session-bindings] Failed to notify mpv to reload session bindings', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/main/runtime/session-bindings-runtime.ts b/src/main/runtime/session-bindings-runtime.ts new file mode 100644 index 00000000..998331ac --- /dev/null +++ b/src/main/runtime/session-bindings-runtime.ts @@ -0,0 +1,84 @@ +import { sendMpvCommandRuntime, type MpvRuntimeClientLike } from '../../core/services'; +import { + buildPluginSessionBindingsArtifact, + compileSessionBindings, +} from '../../core/services/session-bindings'; +import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; +import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types'; +import { writeSessionBindingsArtifact } from './session-bindings-artifact'; + +export interface SessionBindingsRuntimeDeps { + configDir: string; + getKeybindings: () => Keybinding[]; + getConfiguredShortcuts: () => ConfiguredShortcuts; + getResolvedConfig: () => ResolvedConfig; + getMpvClient: () => MpvRuntimeClientLike | null; + setSessionBindings: (bindings: CompiledSessionBinding[]) => void; + setSessionBindingsInitialized: (initialized: boolean) => void; + logWarn: (message: string, details?: unknown) => void; +} + +export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): { + persistSessionBindings: ( + bindings: CompiledSessionBinding[], + warnings?: ReturnType['warnings'], + ) => void; + refreshCurrentSessionBindings: () => void; +} { + function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; + } + + function compileCurrentSessionBindings(): { + bindings: CompiledSessionBinding[]; + warnings: ReturnType['warnings']; + } { + return compileSessionBindings({ + keybindings: deps.getKeybindings(), + shortcuts: deps.getConfiguredShortcuts(), + statsToggleKey: deps.getResolvedConfig().stats.toggleKey, + statsMarkWatchedKey: deps.getResolvedConfig().stats.markWatchedKey, + platform: resolveSessionBindingPlatform(), + rawConfig: deps.getResolvedConfig(), + }); + } + + function persistSessionBindings( + bindings: CompiledSessionBinding[], + warnings: ReturnType['warnings'] = [], + ): void { + const artifact = buildPluginSessionBindingsArtifact({ + bindings, + warnings, + numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs, + }); + try { + writeSessionBindingsArtifact(deps.configDir, artifact); + } catch (error) { + deps.logWarn('[session-bindings] Failed to write session bindings artifact'); + throw error; + } + deps.setSessionBindings(bindings); + deps.setSessionBindingsInitialized(true); + const mpvClient = deps.getMpvClient(); + if (mpvClient?.connected) { + try { + sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']); + } catch (error) { + deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error); + } + } + } + + function refreshCurrentSessionBindings(): void { + const compiled = compileCurrentSessionBindings(); + for (const warning of compiled.warnings) { + deps.logWarn(`[session-bindings] ${warning.message}`); + } + persistSessionBindings(compiled.bindings, compiled.warnings); + } + + return { persistSessionBindings, refreshCurrentSessionBindings }; +} diff --git a/src/main/runtime/stats-daemon.ts b/src/main/runtime/stats-daemon.ts index 493c2165..11be4230 100644 --- a/src/main/runtime/stats-daemon.ts +++ b/src/main/runtime/stats-daemon.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; import path from 'node:path'; export type BackgroundStatsServerState = { @@ -65,6 +66,43 @@ export function isBackgroundStatsServerProcessAlive(pid: number): boolean { } } +function readProcessStartedAtMs(pid: number): number | null { + try { + if (process.platform === 'win32') { + const output = execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate.ToUniversalTime().ToString("o")`, + ], + { encoding: 'utf8', timeout: 1000 }, + ).trim(); + const parsed = Date.parse(output); + return Number.isFinite(parsed) ? parsed : null; + } + + const output = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { + encoding: 'utf8', + timeout: 1000, + }).trim(); + const parsed = Date.parse(output); + return Number.isFinite(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function verifyBackgroundStatsServerIdentity(pid: number, startedAtMs: number): boolean { + const processStartedAtMs = readProcessStartedAtMs(pid); + if (processStartedAtMs === null) { + return false; + } + const earliestAllowedStateWriteMs = processStartedAtMs; + const latestAllowedStateWriteMs = processStartedAtMs + 60_000; + return startedAtMs >= earliestAllowedStateWriteMs && startedAtMs <= latestAllowedStateWriteMs; +} + export function resolveBackgroundStatsServerUrl( state: Pick, ): string { diff --git a/src/main/runtime/stats-server-runtime.test.ts b/src/main/runtime/stats-server-runtime.test.ts new file mode 100644 index 00000000..060c17e9 --- /dev/null +++ b/src/main/runtime/stats-server-runtime.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createStatsServerRuntime, + isSelfOwnedBackgroundStatsDaemonState, + shouldClearAppStateStatsServerOnStop, +} from './stats-server-runtime'; + +test('detects self-owned background stats daemon state', () => { + assert.equal( + isSelfOwnedBackgroundStatsDaemonState({ pid: process.pid, port: 6969, startedAtMs: 1 }), + true, + ); +}); + +test('stats server app-state reference should be cleared after private server stop', () => { + assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true); +}); + +test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => { + const calls: string[] = []; + const runtime = createStatsServerRuntime({ + userDataPath: '/tmp/subminer-stats-runtime-test', + statsDistPath: '/tmp/stats-dist', + getResolvedConfig: () => ({ stats: { serverPort: 5175 } }) as never, + getImmersionTracker: () => null, + setAppStateStatsServer: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + getYomitanExt: () => null, + getYomitanSession: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + getYomitanAnkiDeckName: async () => 'Mining', + getAnilistRateLimiter: () => ({}) as never, + resolveAnkiNoteId: (noteId) => noteId, + trackDuplicateNoteIdsForNote: () => {}, + resolveSentenceSearchHeadwords: async () => [], + ensureImmersionTrackerStarted: () => {}, + setStatsStartupInProgress: () => {}, + readBackgroundStatsServerState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }), + removeBackgroundStatsServerState: () => { + calls.push('removeBackgroundStatsServerState'); + }, + isBackgroundStatsServerProcessAlive: () => true, + verifyBackgroundStatsServerIdentity: () => false, + killProcess: () => { + calls.push('killProcess'); + }, + }); + + const result = await runtime.stopBackgroundStatsServer(); + + assert.deepEqual(result, { ok: true, stale: true }); + assert.deepEqual(calls, ['removeBackgroundStatsServerState']); +}); diff --git a/src/main/runtime/stats-server-runtime.ts b/src/main/runtime/stats-server-runtime.ts new file mode 100644 index 00000000..dfe2a681 --- /dev/null +++ b/src/main/runtime/stats-server-runtime.ts @@ -0,0 +1,284 @@ +import path from 'node:path'; +import type { BrowserWindow } from 'electron'; +import { + addYomitanNoteViaSearch, + syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, +} from '../../core/services'; +import { startStatsServer } from '../../core/services/stats-server'; +import { createLogger } from '../../logger'; +import type { ResolvedConfig } from '../../types/config'; +import type { AppState } from '../state'; +import { + isBackgroundStatsServerProcessAlive as defaultIsBackgroundStatsServerProcessAlive, + readBackgroundStatsServerState as defaultReadBackgroundStatsServerState, + removeBackgroundStatsServerState as defaultRemoveBackgroundStatsServerState, + resolveBackgroundStatsServerUrl, + verifyBackgroundStatsServerIdentity as defaultVerifyBackgroundStatsServerIdentity, + writeBackgroundStatsServerState, +} from './stats-daemon'; +import { createEnsureStatsServerUrlHandler } from './stats-server-routing'; +import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server'; + +export function isSelfOwnedBackgroundStatsDaemonState(state: { + pid: number; + port?: number; + startedAtMs?: number; +}): boolean { + return state.pid === process.pid; +} + +export function shouldClearAppStateStatsServerOnStop(options: { + hadStatsServer: boolean; +}): boolean { + return options.hadStatsServer; +} + +export interface StatsServerRuntimeDeps { + userDataPath: string; + statsDistPath: string; + getResolvedConfig: () => ResolvedConfig; + getImmersionTracker: () => AppState['immersionTracker']; + setAppStateStatsServer: (server: AppState['statsServer']) => void; + getMpvSocketPath: () => AppState['mpvSocketPath']; + getYomitanExt: () => AppState['yomitanExt']; + getYomitanSession: () => AppState['yomitanSession']; + getYomitanParserWindow: () => AppState['yomitanParserWindow']; + setYomitanParserWindow: (w: BrowserWindow | null) => void; + getYomitanParserReadyPromise: () => AppState['yomitanParserReadyPromise']; + setYomitanParserReadyPromise: (p: Promise | null) => void; + getYomitanParserInitPromise: () => AppState['yomitanParserInitPromise']; + setYomitanParserInitPromise: (p: Promise | null) => void; + getYomitanAnkiDeckName: () => Promise; + getAnilistRateLimiter: () => NonNullable< + Parameters[0]['anilistRateLimiter'] + >; + resolveAnkiNoteId: (noteId: number) => number; + trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void; + resolveSentenceSearchHeadwords: (term: string) => Promise; + ensureImmersionTrackerStarted: () => void; + setStatsStartupInProgress: (inProgress: boolean) => void; + readBackgroundStatsServerState?: typeof defaultReadBackgroundStatsServerState; + removeBackgroundStatsServerState?: typeof defaultRemoveBackgroundStatsServerState; + isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive; + verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity; + killProcess?: (pid: number, signal: NodeJS.Signals) => void; +} + +export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { + stopStatsServer: () => void; + ensureStatsServerStarted: ReturnType; + ensureBackgroundStatsServerStarted: () => { + url: string; + runningInCurrentProcess: boolean; + }; + stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>; +} { + let statsServer: ReturnType | null = null; + const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json'); + const readDaemonState = + deps.readBackgroundStatsServerState ?? + ((statePath: string) => defaultReadBackgroundStatsServerState(statePath)); + const removeDaemonState = + deps.removeBackgroundStatsServerState ?? + ((statePath: string) => defaultRemoveBackgroundStatsServerState(statePath)); + const isDaemonAlive = + deps.isBackgroundStatsServerProcessAlive ?? + ((pid: number) => defaultIsBackgroundStatsServerProcessAlive(pid)); + const verifyDaemonIdentity = + deps.verifyBackgroundStatsServerIdentity ?? + ((pid: number, startedAtMs: number) => + defaultVerifyBackgroundStatsServerIdentity(pid, startedAtMs)); + const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal)); + + function readLiveBackgroundStatsDaemonState(): { + pid: number; + port: number; + startedAtMs: number; + } | null { + const state = readDaemonState(statsDaemonStatePath); + if (!state) { + removeDaemonState(statsDaemonStatePath); + return null; + } + if (state.pid === process.pid && !statsServer) { + removeDaemonState(statsDaemonStatePath); + return null; + } + if (!isDaemonAlive(state.pid)) { + removeDaemonState(statsDaemonStatePath); + return null; + } + return state; + } + + function clearOwnedBackgroundStatsDaemonState(): void { + const state = readDaemonState(statsDaemonStatePath); + if (state?.pid === process.pid) { + removeDaemonState(statsDaemonStatePath); + } + } + + function stopStatsServer(): void { + if (!statsServer) { + return; + } + statsServer.close(); + statsServer = null; + if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) { + deps.setAppStateStatsServer(null); + } + clearOwnedBackgroundStatsDaemonState(); + } + + const startLocalStatsServer = (): void => { + const tracker = deps.getImmersionTracker(); + if (!tracker) { + throw new Error('Immersion tracker failed to initialize.'); + } + if (!statsServer) { + const yomitanDeps = { + getYomitanExt: () => deps.getYomitanExt(), + getYomitanSession: () => deps.getYomitanSession(), + getYomitanParserWindow: () => deps.getYomitanParserWindow(), + setYomitanParserWindow: (w: BrowserWindow | null) => { + deps.setYomitanParserWindow(w); + }, + getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), + setYomitanParserReadyPromise: (p: Promise | null) => { + deps.setYomitanParserReadyPromise(p); + }, + getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(), + setYomitanParserInitPromise: (p: Promise | null) => { + deps.setYomitanParserInitPromise(p); + }, + }; + const yomitanLogger = createLogger('main:yomitan-stats'); + statsServer = startStatsServer({ + port: deps.getResolvedConfig().stats.serverPort, + staticDir: deps.statsDistPath, + tracker, + knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'), + mpvSocketPath: deps.getMpvSocketPath(), + getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect, + getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName, + getSecondarySubtitleLanguages: () => + deps.getResolvedConfig().secondarySub.secondarySubLanguages, + getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path, + anilistRateLimiter: deps.getAnilistRateLimiter(), + resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId), + resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term), + addYomitanNote: async (word: string) => { + const ankiConnectConfig = deps.getResolvedConfig().ankiConnect; + const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765'; + await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { + forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig), + deck: ankiConnectConfig.deck, + }); + const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); + if (result.noteId && result.duplicateNoteIds.length > 0) { + deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds); + } + return result.noteId; + }, + }); + deps.setAppStateStatsServer(statsServer); + } + deps.setAppStateStatsServer(statsServer); + }; + + const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({ + currentPid: process.pid, + readBackgroundState: () => readDaemonState(statsDaemonStatePath), + removeBackgroundState: () => { + removeDaemonState(statsDaemonStatePath); + }, + isProcessAlive: (pid) => isDaemonAlive(pid), + hasLocalStatsServer: () => statsServer !== null, + startLocalStatsServer, + getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort, + }); + + const ensureBackgroundStatsServerStarted = (): { + url: string; + runningInCurrentProcess: boolean; + } => { + const liveDaemon = readLiveBackgroundStatsDaemonState(); + if (liveDaemon && liveDaemon.pid !== process.pid) { + return { + url: resolveBackgroundStatsServerUrl(liveDaemon), + runningInCurrentProcess: false, + }; + } + + deps.setStatsStartupInProgress(true); + try { + deps.ensureImmersionTrackerStarted(); + } finally { + deps.setStatsStartupInProgress(false); + } + + const port = deps.getResolvedConfig().stats.serverPort; + const result = ensureStatsServerStarted(); + if (result.source === 'local') { + writeBackgroundStatsServerState(statsDaemonStatePath, { + pid: process.pid, + port, + startedAtMs: Date.now(), + }); + } + return { url: result.url, runningInCurrentProcess: result.source === 'local' }; + }; + + const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => { + const state = readDaemonState(statsDaemonStatePath); + if (!state) { + removeDaemonState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + if (isSelfOwnedBackgroundStatsDaemonState(state)) { + removeDaemonState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + if (!isDaemonAlive(state.pid)) { + removeDaemonState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + if (!verifyDaemonIdentity(state.pid, state.startedAtMs)) { + removeDaemonState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + + try { + killProcess(state.pid, 'SIGTERM'); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') { + removeDaemonState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + if ((error as NodeJS.ErrnoException)?.code === 'EPERM') { + throw new Error( + `Insufficient permissions to stop background stats server (pid ${state.pid}).`, + ); + } + throw error; + } + + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (!isDaemonAlive(state.pid)) { + removeDaemonState(statsDaemonStatePath); + return { ok: true, stale: false }; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + throw new Error('Timed out stopping background stats server.'); + }; + + return { + stopStatsServer, + ensureStatsServerStarted, + ensureBackgroundStatsServerStarted, + stopBackgroundStatsServer, + }; +} diff --git a/src/main/runtime/update/update-service-runtime.test.ts b/src/main/runtime/update/update-service-runtime.test.ts new file mode 100644 index 00000000..781d0979 --- /dev/null +++ b/src/main/runtime/update/update-service-runtime.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime'; + +test('runSupportAssetUpdatesForLauncherResult logs support-asset errors and preserves launcher result', async () => { + const warnings: string[] = []; + const launcherResult = { status: 'updated' } as const; + const result = await runSupportAssetUpdatesForLauncherResult({ + launcherResult, + updateSupportAssets: async () => { + throw new Error('archive failed'); + }, + logWarn: (message, details) => { + warnings.push(`${message}:${details instanceof Error ? details.message : String(details)}`); + }, + }); + + assert.equal(result, launcherResult); + assert.deepEqual(warnings, ['Support asset update failed after launcher update:archive failed']); +}); + +test('runSupportAssetUpdatesForLauncherResult uses support asset description in skip warnings', async () => { + const warnings: string[] = []; + const launcherResult = { status: 'updated' } as const; + + const result = await runSupportAssetUpdatesForLauncherResult({ + launcherResult, + assetDescription: 'Support asset update', + updateSupportAssets: async () => [ + { status: 'protected', command: 'install-theme' }, + { status: 'hash-mismatch', message: 'checksum failed' }, + ], + logWarn: (message) => { + warnings.push(message); + }, + }); + + assert.equal(result, launcherResult); + assert.deepEqual(warnings, [ + 'Support asset update requires manual command: install-theme', + 'Support asset update skipped: checksum failed', + ]); +}); diff --git a/src/main/runtime/update/update-service-runtime.ts b/src/main/runtime/update/update-service-runtime.ts new file mode 100644 index 00000000..7ddcd3ac --- /dev/null +++ b/src/main/runtime/update/update-service-runtime.ts @@ -0,0 +1,191 @@ +import { app, dialog } from 'electron'; +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import type { UpdateChannel, UpdatesConfig } from '../../../types/config'; +import type { OverlayNotificationPayload } from '../../../types/notification'; +import { createElectronAppUpdater, isNativeUpdaterSupported } from './app-updater'; +import { createCurlFetch, createGlobalFetch } from './fetch-adapter'; +import { createCurlHttpExecutor } from './curl-http-executor'; +import { createFetchHttpExecutor } from './fetch-http-executor'; +import { + fetchLatestStableRelease, + fetchReleaseAssetBuffer, + fetchReleaseAssetText, + findReleaseAsset, + parseSha256Sums, + type GitHubRelease, +} from './release-assets'; +import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy'; +import { updateLauncherFromRelease } from './launcher-updater'; +import { notifyUpdateAvailable } from './update-notifications'; +import { createUpdateDialogPresenter } from './update-dialogs'; +import { createFileUpdateStateStore, createUpdateService } from './update-service'; +import { updateSupportAssetsFromRelease } from './support-assets'; +import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime'; + +const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner'; + +export interface UpdateServiceRuntimeDeps { + userDataPath: string; + getUpdatesConfig: () => Required; + logInfo: (message: string) => void; + logWarn: (message: string, details?: unknown) => void; + showOverlayNotification: (payload: OverlayNotificationPayload) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; + showMpvOsd: (message: string) => void; + withStatsWindowLayerSuspendedForNativeDialog: (showDialog: () => Promise) => Promise; +} + +export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): { + getUpdateService: () => ReturnType; +} { + const updateStateStore = createFileUpdateStateStore( + path.join(deps.userDataPath, 'update-state.json'), + ); + let updateService: ReturnType | null = null; + const globalFetchForUpdater = createGlobalFetch(); + const curlFetch = createCurlFetch(); + + function createNativeUpdaterHttpExecutor() { + if (process.platform === 'win32') { + return createFetchHttpExecutor(); + } + return createCurlHttpExecutor(); + } + + function getFetchForUpdater() { + if (process.platform === 'win32') return globalFetchForUpdater; + return curlFetch; + } + + async function updateLauncherFromSelectedRelease( + launcherPath?: string, + channel: UpdateChannel = deps.getUpdatesConfig().channel, + release: GitHubRelease | null = null, + ) { + const fetchForUpdater = getFetchForUpdater(); + if (!release) { + return { status: 'missing-asset', message: `No ${channel} GitHub release found.` }; + } + const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt'); + if (!sumsAsset) { + return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' }; + } + const sums = parseSha256Sums( + await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url), + ); + const launcherResult = await updateLauncherFromRelease({ + release, + sha256Sums: sums, + launcherPath, + downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url), + }); + return runSupportAssetUpdatesForLauncherResult({ + launcherResult, + assetDescription: 'Support asset update', + updateSupportAssets: () => + updateSupportAssetsFromRelease({ + release, + sha256Sums: sums, + downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url), + }), + logWarn: (message, details) => deps.logWarn(message, details), + }); + } + + function getUpdateService() { + if (updateService) return updateService; + const appUpdater = createElectronAppUpdater({ + currentVersion: app.getVersion(), + isPackaged: app.isPackaged, + log: (message) => deps.logInfo(message), + getChannel: () => deps.getUpdatesConfig().channel, + configureHttpExecutor: createNativeUpdaterHttpExecutor, + disableDifferentialDownload: true, + isNativeUpdaterSupported: () => + isNativeUpdaterSupported({ + platform: process.platform, + isPackaged: app.isPackaged, + execPath: process.execPath, + env: process.env, + log: (message) => deps.logWarn(message), + }), + }); + const updateDialogPresenter = createUpdateDialogPresenter({ + platform: process.platform, + focusApp: async () => { + if (process.platform !== 'darwin') { + app.focus({ steal: true }); + return; + } + try { + await app.dock?.show(); + } catch (error) { + deps.logWarn('Failed to show macOS dock before update dialog', error); + } + // app.focus({ steal: true }) alone does not reliably activate the process + // when SubMiner was reached via `subminer -u` (single-instance forwarding + // from a CLI-spawned child). osascript's `activate` uses LaunchServices, + // which is the only path that reliably brings the running app forward. + await new Promise((resolve) => { + execFile( + '/usr/bin/osascript', + ['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`], + { timeout: 2000 }, + (error) => { + if (error) { + deps.logWarn( + `Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`, + ); + } + resolve(); + }, + ); + }); + app.focus({ steal: true }); + }, + withStatsWindowLayerSuspended: (showDialog) => + deps.withStatsWindowLayerSuspendedForNativeDialog(showDialog), + showMessageBox: (options) => dialog.showMessageBox(options), + }); + updateService = createUpdateService({ + getConfig: () => deps.getUpdatesConfig(), + getCurrentVersion: () => app.getVersion(), + now: () => Date.now(), + readState: () => updateStateStore.readState(), + writeState: (state) => updateStateStore.writeState(state), + checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel), + shouldFetchReleaseMetadata: ({ request, appUpdate }) => + shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request), + fetchLatestStableRelease: (channel) => + fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }), + updateLauncher: (launcherPath, channel, release) => + updateLauncherFromSelectedRelease(launcherPath, channel, release), + showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version), + showUpdateAvailableDialog: (version) => + updateDialogPresenter.showUpdateAvailableDialog(version), + showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message), + showManualUpdateRequiredDialog: (version) => + updateDialogPresenter.showManualUpdateRequiredDialog(version), + downloadAppUpdate: () => appUpdater.downloadUpdate(), + showRestartDialog: () => updateDialogPresenter.showRestartDialog(), + quitAndInstall: () => appUpdater.quitAndInstall(), + notifyUpdateAvailable: (version) => + notifyUpdateAvailable( + { notificationType: deps.getUpdatesConfig().notificationType, version }, + { + showSystemNotification: (title, body) => deps.showDesktopNotification(title, { body }), + showOverlayNotification: (payload) => deps.showOverlayNotification(payload), + showOsdNotification: (message) => { + deps.showMpvOsd(message); + }, + log: (message) => deps.logWarn(message), + }, + ), + log: (message) => deps.logWarn(message), + }); + return updateService; + } + + return { getUpdateService }; +} diff --git a/src/main/runtime/update/update-support-assets-runtime.ts b/src/main/runtime/update/update-support-assets-runtime.ts new file mode 100644 index 00000000..fa4634f6 --- /dev/null +++ b/src/main/runtime/update/update-support-assets-runtime.ts @@ -0,0 +1,24 @@ +export async function runSupportAssetUpdatesForLauncherResult< + TLauncherResult, + TSupportResult extends { status: string; command?: string; message?: string }, +>(options: { + launcherResult: TLauncherResult; + assetDescription?: string; + updateSupportAssets: () => Promise; + logWarn: (message: string, details?: unknown) => void; +}): Promise { + const assetDescription = options.assetDescription ?? 'Support asset update'; + try { + const supportResults = await options.updateSupportAssets(); + for (const result of supportResults) { + if (result.status === 'protected' && result.command) { + options.logWarn(`${assetDescription} requires manual command: ${result.command}`); + } else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') { + options.logWarn(`${assetDescription} skipped: ${result.message ?? result.status}`); + } + } + } catch (error) { + options.logWarn('Support asset update failed after launcher update', error); + } + return options.launcherResult; +} diff --git a/src/main/runtime/visible-overlay-interaction-runtime.ts b/src/main/runtime/visible-overlay-interaction-runtime.ts new file mode 100644 index 00000000..7ece7ab9 --- /dev/null +++ b/src/main/runtime/visible-overlay-interaction-runtime.ts @@ -0,0 +1,810 @@ +import { type BrowserWindow, screen } from 'electron'; +import { execFile } from 'node:child_process'; +import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services'; +import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args'; +import type { OverlayContentMeasurement, WindowGeometry } from '../../types'; +import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers'; +import type { BaseWindowTracker } from '../../window-trackers'; +import { + bindWindowsOverlayAboveMpv, + clearWindowsOverlayOwner, + findWindowsMpvTargetWindowHandle, + getWindowsForegroundProcessName, + setWindowsOverlayOwner, +} from '../../window-trackers/windows-helper'; +import { + applyLinuxOverlayInputShape, + applyLinuxOverlayPointerInteractionMousePassthrough, + ensureLinuxOverlayPointerInteractionLoop, + type ForegroundSuppressionGraceState, + mapOverlayMeasurementForPointerInteraction, + resolveForegroundSuppressionWithGrace, + shouldPrimeLinuxOverlayInteractionFromMeasurement, + tickLinuxOverlayPointerInteraction, +} from './linux-overlay-pointer-interaction'; +import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape'; +import { + ensureLinuxOverlayZOrderKeepAliveLoop, + shouldRunLinuxOverlayZOrderKeepAlive, + tickLinuxOverlayZOrderKeepAlive, +} from './linux-overlay-zorder-keepalive'; +import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point'; +import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode'; +import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility'; +import { hasLiveSeparateWindow } from './settings-window-z-order'; + +export interface VisibleOverlayInteractionRuntimeDeps { + overlayManager: { + getMainWindow: () => BrowserWindow | null; + getVisibleOverlayVisible: () => boolean; + }; + overlayContentMeasurementStore: { + clear: (layer: 'visible') => void; + getLatestByLayer: (layer: 'visible') => OverlayContentMeasurement | null; + }; + logger: { + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; + debug: (message: string, ...args: unknown[]) => void; + }; + updateVisibleOverlayVisibility: () => void; + getModalInputExclusive: () => boolean; + getStatsOverlayVisible: () => boolean; + setStatsOverlayVisible: (visible: boolean) => void; + getWindowTracker: () => BaseWindowTracker | null; + setWindowTracker: (tracker: BaseWindowTracker | null) => void; + setTrackerNotReadyWarningShown: (shown: boolean) => void; + getMpvSocketPath: () => string; + getBackendOverride: () => string | null; + getInitialArgs: () => CliArgs | null; + getOverlayRuntimeInitialized: () => boolean; + getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode; + setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void; + bindVisibleOverlayToTrackedX11Window: (window: BrowserWindow) => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + refreshCurrentSubtitle: () => void; + getOverlayWindows: () => BrowserWindow[]; + syncOverlayShortcuts: () => void; + resetLastOverlayWindowGeometry: () => void; + enforceOverlayLayerOrder: () => void; + getOverlayForegroundSeparateWindows: () => BrowserWindow[]; +} + +export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInteractionRuntimeDeps) { + const { overlayManager, overlayContentMeasurementStore, logger } = deps; + + 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; + const LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 1_500; + // Ignore transient "neither mpv nor overlay is the active window" blips before suppressing + // subtitle pointer interaction. Right after playback starts the overlay can briefly become the + // X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s). + const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500; + const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500; + const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200; + let visibleOverlayBlurRefreshTimeouts: Array> = []; + let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; + let windowsVisibleOverlayZOrderSyncInFlight = false; + let windowsVisibleOverlayZOrderSyncQueued = false; + let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; + let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; + let lastWindowsVisibleOverlayBlurredAtMs = 0; + let lastLinuxVisibleOverlayFollowedMpvAtMs = 0; + const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = { + lossSinceMs: null, + }; + let visibleOverlayInteractionActive = false; + let linuxOverlayInputShapeActive = false; + let linuxVisibleOverlayStartupInputPrimed = false; + let linuxVisibleOverlayStartupInputGraceUntilMs = 0; + // Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal + // region is interactive, so the cursor poll keeps the overlay interactive even when the cursor + // moves off measured subtitle/sidebar rects onto the popup. + let linuxOverlayInteractiveHint = false; + let macOSVisibleOverlayForegroundProbeActive = false; + let macOSVisibleOverlayForegroundProbeToken = 0; + let macOSVisibleOverlayForegroundProbeTimeout: ReturnType | null = null; + const linuxVisibleOverlayOwnerBindingQueues = new WeakMap>(); + + const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({ + setStatsOverlayVisibleState: (visible) => { + deps.setStatsOverlayVisible(visible); + }, + resetVisibleOverlayInteraction: () => { + visibleOverlayInteractionActive = false; + }, + getMainWindow: () => overlayManager.getMainWindow(), + updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(), + }); + + function resetVisibleOverlayInputState(): void { + visibleOverlayInteractionActive = false; + linuxOverlayInputShapeActive = false; + resetLinuxVisibleOverlayStartupInputPrimer(); + linuxOverlayInteractiveHint = false; + overlayContentMeasurementStore.clear('visible'); + const mainWindow = overlayManager.getMainWindow(); + if (process.platform === 'linux' && mainWindow && !mainWindow.isDestroyed()) { + restoreLinuxOverlayWindowShape(mainWindow); + } + } + + function restoreVisibleOverlayWindowShapeForShow(): void { + if (process.platform !== 'linux') { + return; + } + restoreLinuxOverlayWindowShape(overlayManager.getMainWindow()); + } + + function clearVisibleOverlayBlurRefreshTimeouts(): void { + for (const timeout of visibleOverlayBlurRefreshTimeouts) { + clearTimeout(timeout); + } + visibleOverlayBlurRefreshTimeouts = []; + } + + function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { + for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) { + clearTimeout(timeout); + } + windowsVisibleOverlayZOrderRetryTimeouts = []; + } + + function finishMacOSVisibleOverlayForegroundProbe(token: number): void { + if (token !== macOSVisibleOverlayForegroundProbeToken) { + return; + } + if (macOSVisibleOverlayForegroundProbeTimeout !== null) { + clearTimeout(macOSVisibleOverlayForegroundProbeTimeout); + macOSVisibleOverlayForegroundProbeTimeout = null; + } + if (!macOSVisibleOverlayForegroundProbeActive) { + return; + } + macOSVisibleOverlayForegroundProbeActive = false; + deps.updateVisibleOverlayVisibility(); + } + + function startMacOSVisibleOverlayForegroundProbe(): void { + if (process.platform !== 'darwin') { + return; + } + const tracker = deps.getWindowTracker(); + if (!tracker) { + return; + } + + macOSVisibleOverlayForegroundProbeActive = true; + const token = ++macOSVisibleOverlayForegroundProbeToken; + if (macOSVisibleOverlayForegroundProbeTimeout !== null) { + clearTimeout(macOSVisibleOverlayForegroundProbeTimeout); + } + macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => { + finishMacOSVisibleOverlayForegroundProbe(token); + }, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS); + + void tracker + .refreshNow() + .catch((error) => { + logger.warn('Failed to refresh macOS frontmost app after overlay blur', error); + }) + .finally(() => { + finishMacOSVisibleOverlayForegroundProbe(token); + }); + } + + function getNativeWindowHandleDecimal(window: BrowserWindow): string { + const handle = window.getNativeWindowHandle(); + return handle.length >= 8 + ? handle.readBigUInt64LE(0).toString() + : BigInt(handle.readUInt32LE(0)).toString(); + } + + function getWindowsNativeWindowHandle(window: BrowserWindow): string { + return getNativeWindowHandleDecimal(window); + } + + function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number { + const handle = window.getNativeWindowHandle(); + return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0); + } + + function enqueueVisibleOverlayX11OwnerBindingOperation( + window: BrowserWindow, + args: string[], + onError?: (error: Error) => void, + ): void { + const previous = linuxVisibleOverlayOwnerBindingQueues.get(window) ?? Promise.resolve(); + const operation = previous + .catch(() => {}) + .then( + () => + new Promise((resolve) => { + if (window.isDestroyed()) { + resolve(); + return; + } + execFile('xprop', args, { timeout: 1500 }, (error) => { + if (error) { + onError?.(error); + } + resolve(); + }); + }), + ); + const queued = operation.finally(() => { + if (linuxVisibleOverlayOwnerBindingQueues.get(window) === queued) { + linuxVisibleOverlayOwnerBindingQueues.delete(window); + } + }); + linuxVisibleOverlayOwnerBindingQueues.set(window, queued); + } + + function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void { + if (window.isDestroyed()) return; + enqueueVisibleOverlayX11OwnerBindingOperation(window, [ + '-id', + getNativeWindowHandleDecimal(window), + '-remove', + 'WM_TRANSIENT_FOR', + ]); + } + + function resolveWindowsOverlayBindTargetHandle( + targetMpvSocketPath?: string | null, + ): number | null { + if (process.platform !== 'win32') { + return null; + } + + try { + if (targetMpvSocketPath) { + const windowTracker = deps.getWindowTracker() as { + getTargetWindowHandle?: () => number | null; + } | null; + const trackedHandle = windowTracker?.getTargetWindowHandle?.(); + if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) { + return trackedHandle; + } + return null; + } + return findWindowsMpvTargetWindowHandle(); + } catch { + return null; + } + } + + function createOverlayWindowTracker( + override?: string | null, + targetMpvSocketPath?: string | null, + ) { + const initialArgs = deps.getInitialArgs(); + if (initialArgs && isHeadlessInitialCommand(initialArgs)) { + return null; + } + return createWindowTrackerCore(override, targetMpvSocketPath); + } + + function bindVisibleOverlayOwner(): void { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + if (process.platform === 'linux') { + deps.bindVisibleOverlayToTrackedX11Window(mainWindow); + return; + } + if (process.platform !== 'win32') return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + const targetSocketPath = deps.getMpvSocketPath(); + const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath); + if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) { + return; + } + if (targetSocketPath) { + return; + } + const tracker = deps.getWindowTracker(); + const mpvResult = tracker + ? (() => { + try { + const win32 = + require('../../window-trackers/win32') as typeof import('../../window-trackers/win32'); + const poll = win32.findMpvWindows(); + const focused = poll.matches.find((m) => m.isForeground); + return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null; + } catch { + return null; + } + })() + : null; + if (!mpvResult) return; + if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) { + logger.warn('Failed to set overlay owner via koffi'); + } + } + + function releaseVisibleOverlayOwner(): void { + const mainWindow = overlayManager.getMainWindow(); + if (process.platform === 'linux') { + deps.setLinuxVisibleOverlayOwnerBindingKey(null); + if (mainWindow && !mainWindow.isDestroyed()) { + clearVisibleOverlayX11OwnerBinding(mainWindow); + } + return; + } + if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + if (!clearWindowsOverlayOwner(overlayHwnd)) { + logger.warn('Failed to clear overlay owner via koffi'); + } + } + + function startOverlayWindowTrackerForCurrentSocket(): void { + startOverlayWindowTrackerCore({ + backendOverride: deps.getBackendOverride(), + getMpvSocketPath: () => deps.getMpvSocketPath(), + createWindowTracker: createOverlayWindowTracker, + setWindowTracker: (tracker) => { + deps.setWindowTracker(tracker); + }, + updateVisibleOverlayBounds: (geometry: WindowGeometry) => + deps.updateVisibleOverlayBounds(geometry), + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(), + refreshCurrentSubtitle: () => { + deps.refreshCurrentSubtitle(); + }, + getOverlayWindows: () => deps.getOverlayWindows(), + syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), + bindOverlayOwner: () => bindVisibleOverlayOwner(), + releaseOverlayOwner: () => releaseVisibleOverlayOwner(), + }); + } + + function retargetOverlayWindowTrackerForMpvSocket( + nextSocketPath: string, + previousSocketPath: string, + ): void { + if (nextSocketPath === previousSocketPath || !deps.getOverlayRuntimeInitialized()) { + return; + } + + const previousTracker = deps.getWindowTracker(); + if (previousTracker) { + try { + previousTracker.stop(); + } catch (error) { + logger.warn('Failed to stop previous overlay window tracker before retargeting', error); + } + } + + releaseVisibleOverlayOwner(); + deps.setWindowTracker(null); + deps.setTrackerNotReadyWarningShown(false); + deps.resetLastOverlayWindowGeometry(); + startOverlayWindowTrackerForCurrentSocket(); + deps.updateVisibleOverlayVisibility(); + deps.syncOverlayShortcuts(); + logger.info( + `Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`, + ); + } + + async function syncWindowsVisibleOverlayToMpvZOrder(): Promise { + if (process.platform !== 'win32') { + return false; + } + + const mainWindow = overlayManager.getMainWindow(); + if ( + !mainWindow || + mainWindow.isDestroyed() || + !mainWindow.isVisible() || + !overlayManager.getVisibleOverlayVisible() + ) { + return false; + } + + const windowTracker = deps.getWindowTracker(); + if (!windowTracker) { + return false; + } + + if ( + typeof windowTracker.isTargetWindowMinimized === 'function' && + windowTracker.isTargetWindowMinimized() + ) { + return false; + } + + if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) { + return false; + } + + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(deps.getMpvSocketPath()); + if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) { + (mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1); + return true; + } + return false; + } + + function requestWindowsVisibleOverlayZOrderSync(): void { + if (process.platform !== 'win32') { + return; + } + + if (windowsVisibleOverlayZOrderSyncInFlight) { + windowsVisibleOverlayZOrderSyncQueued = true; + return; + } + + windowsVisibleOverlayZOrderSyncInFlight = true; + void syncWindowsVisibleOverlayToMpvZOrder() + .catch((error) => { + logger.warn('Failed to bind Windows overlay z-order to mpv', error); + }) + .finally(() => { + windowsVisibleOverlayZOrderSyncInFlight = false; + if (!windowsVisibleOverlayZOrderSyncQueued) { + return; + } + + windowsVisibleOverlayZOrderSyncQueued = false; + requestWindowsVisibleOverlayZOrderSync(); + }); + } + + function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void { + if (process.platform !== 'win32') { + return; + } + + clearWindowsVisibleOverlayZOrderRetryTimeouts(); + for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) { + const retryTimeout = setTimeout(() => { + windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter( + (timeout) => timeout !== retryTimeout, + ); + requestWindowsVisibleOverlayZOrderSync(); + }, delayMs); + windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout); + } + } + + function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean { + return ( + process.platform === 'win32' && + lastWindowsVisibleOverlayBlurredAtMs > 0 && + Date.now() - lastWindowsVisibleOverlayBlurredAtMs <= + WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS + ); + } + + function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean { + if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) { + return false; + } + + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return false; + } + + const windowTracker = deps.getWindowTracker(); + if (!windowTracker) { + return false; + } + + if ( + typeof windowTracker.isTargetWindowMinimized === 'function' && + windowTracker.isTargetWindowMinimized() + ) { + return false; + } + + const overlayFocused = mainWindow.isFocused(); + const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false; + return !overlayFocused && !trackerFocused; + } + + function maybePollWindowsVisibleOverlayForegroundProcess(): void { + if (!shouldPollWindowsVisibleOverlayForegroundProcess()) { + lastWindowsVisibleOverlayForegroundProcessName = null; + return; + } + + const processName = getWindowsForegroundProcessName(); + const normalizedProcessName = processName?.trim().toLowerCase() ?? null; + const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName; + lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName; + + if (normalizedProcessName !== previousProcessName) { + deps.updateVisibleOverlayVisibility(); + } + if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') { + requestWindowsVisibleOverlayZOrderSync(); + } + } + + function ensureWindowsVisibleOverlayForegroundPollLoop(): void { + if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) { + return; + } + + windowsVisibleOverlayForegroundPollInterval = setInterval(() => { + maybePollWindowsVisibleOverlayForegroundProcess(); + }, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS); + } + + function clearWindowsVisibleOverlayForegroundPollLoop(): void { + if (windowsVisibleOverlayForegroundPollInterval === null) { + return; + } + + clearInterval(windowsVisibleOverlayForegroundPollInterval); + windowsVisibleOverlayForegroundPollInterval = null; + } + + function scheduleVisibleOverlayBlurRefresh(): void { + if (process.platform !== 'win32' && process.platform !== 'darwin') { + return; + } + + if (process.platform === 'win32') { + lastWindowsVisibleOverlayBlurredAtMs = Date.now(); + } + startMacOSVisibleOverlayForegroundProbe(); + clearVisibleOverlayBlurRefreshTimeouts(); + for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { + const refreshTimeout = setTimeout(() => { + visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter( + (timeout) => timeout !== refreshTimeout, + ); + deps.updateVisibleOverlayVisibility(); + }, delayMs); + visibleOverlayBlurRefreshTimeouts.push(refreshTimeout); + } + } + + ensureWindowsVisibleOverlayForegroundPollLoop(); + + const linuxX11CursorPointReader = createLinuxX11CursorPointReader(); + + function getLinuxOverlayPointerMeasurement() { + const measurement = overlayContentMeasurementStore.getLatestByLayer('visible'); + return mapOverlayMeasurementForPointerInteraction(measurement); + } + + function shouldSuspendLinuxOverlayPointerInteraction(): boolean { + return deps.getModalInputExclusive() || deps.getStatsOverlayVisible(); + } + + function shouldSuppressLinuxOverlayPointerInteraction(): boolean { + return resolveForegroundSuppressionWithGrace({ + hasForegroundSeparateWindow: hasLiveSeparateWindow( + deps.getOverlayForegroundSeparateWindows(), + ), + isTrackingMpvWindow: Boolean(deps.getWindowTracker()?.isTracking()), + isMpvWindowFocused: deps.getWindowTracker()?.isTargetWindowFocused?.() !== false, + isOverlayWindowFocused: overlayManager.getMainWindow()?.isFocused() === true, + nowMs: Date.now(), + graceMs: LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS, + state: linuxPointerForegroundSuppressionGrace, + }); + } + + function shouldUseLinuxOverlayInputShape(): boolean { + // Electron's setShape is a *bounding* shape: outside the given rects no pixels are drawn, so + // it clips the visible subtitle (and makes a dragged subtitle vanish behind the shaped + // region). There is no input-only region API on Linux, so selective hit-testing is handled by + // the main-process cursor poll instead. Keep this off to avoid clipping the overlay. + return false; + } + + function hasLinuxVisibleOverlayStartupInputGrace(): boolean { + return ( + process.platform === 'linux' && + linuxVisibleOverlayStartupInputGraceUntilMs > 0 && + Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs + ); + } + + function clearLinuxVisibleOverlayStartupInputGrace(): void { + linuxVisibleOverlayStartupInputGraceUntilMs = 0; + } + + function resetLinuxVisibleOverlayStartupInputPrimer(): void { + linuxVisibleOverlayStartupInputPrimed = false; + clearLinuxVisibleOverlayStartupInputGrace(); + } + + function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean { + if (!shouldUseLinuxOverlayInputShape()) { + linuxOverlayInputShapeActive = false; + return false; + } + + const result = applyLinuxOverlayInputShape({ + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, + getRendererInteractiveHint: () => linuxOverlayInteractiveHint, + shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, + shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, + }); + linuxOverlayInputShapeActive = result.active; + return result.handled; + } + + function updateLinuxOverlayPointerInteractionActive(active: boolean): void { + visibleOverlayInteractionActive = active; + if ( + process.platform === 'linux' && + applyLinuxOverlayPointerInteractionMousePassthrough({ + active, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, + shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, + updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(), + }) + ) { + return; + } + + deps.updateVisibleOverlayVisibility(); + } + + function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void { + if (process.platform !== 'linux') return; + if (linuxVisibleOverlayStartupInputPrimed) return; + if (shouldUseLinuxOverlayInputShape()) return; + if ( + !shouldPrimeLinuxOverlayInteractionFromMeasurement({ + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, + shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, + shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, + }) + ) { + return; + } + + linuxVisibleOverlayStartupInputPrimed = true; + linuxVisibleOverlayStartupInputGraceUntilMs = + Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS; + updateLinuxOverlayPointerInteractionActive(true); + } + + const linuxOverlayZOrderKeepAliveDeps = { + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + isTrackingMpvWindow: () => Boolean(deps.getWindowTracker()?.isTracking()), + isMpvWindowFocused: () => deps.getWindowTracker()?.isTargetWindowFocused?.() !== false, + isOverlayWindowFocused: () => overlayManager.getMainWindow()?.isFocused() === true, + shouldSuppressReassert: () => + deps.getModalInputExclusive() || + deps.getStatsOverlayVisible() || + hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows()) || + (visibleOverlayInteractionActive && overlayManager.getMainWindow()?.isFocused() !== true), + raiseMpvWindow: () => { + if ( + lastLinuxVisibleOverlayFollowedMpvAtMs > 0 && + Date.now() - lastLinuxVisibleOverlayFollowedMpvAtMs <= + LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS + ) { + return Promise.resolve(false); + } + lastLinuxVisibleOverlayFollowedMpvAtMs = Date.now(); + return deps.getWindowTracker()?.raiseTargetWindow?.() ?? Promise.resolve(false); + }, + releaseOverlayLayerOrder: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.setAlwaysOnTop(false); + mainWindow.setFullScreen?.(false); + mainWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false }); + if ( + deps.getLinuxVisibleOverlayWindowMode() === 'fullscreen-override' && + mainWindow.isVisible() + ) { + mainWindow.hide(); + } + }, + enforceOverlayLayerOrder: () => { + deps.enforceOverlayLayerOrder(); + }, + focusOverlayWindow: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isFocused()) return; + mainWindow.focus(); + }, + }; + + function requestLinuxOverlayZOrderFollow(): void { + if (!shouldRunLinuxOverlayZOrderKeepAlive()) return; + void tickLinuxOverlayZOrderKeepAlive(linuxOverlayZOrderKeepAliveDeps).catch((error) => { + logger.debug( + 'Failed to follow tracked mpv behind focused overlay:', + error instanceof Error ? error.message : String(error), + ); + }); + } + + ensureLinuxOverlayZOrderKeepAliveLoop(linuxOverlayZOrderKeepAliveDeps); + + const linuxOverlayPointerInteractionDeps = { + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + getCursorScreenPoint: () => + linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()), + getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, + getRendererInteractiveHint: () => + linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(), + shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, + shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, + shouldUseInputShape: shouldUseLinuxOverlayInputShape, + getInteractionActive: () => visibleOverlayInteractionActive, + setInteractionActive: updateLinuxOverlayPointerInteractionActive, + }; + + function tickLinuxOverlayPointerInteractionNow(): void { + if (applyLinuxOverlayInputShapeFromLatestMeasurement()) { + return; + } + tickLinuxOverlayPointerInteraction(linuxOverlayPointerInteractionDeps); + } + + ensureLinuxOverlayPointerInteractionLoop(linuxOverlayPointerInteractionDeps); + + return { + handleStatsOverlayVisibilityChanged, + resetVisibleOverlayInputState, + restoreVisibleOverlayWindowShapeForShow, + startMacOSVisibleOverlayForegroundProbe, + getNativeWindowHandleDecimal, + getWindowsNativeWindowHandle, + getWindowsNativeWindowHandleNumber, + enqueueVisibleOverlayX11OwnerBindingOperation, + clearVisibleOverlayX11OwnerBinding, + createOverlayWindowTracker, + bindVisibleOverlayOwner, + releaseVisibleOverlayOwner, + startOverlayWindowTrackerForCurrentSocket, + retargetOverlayWindowTrackerForMpvSocket, + requestWindowsVisibleOverlayZOrderSync, + scheduleWindowsVisibleOverlayZOrderSyncBurst, + hasWindowsVisibleOverlayFocusHandoffGrace, + ensureWindowsVisibleOverlayForegroundPollLoop, + clearWindowsVisibleOverlayForegroundPollLoop, + scheduleVisibleOverlayBlurRefresh, + getLinuxOverlayPointerMeasurement, + hasLinuxVisibleOverlayStartupInputGrace, + clearLinuxVisibleOverlayStartupInputGrace, + resetLinuxVisibleOverlayStartupInputPrimer, + applyLinuxOverlayInputShapeFromLatestMeasurement, + updateLinuxOverlayPointerInteractionActive, + primeLinuxOverlayPointerInteractionAfterFirstMeasurement, + requestLinuxOverlayZOrderFollow, + tickLinuxOverlayPointerInteractionNow, + getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive, + setVisibleOverlayInteractionActive: (active: boolean) => { + visibleOverlayInteractionActive = active; + }, + getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive, + getLastWindowsVisibleOverlayForegroundProcessName: () => + lastWindowsVisibleOverlayForegroundProcessName, + getMacOSVisibleOverlayForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive, + setLinuxOverlayInteractiveHint: (interactive: boolean) => { + linuxOverlayInteractiveHint = interactive; + }, + }; +} + +export type VisibleOverlayInteractionRuntime = ReturnType< + typeof createVisibleOverlayInteractionRuntime +>; diff --git a/src/main/runtime/windows-mpv-plugin-detection.test.ts b/src/main/runtime/windows-mpv-plugin-detection.test.ts new file mode 100644 index 00000000..ba917c6d --- /dev/null +++ b/src/main/runtime/windows-mpv-plugin-detection.test.ts @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { detectWindowsMpvPluginRemovalCandidates } from './first-run-setup-plugin'; + +test('Windows plugin removal candidates include portable directory installs', () => { + const mpvPath = 'C:\\tools\\mpv\\mpv.exe'; + const portablePluginDir = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer'; + const existing = new Set([portablePluginDir]); + + const candidates = detectWindowsMpvPluginRemovalCandidates({ + homeDir: 'C:\\Users\\tester', + appDataDir: 'C:\\Users\\tester\\AppData\\Roaming', + mpvExecutablePath: mpvPath, + existsSync: (candidate) => existing.has(candidate), + }); + + assert.deepEqual(candidates, [{ path: portablePluginDir, kind: 'directory' }]); +}); diff --git a/src/main/runtime/windows-mpv-plugin-detection.ts b/src/main/runtime/windows-mpv-plugin-detection.ts new file mode 100644 index 00000000..b1876c8f --- /dev/null +++ b/src/main/runtime/windows-mpv-plugin-detection.ts @@ -0,0 +1,121 @@ +import { app, dialog, shell } from 'electron'; +import * as os from 'os'; +import { + detectInstalledMpvPlugin, + detectWindowsMpvPluginRemovalCandidates, + removeLegacyMpvPluginCandidates, + resolvePackagedRuntimePluginPath, +} from './first-run-setup-plugin'; + +export interface WindowsMpvPluginDetectionRuntimeDeps { + mainDirname: string; + logWarn: (message: string) => void; +} + +export function createWindowsMpvPluginDetectionRuntime( + deps: WindowsMpvPluginDetectionRuntimeDeps, +): { + resolveBundledMpvRuntimePluginEntrypoint: () => string | undefined; + detectWindowsInstalledMpvPlugin: ( + mpvExecutablePath: string, + ) => ReturnType; + logInstalledMpvPluginDetected: (detection: { + path: string | null; + version: string | null; + }) => void; + promptForLegacyMpvPluginRemovalBeforeWindowsLaunch: ( + mpvPath: string, + detection: { path: string | null; version: string | null }, + ) => Promise<'removed' | 'continue' | 'cancel'>; +} { + function resolveBundledMpvRuntimePluginEntrypoint(): string | undefined { + return ( + resolvePackagedRuntimePluginPath({ + dirname: deps.mainDirname, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + }) ?? undefined + ); + } + + function detectWindowsInstalledMpvPlugin(mpvExecutablePath: string) { + return detectInstalledMpvPlugin({ + platform: 'win32', + homeDir: os.homedir(), + appDataDir: app.getPath('appData'), + mpvExecutablePath, + }); + } + + function logInstalledMpvPluginDetected(detection: { + path: string | null; + version: string | null; + }) { + if (!detection.path) return; + deps.logWarn( + `SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`, + ); + } + + async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch( + mpvPath: string, + detection: { path: string | null; version: string | null }, + ): Promise<'removed' | 'continue' | 'cancel'> { + const response = await dialog.showMessageBox({ + type: 'warning', + title: 'SubMiner mpv plugin detected', + message: [ + 'SubMiner detected an installed mpv plugin at:', + detection.path ?? 'unknown path', + '', + "This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.", + `Detected plugin version: ${detection.version ?? 'unknown or legacy'}`, + ].join('\n'), + detail: + 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.', + buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'], + defaultId: 0, + cancelId: 2, + }); + + if (response.response === 2) { + return 'cancel'; + } + if (response.response === 1) { + return 'continue'; + } + + const result = await removeLegacyMpvPluginCandidates({ + candidates: detectWindowsMpvPluginRemovalCandidates({ + homeDir: os.homedir(), + appDataDir: app.getPath('appData'), + mpvExecutablePath: mpvPath, + }), + trashItem: (candidatePath) => shell.trashItem(candidatePath), + }); + if (result.ok) { + await dialog.showMessageBox({ + type: 'info', + title: 'Legacy mpv plugin removed', + message: + 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.', + }); + return 'removed'; + } + + await dialog.showMessageBox({ + type: 'error', + title: 'Could not remove legacy mpv plugin', + message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.', + detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'), + }); + return 'cancel'; + } + + return { + resolveBundledMpvRuntimePluginEntrypoint, + detectWindowsInstalledMpvPlugin, + logInstalledMpvPluginDetected, + promptForLegacyMpvPluginRemovalBeforeWindowsLaunch, + }; +} diff --git a/src/main/runtime/yomitan-anki-server-sync.test.ts b/src/main/runtime/yomitan-anki-server-sync.test.ts new file mode 100644 index 00000000..4ccf7adf --- /dev/null +++ b/src/main/runtime/yomitan-anki-server-sync.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync'; + +test('buildYomitanAnkiSettingsKey includes force override policy', () => { + assert.notEqual( + buildYomitanAnkiSettingsKey({ + targetUrl: 'http://127.0.0.1:8766', + targetDeck: 'Mining', + forceOverride: false, + }), + buildYomitanAnkiSettingsKey({ + targetUrl: 'http://127.0.0.1:8766', + targetDeck: 'Mining', + forceOverride: true, + }), + ); +}); diff --git a/src/main/runtime/yomitan-anki-server-sync.ts b/src/main/runtime/yomitan-anki-server-sync.ts new file mode 100644 index 00000000..158a2092 --- /dev/null +++ b/src/main/runtime/yomitan-anki-server-sync.ts @@ -0,0 +1,76 @@ +import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services'; +import type { ResolvedConfig } from '../../types'; +import { + getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, + shouldForceOverrideYomitanAnkiServer, +} from './yomitan-anki-server'; + +export interface YomitanAnkiServerSyncRuntimeDeps { + isExternalReadOnlyMode: () => boolean; + getResolvedConfig: () => ResolvedConfig; + getYomitanParserRuntimeDeps: () => Parameters[1]; + logError: (message: string, ...args: unknown[]) => void; + logInfo: (message: string, ...args: unknown[]) => void; +} + +export function buildYomitanAnkiSettingsKey(options: { + targetUrl: string; + targetDeck: string; + forceOverride: boolean; +}): string { + return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`; +} + +export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): { + syncYomitanDefaultProfileAnkiServer: () => Promise; +} { + let lastSyncedYomitanAnkiSettingsKey: string | null = null; + + function getPreferredYomitanAnkiServerUrl(): string { + return getPreferredYomitanAnkiServerUrlRuntime(deps.getResolvedConfig().ankiConnect); + } + + async function syncYomitanDefaultProfileAnkiServer(): Promise { + if (deps.isExternalReadOnlyMode()) { + return; + } + + const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); + const ankiConnectConfig = deps.getResolvedConfig().ankiConnect; + const targetDeck = ankiConnectConfig?.deck?.trim() ?? ''; + const forceOverride = ankiConnectConfig + ? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig) + : false; + const targetSettingsKey = buildYomitanAnkiSettingsKey({ + targetUrl, + targetDeck, + forceOverride, + }); + if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) { + return; + } + + const synced = await syncYomitanDefaultAnkiServerCore( + targetUrl, + deps.getYomitanParserRuntimeDeps(), + { + error: (message, ...args) => { + deps.logError(message, ...args); + }, + info: (message, ...args) => { + deps.logInfo(message, ...args); + }, + }, + { + forceOverride, + deck: targetDeck, + }, + ); + + if (synced) { + lastSyncedYomitanAnkiSettingsKey = targetSettingsKey; + } + } + + return { syncYomitanDefaultProfileAnkiServer }; +} diff --git a/src/stats-daemon-control.test.ts b/src/stats-daemon-control.test.ts index acf5f535..38f238ee 100644 --- a/src/stats-daemon-control.test.ts +++ b/src/stats-daemon-control.test.ts @@ -123,6 +123,10 @@ test('stats daemon control stops live daemon and treats stale state as success', calls.push(`isProcessAlive:${pid}:${aliveChecks}`); return aliveChecks === 1; }, + verifyProcessIdentity: (pid, startedAtMs) => { + calls.push(`verifyProcessIdentity:${pid}:${startedAtMs}`); + return true; + }, resolveUrl: (state) => `http://127.0.0.1:${state.port}`, spawnDaemon: async () => 1, waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), @@ -147,6 +151,7 @@ test('stats daemon control stops live daemon and treats stale state as success', assert.equal(exitCode, 0); assert.deepEqual(calls, [ 'isProcessAlive:4242:1', + 'verifyProcessIdentity:4242:1', 'killProcess:4242:SIGTERM', 'isProcessAlive:4242:2', 'removeState', @@ -158,3 +163,47 @@ test('stats daemon control stops live daemon and treats stale state as success', }, ]); }); + +test('stats daemon control clears stale state when daemon identity mismatches', async () => { + const calls: string[] = []; + const responses: Array<{ path: string; payload: { ok: boolean; url?: string; error?: string } }> = + []; + const handler = createRunStatsDaemonControlHandler({ + statePath: '/tmp/stats-daemon.json', + readState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }), + removeState: () => { + calls.push('removeState'); + }, + isProcessAlive: (pid) => { + calls.push(`isProcessAlive:${pid}`); + return true; + }, + verifyProcessIdentity: (pid, startedAtMs) => { + calls.push(`verifyProcessIdentity:${pid}:${startedAtMs}`); + return false; + }, + resolveUrl: (state) => `http://127.0.0.1:${state.port}`, + spawnDaemon: async () => 1, + waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), + openExternal: async () => {}, + writeResponse: (responsePath, payload) => { + responses.push({ path: responsePath, payload }); + }, + killProcess: () => { + calls.push('killProcess'); + }, + sleep: async () => {}, + }); + + const exitCode = await handler({ + action: 'stop', + responsePath: '/tmp/response.json', + openBrowser: false, + daemonScriptPath: '/tmp/stats-daemon-runner.js', + userDataPath: '/tmp/SubMiner', + }); + + assert.equal(exitCode, 0); + assert.deepEqual(calls, ['isProcessAlive:4242', 'verifyProcessIdentity:4242:1', 'removeState']); + assert.deepEqual(responses, [{ path: '/tmp/response.json', payload: { ok: true } }]); +}); diff --git a/src/stats-daemon-control.ts b/src/stats-daemon-control.ts index a51e6a61..ca9b8b82 100644 --- a/src/stats-daemon-control.ts +++ b/src/stats-daemon-control.ts @@ -1,4 +1,5 @@ import type { BackgroundStatsServerState } from './main/runtime/stats-daemon'; +import { verifyBackgroundStatsServerIdentity } from './main/runtime/stats-daemon'; import type { StatsCliCommandResponse } from './main/runtime/stats-cli-command'; export type StatsDaemonControlAction = 'start' | 'stop'; @@ -22,6 +23,7 @@ export function createRunStatsDaemonControlHandler(deps: { readState: () => BackgroundStatsServerState | null; removeState: () => void; isProcessAlive: (pid: number) => boolean; + verifyProcessIdentity?: (pid: number, startedAtMs: number) => boolean; resolveUrl: (state: Pick) => string; spawnDaemon: (options: SpawnStatsDaemonOptions) => Promise | number; waitForDaemonResponse: (responsePath: string) => Promise; @@ -81,6 +83,12 @@ export function createRunStatsDaemonControlHandler(deps: { writeResponseSafe(args.responsePath, { ok: true }); return 0; } + const verifyProcessIdentity = deps.verifyProcessIdentity ?? verifyBackgroundStatsServerIdentity; + if (!verifyProcessIdentity(state.pid, state.startedAtMs)) { + deps.removeState(); + writeResponseSafe(args.responsePath, { ok: true }); + return 0; + } deps.killProcess(state.pid, 'SIGTERM'); const deadline = Date.now() + 2_000;