mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 01:55:51 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f73fe179d0
|
|||
| 2938e7a32a | |||
| 82f6b4705a | |||
| a02c33dac4 |
@@ -0,0 +1,5 @@
|
||||
type: internal
|
||||
area: docs
|
||||
|
||||
- Excluded the `/main/` and `/v/<version>/` docs trees from search indexing with a self-referential canonical, `noindex,follow`, and a matching `X-Robots-Tag` header, so crawlers spend their budget on the current docs instead of ~30 archived copies of every page.
|
||||
- Restored `<lastmod>` dates in the docs sitemap, which were silently dropped because production builds render from an untracked release snapshot.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change.
|
||||
- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed system-wide mouse lag on Windows while SubMiner is running: the overlay no longer installs Electron's global mouse hook for click-through forwarding, and the mpv window tracker no longer blocks the app on repeated PowerShell command-line lookups.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
||||
@@ -26,6 +27,9 @@ function optionalEnv(value: string | undefined): string | undefined {
|
||||
const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
|
||||
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
||||
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
||||
// The tracked `docs-site/` checkout, which stays a git working tree even when
|
||||
// `docsSourceDir` points at an untracked release snapshot. Used for git lookups only.
|
||||
const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd();
|
||||
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
||||
@@ -82,15 +86,18 @@ function pageToRoute(page: string): string | null {
|
||||
return route ? `/${route}` : '/';
|
||||
}
|
||||
|
||||
// Only the root channel is indexable. `main` and every /v/<version>/ archive are
|
||||
// near-verbatim copies of it, so they own their URL via a self-referential canonical
|
||||
// and are excluded from the index instead of being consolidated onto root. Uniform
|
||||
// self-canonical plus noindex avoids mixing noindex with a cross-page canonical,
|
||||
// which Google treats as a conflicting signal.
|
||||
const isIndexableChannel = channel === 'stable-root';
|
||||
|
||||
function pageToCanonicalHref(page: string): string | null {
|
||||
const route = pageToRoute(page);
|
||||
if (!route) return null;
|
||||
|
||||
if (channel === 'main') {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
if (channel === 'stable-archive' && docsVersion !== latestStable) {
|
||||
if (!isIndexableChannel) {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
@@ -106,7 +113,9 @@ function transformPageHead({ page }: TransformContext): HeadConfig[] {
|
||||
const href = pageToCanonicalHref(page);
|
||||
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
|
||||
|
||||
if (channel === 'main') {
|
||||
// Crawlable so links still pass through, but out of the index: ~30 archived copies
|
||||
// of every page otherwise soak up the crawl budget the current docs need.
|
||||
if (!isIndexableChannel) {
|
||||
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
}
|
||||
|
||||
@@ -287,6 +296,39 @@ const versionItems = [
|
||||
})),
|
||||
];
|
||||
|
||||
function sitemapUrlToPage(url: string): string {
|
||||
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
|
||||
return route ? `${route}.md` : 'index.md';
|
||||
}
|
||||
|
||||
// VitePress derives <lastmod> by running `git log` inside its source dir. Production
|
||||
// builds point that at an untracked snapshot of the release tag, so the lookup comes
|
||||
// back empty and the sitemap ships with no dates at all. Resolve it from the tracked
|
||||
// checkout at the ref being built instead.
|
||||
function lastModifiedFor(url: string): string | undefined {
|
||||
const ref = docsVersion && docsVersion !== 'main' ? docsVersion : 'HEAD';
|
||||
const result = spawnSync('git', ['log', '-1', '--format=%cI', ref, '--', sitemapUrlToPage(url)], {
|
||||
cwd: repoDocsDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
return (result.status === 0 && result.stdout.trim()) || undefined;
|
||||
}
|
||||
|
||||
// Only the root channel publishes a sitemap. Archived and `main` builds would emit
|
||||
// their own copies listing the same canonical URLs, which just advertises the
|
||||
// duplicate trees we are trying to keep out of the index.
|
||||
const sitemap: UserConfig['sitemap'] = isIndexableChannel
|
||||
? {
|
||||
hostname: DOCS_HOSTNAME,
|
||||
transformItems(items) {
|
||||
return items
|
||||
.filter((item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`)
|
||||
.map((item) => ({ ...item, lastmod: item.lastmod ?? lastModifiedFor(item.url) }));
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const nav: DefaultTheme.NavItem[] = [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Get Started', link: '/installation' },
|
||||
@@ -419,14 +461,7 @@ const config: UserConfig = {
|
||||
appearance: 'dark',
|
||||
cleanUrls: true,
|
||||
metaChunk: true,
|
||||
sitemap: {
|
||||
hostname: DOCS_HOSTNAME,
|
||||
transformItems(items) {
|
||||
return items.filter(
|
||||
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
|
||||
);
|
||||
},
|
||||
},
|
||||
sitemap,
|
||||
transformHead: transformPageHead,
|
||||
lastUpdated: true,
|
||||
srcExclude: ['subagents/**', 'README.md'],
|
||||
|
||||
+3
-1
@@ -38,8 +38,10 @@ bun run docs:dev
|
||||
The public docs root is stable-only:
|
||||
|
||||
- `/` serves the latest stable release docs.
|
||||
- `/main/` serves development docs from `main` and is marked `noindex,follow`.
|
||||
- `/main/` serves development docs from `main`.
|
||||
- `/v/<version>/` serves stable release archives.
|
||||
- Prerelease tags do not update the docs site.
|
||||
|
||||
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, and the generated `_headers` file repeats that as an `X-Robots-Tag`. They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
|
||||
|
||||
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
|
||||
|
||||
+49
-21
@@ -56,34 +56,43 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
|
||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
|
||||
]);
|
||||
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
expect(mainDocsConfig.sitemap).toBeUndefined();
|
||||
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
});
|
||||
|
||||
test('latest stable archive canonical points to root equivalent', async () => {
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
|
||||
test.each([
|
||||
['latest stable', 'v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'],
|
||||
['superseded', 'v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'],
|
||||
])(
|
||||
'%s archive keeps a self-referential canonical and stays out of the index',
|
||||
async (_label, version, base, expectedCanonical) => {
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = base;
|
||||
process.env.SUBMINER_DOCS_VERSION = version;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
try {
|
||||
const { default: archiveConfig } = await import(`./.vitepress/config?archive-${version}`);
|
||||
|
||||
const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||
const head = await archiveConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||
|
||||
expect(head).toContainEqual([
|
||||
'link',
|
||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
|
||||
]);
|
||||
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
});
|
||||
expect(head).toContainEqual(['link', { rel: 'canonical', href: expectedCanonical }]);
|
||||
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
// A sitemap here would advertise the archive tree we just excluded.
|
||||
expect(archiveConfig.sitemap).toBeUndefined();
|
||||
} finally {
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('stable archive theme links stay on the selected version', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
@@ -433,3 +442,22 @@ test('docs sitemap excludes duplicate README page from indexable URLs', async ()
|
||||
|
||||
expect(transformedItems?.map((item) => item.url)).toEqual(['', 'usage']);
|
||||
});
|
||||
|
||||
test('docs sitemap dates every URL from the tracked checkout', async () => {
|
||||
const previousRepoDir = process.env.SUBMINER_DOCS_REPO_DIR;
|
||||
// Production builds render from an untracked snapshot, so the date has to come from
|
||||
// the real checkout rather than VitePress's own srcDir git lookup.
|
||||
process.env.SUBMINER_DOCS_REPO_DIR = docsSiteDir;
|
||||
try {
|
||||
const { default: sitemapConfig } = await import('./.vitepress/config?sitemap-lastmod');
|
||||
|
||||
const items = await sitemapConfig.sitemap?.transformItems?.([{ url: '' }, { url: 'usage' }]);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
for (const item of items ?? []) {
|
||||
expect(item.lastmod).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
}
|
||||
} finally {
|
||||
process.env.SUBMINER_DOCS_REPO_DIR = previousRepoDir;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,7 +129,11 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
|
||||
overlay window remained mapped above mpv.
|
||||
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
||||
the Linux cursor-poll fallback, not bounding-shape clipping.
|
||||
the Linux cursor-poll fallback, not bounding-shape clipping. Note that on Windows click-through
|
||||
must go through `applyOverlayClickThrough()` (`src/core/services/overlay-click-through.ts`),
|
||||
which omits `forward: true` there: Electron implements forwarding with a global low-level mouse
|
||||
hook that lags mouse input system-wide whenever the main thread stalls; the Windows cursor poll
|
||||
handles overlay wake-up instead.
|
||||
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
|
||||
interaction state is already inactive. The next cursor-poll tick must still reapply
|
||||
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
|
||||
|
||||
@@ -106,8 +106,11 @@ function M.create(ctx)
|
||||
|
||||
local function get_subtitle_ass_property()
|
||||
local ass_text = mp.get_property("sub-text/ass")
|
||||
if type(ass_text) == "string" and ass_text ~= "" then
|
||||
return ass_text
|
||||
if ass_text ~= nil then
|
||||
if type(ass_text) == "string" and ass_text ~= "" then
|
||||
return ass_text
|
||||
end
|
||||
return nil
|
||||
end
|
||||
ass_text = mp.get_property("sub-text-ass")
|
||||
if type(ass_text) == "string" and ass_text ~= "" then
|
||||
|
||||
@@ -232,7 +232,7 @@ function M.create(ctx)
|
||||
elseif action_id == "triggerFieldGrouping" then
|
||||
return { "--trigger-field-grouping" }
|
||||
elseif action_id == "triggerSubsync" then
|
||||
return { "--trigger-subsync" }
|
||||
return { "--session-action", '{"actionId":"triggerSubsync"}' }
|
||||
elseif action_id == "mineSentence" then
|
||||
return { "--mine-sentence" }
|
||||
elseif action_id == "mineSentenceMultiple" then
|
||||
@@ -251,7 +251,7 @@ function M.create(ctx)
|
||||
elseif action_id == "markWatched" then
|
||||
return { "--mark-watched" }
|
||||
elseif action_id == "openRuntimeOptions" then
|
||||
return { "--open-runtime-options" }
|
||||
return { "--session-action", '{"actionId":"openRuntimeOptions"}' }
|
||||
elseif action_id == "openJimaku" then
|
||||
return { "--open-jimaku" }
|
||||
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
|
||||
@@ -259,7 +259,7 @@ function M.create(ctx)
|
||||
elseif action_id == "openYoutubePicker" then
|
||||
return { "--open-youtube-picker" }
|
||||
elseif action_id == "openSessionHelp" then
|
||||
return { "--open-session-help" }
|
||||
return { "--session-action", '{"actionId":"openSessionHelp"}' }
|
||||
elseif action_id == "openCharacterDictionaryManager" then
|
||||
return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
|
||||
elseif action_id == "openControllerSelect" then
|
||||
|
||||
+13
-1
@@ -4,6 +4,7 @@ function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local input = ctx.input
|
||||
local process = ctx.process
|
||||
local state = ctx.state
|
||||
local subminer_log = ctx.log.subminer_log
|
||||
local show_osd = ctx.log.show_osd
|
||||
|
||||
@@ -93,7 +94,18 @@ function M.create(ctx)
|
||||
if not ensure_binary_for_menu() then
|
||||
return
|
||||
end
|
||||
process.run_control_command_async("open-session-help")
|
||||
process.run_binary_command_async({
|
||||
state.binary_path,
|
||||
"--session-action",
|
||||
'{"actionId":"openSessionHelp"}',
|
||||
}, function(ok, result, error)
|
||||
if ok then
|
||||
return
|
||||
end
|
||||
local reason = error or (result and result.stderr) or "unknown error"
|
||||
subminer_log("warn", "session-bindings", "Session action failed: " .. tostring(reason))
|
||||
show_osd("Session action failed")
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache');
|
||||
const maxCloudflareFiles = 20_000;
|
||||
const maxCloudflareFileBytes = 25 * 1024 * 1024;
|
||||
|
||||
// Cloudflare Pages header rules for the whole deployment. Mirrors the `noindex,follow`
|
||||
// meta tag the non-root channels emit, so the duplicate trees stay out of the index
|
||||
// even for responses a crawler takes without parsing the HTML.
|
||||
const deployHeaders = `# Generated by scripts/build-versioned-docs.ts. Do not edit by hand.
|
||||
/main/*
|
||||
X-Robots-Tag: noindex, follow
|
||||
|
||||
/v/*
|
||||
X-Robots-Tag: noindex, follow
|
||||
`;
|
||||
|
||||
function run(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -173,6 +184,7 @@ function buildDocs(options: {
|
||||
SUBMINER_DOCS_BASE: options.base,
|
||||
SUBMINER_DOCS_OUT_DIR: options.outDir,
|
||||
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
|
||||
SUBMINER_DOCS_REPO_DIR: currentDocsSite,
|
||||
SUBMINER_DOCS_CHANNEL: options.channel,
|
||||
SUBMINER_DOCS_VERSION: options.version ?? '',
|
||||
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
|
||||
@@ -378,6 +390,7 @@ function main() {
|
||||
});
|
||||
|
||||
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
writeFileSync(join(aggregateOutDir, '_headers'), deployHeaders);
|
||||
assertCloudflarePagesLimits(aggregateOutDir);
|
||||
const prunedArchives = pruneArchiveCacheGenerations({
|
||||
cacheRoot: archiveCacheRoot,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
parseSubsyncManualRunRequest,
|
||||
parseYoutubePickerResolveRequest,
|
||||
} from '../../shared/ipc/validators';
|
||||
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
@@ -442,7 +443,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
const senderWindow =
|
||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
// Route forwarding requests through the platform-aware helper so Windows never
|
||||
// installs Electron's global mouse hook (see overlay-click-through.ts).
|
||||
if (ignore && parsedOptions?.forward) {
|
||||
applyOverlayClickThrough(senderWindow);
|
||||
} else {
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
}
|
||||
}
|
||||
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'sub-scale-by-window',
|
||||
'osd-height',
|
||||
'osd-dimensions',
|
||||
'sub-text-ass',
|
||||
'sub-text/ass',
|
||||
'sub-border-size',
|
||||
'sub-shadow-offset',
|
||||
'sub-ass-override',
|
||||
@@ -74,7 +74,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
|
||||
request_id: MPV_REQUEST_ID_SUBTEXT,
|
||||
},
|
||||
{
|
||||
command: ['get_property', 'sub-text-ass'],
|
||||
command: ['get_property', 'sub-text/ass'],
|
||||
request_id: MPV_REQUEST_ID_SUBTEXT_ASS,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -129,6 +129,28 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits ASS subtitle text from the current mpv property', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-text/ass', data: '{\\b1}字幕' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits ASS subtitle text from the legacy mpv property', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-text-ass', data: '{\\b1}字幕' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
emitSubtitleTrackChange: (payload) => state.events.push(payload),
|
||||
|
||||
@@ -248,7 +248,7 @@ export async function dispatchMpvProtocolMessage(
|
||||
isOverlayVisible: overlayVisible,
|
||||
});
|
||||
deps.setCurrentSubText(nextSubText);
|
||||
} else if (msg.name === 'sub-text-ass') {
|
||||
} else if (msg.name === 'sub-text/ass' || msg.name === 'sub-text-ass') {
|
||||
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
|
||||
} else if (msg.name === 'sub-start') {
|
||||
deps.setCurrentSubStart((msg.data as number) || 0);
|
||||
|
||||
@@ -505,6 +505,17 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
|
||||
(command as { command: unknown[] }).command[1] === 1 &&
|
||||
(command as { command: unknown[] }).command[2] === 'sub-text',
|
||||
);
|
||||
const hasAssSubtitleSubscription = commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command: unknown[] }).command) &&
|
||||
(command as { command: unknown[] }).command[0] === 'observe_property' &&
|
||||
(command as { command: unknown[] }).command[2] === 'sub-text/ass',
|
||||
);
|
||||
const hasDeprecatedAssSubtitleProperty = commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command: unknown[] }).command) &&
|
||||
(command as { command: unknown[] }).command.includes('sub-text-ass'),
|
||||
);
|
||||
const hasPathRequest = commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command: unknown[] }).command) &&
|
||||
@@ -514,6 +525,8 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
|
||||
|
||||
assert.equal(hasSecondaryVisibilityReset, true);
|
||||
assert.equal(hasTrackSubscription, true);
|
||||
assert.equal(hasAssSubtitleSubscription, true);
|
||||
assert.equal(hasDeprecatedAssSubtitleProperty, false);
|
||||
assert.equal(hasPathRequest, true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||
|
||||
test('applyOverlayClickThrough requests forwarding only off Windows', () => {
|
||||
const calls: Array<{ ignore: boolean; forward: boolean }> = [];
|
||||
const window = {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push({ ignore, forward: options?.forward === true });
|
||||
},
|
||||
};
|
||||
|
||||
applyOverlayClickThrough(window, true);
|
||||
applyOverlayClickThrough(window, false);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ ignore: true, forward: false },
|
||||
{ ignore: true, forward: true },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
type ClickThroughWindow = {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Puts an overlay window into click-through mode. Forwarded mouse-move ({ forward: true }) is
|
||||
* what lets renderer hover tracking wake a click-through overlay, but on Windows Electron
|
||||
* implements it with a global WH_MOUSE_LL hook whose callback runs on the main-process message
|
||||
* loop, so any main-thread stall delays mouse input system-wide (electron/electron#10183).
|
||||
* Windows instead wakes the overlay via the main-process cursor poll
|
||||
* (tickWindowsOverlayPointerInteraction), so no forwarding is requested there. macOS still
|
||||
* needs forwarding for renderer hover tracking; Linux ignores the flag entirely
|
||||
* (electron/electron#16777).
|
||||
*
|
||||
* Pass isWindowsPlatform when the caller already carries a platform flag (tests simulate
|
||||
* platforms through it); otherwise the real process.platform decides.
|
||||
*/
|
||||
export function applyOverlayClickThrough(
|
||||
window: ClickThroughWindow,
|
||||
isWindowsPlatform?: boolean,
|
||||
): void {
|
||||
if (isWindowsPlatform ?? process.platform === 'win32') {
|
||||
window.setIgnoreMouseEvents(true);
|
||||
} else {
|
||||
window.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
@@ -848,7 +848,7 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('opacity:0'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
@@ -1060,7 +1060,7 @@ test('tracked Windows overlay refresh rebinds while already visible', () => {
|
||||
isWindowsPlatform: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
@@ -1134,7 +1134,7 @@ test('forced passthrough still reapplies while visible on Windows', () => {
|
||||
forceMousePassthrough: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
@@ -1339,7 +1339,7 @@ test('tracked Windows overlay rebinds without hiding when tracker focus changes'
|
||||
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(!calls.includes('enforce-order'));
|
||||
@@ -1489,7 +1489,7 @@ test('tracked Windows overlay reshows click-through even if focus state is stale
|
||||
isWindowsPlatform: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
@@ -1532,7 +1532,7 @@ test('tracked Windows overlay binds above mpv even when tracker focus lags', ()
|
||||
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
});
|
||||
@@ -2193,7 +2193,7 @@ test('Windows preserves visible overlay and rebinds to mpv while tracker transie
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { BaseWindowTracker } from '../../window-trackers';
|
||||
import { WindowGeometry } from '../../types';
|
||||
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
|
||||
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
||||
@@ -117,7 +118,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
clearPendingWindowsOverlayReveal(mainWindow);
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
}
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
@@ -215,7 +216,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
||||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
||||
if (shouldIgnoreMouseEvents) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
}
|
||||
@@ -263,7 +264,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
if (hasNonNativeInputRegion) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||
}
|
||||
if (args.isWindowsPlatform) {
|
||||
scheduleWindowsOverlayReveal(
|
||||
@@ -424,7 +425,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
return;
|
||||
}
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
|
||||
@@ -15,6 +15,32 @@ test('overlay window config explicitly disables renderer sandbox for preload com
|
||||
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
||||
});
|
||||
|
||||
test('macOS modal overlay uses a fullscreen auxiliary panel without changing the passive overlay', () => {
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
platform: 'darwin',
|
||||
yomitanSession: null,
|
||||
});
|
||||
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||
isDev: false,
|
||||
platform: 'darwin',
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(visibleOptions.type, undefined);
|
||||
assert.equal(modalOptions.type, 'panel');
|
||||
});
|
||||
|
||||
test('non-macOS modal overlay remains a regular window', () => {
|
||||
const options = buildOverlayWindowOptions('modal', {
|
||||
isDev: false,
|
||||
platform: 'linux',
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
});
|
||||
|
||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
||||
export const OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG = '__subminerOverlayDocumentLoaded';
|
||||
|
||||
@@ -12,15 +12,17 @@ export function buildOverlayWindowOptions(
|
||||
options: {
|
||||
isDev: boolean;
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
): BrowserWindowConstructorOptions {
|
||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||
const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible';
|
||||
const platform = options.platform ?? process.platform;
|
||||
const showNativeDebugFrame = platform === 'win32' && options.isDev;
|
||||
const isLinuxVisibleOverlay = platform === 'linux' && kind === 'visible';
|
||||
const isLinuxFullscreenOverlay =
|
||||
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
|
||||
const shouldStartAlwaysOnTop =
|
||||
!(process.platform === 'win32' && kind === 'visible') &&
|
||||
!(platform === 'win32' && kind === 'visible') &&
|
||||
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
|
||||
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
|
||||
|
||||
@@ -41,7 +43,10 @@ export function buildOverlayWindowOptions(
|
||||
hasShadow: false,
|
||||
focusable: !isLinuxFullscreenOverlay,
|
||||
acceptFirstMouse: true,
|
||||
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||
// A macOS panel is a fullscreen auxiliary window, so modal surfaces stay on the
|
||||
// active mpv Space instead of opening on SubMiner's last regular desktop.
|
||||
...(platform === 'darwin' && kind === 'modal' ? { type: 'panel' as const } : {}),
|
||||
...(platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
} from './hyprland-window-placement';
|
||||
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
import {
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||
} from './overlay-window-flags';
|
||||
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
|
||||
const logger = createLogger('main:overlay-window');
|
||||
@@ -133,6 +136,9 @@ export function createOverlayWindow(
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
||||
] = false;
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
|
||||
] = false;
|
||||
|
||||
if (!(process.platform === 'win32' && kind === 'visible')) {
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
@@ -144,11 +150,20 @@ export function createOverlayWindow(
|
||||
});
|
||||
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
|
||||
] = true;
|
||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||
options.onRuntimeOptionsChanged();
|
||||
options.onWindowDidFinishLoad?.();
|
||||
});
|
||||
|
||||
window.webContents.on('did-start-loading', () => {
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
|
||||
] = false;
|
||||
});
|
||||
|
||||
window.webContents.on('page-title-updated', (event) => {
|
||||
event.preventDefault();
|
||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||
|
||||
@@ -57,7 +57,9 @@ export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey:
|
||||
export function buildStatsWindowOptions(options: {
|
||||
preloadPath: string;
|
||||
bounds?: WindowGeometry | null;
|
||||
platform?: NodeJS.Platform;
|
||||
}): BrowserWindowConstructorOptions {
|
||||
const platform = options.platform ?? process.platform;
|
||||
return {
|
||||
title: STATS_WINDOW_TITLE,
|
||||
x: options.bounds?.x,
|
||||
@@ -73,6 +75,9 @@ export function buildStatsWindowOptions(options: {
|
||||
focusable: true,
|
||||
acceptFirstMouse: true,
|
||||
fullscreenable: false,
|
||||
// Panels join fullscreen Spaces on macOS without moving the user back to the
|
||||
// desktop where SubMiner last owned a regular application window.
|
||||
...(platform === 'darwin' ? { type: 'panel' as const } : {}),
|
||||
backgroundColor: '#24273a',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
@@ -84,6 +89,12 @@ export function buildStatsWindowOptions(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPresentStatsWindowAfterLoad(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
return platform === 'darwin';
|
||||
}
|
||||
|
||||
export function resolveStatsWindowOuterBoundsForContent(
|
||||
window: StatsWindowBoundsController,
|
||||
target: WindowGeometry,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
scheduleStatsWindowPostShowReconciles,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
shouldPresentStatsWindowAfterLoad,
|
||||
} from './stats-window-runtime';
|
||||
|
||||
test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => {
|
||||
@@ -40,6 +41,30 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w
|
||||
assert.equal(options.webPreferences?.sandbox, true);
|
||||
});
|
||||
|
||||
test('buildStatsWindowOptions uses a fullscreen auxiliary panel on macOS', () => {
|
||||
const options = buildStatsWindowOptions({
|
||||
preloadPath: '/tmp/preload-stats.js',
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(options.type, 'panel');
|
||||
});
|
||||
|
||||
test('buildStatsWindowOptions remains a regular window off macOS', () => {
|
||||
const options = buildStatsWindowOptions({
|
||||
preloadPath: '/tmp/preload-stats.js',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
});
|
||||
|
||||
test('stats panels present after document load on macOS', () => {
|
||||
assert.equal(shouldPresentStatsWindowAfterLoad('darwin'), true);
|
||||
assert.equal(shouldPresentStatsWindowAfterLoad('linux'), false);
|
||||
assert.equal(shouldPresentStatsWindowAfterLoad('win32'), false);
|
||||
});
|
||||
|
||||
test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
|
||||
assert.equal(
|
||||
shouldHideStatsWindowForInput(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
scheduleStatsWindowPostShowReconciles,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
shouldPresentStatsWindowAfterLoad,
|
||||
STATS_WINDOW_TITLE,
|
||||
} from './stats-window-runtime.js';
|
||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
|
||||
@@ -209,10 +210,15 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
options.onVisibilityChanged?.(false);
|
||||
}
|
||||
});
|
||||
statsWindow.once('ready-to-show', () => {
|
||||
const showInitialStatsWindow = () => {
|
||||
if (!statsWindow) return;
|
||||
showStatsWindow(statsWindow, options);
|
||||
});
|
||||
};
|
||||
if (shouldPresentStatsWindowAfterLoad()) {
|
||||
statsWindow.webContents.once('did-finish-load', showInitialStatsWindow);
|
||||
} else {
|
||||
statsWindow.once('ready-to-show', showInitialStatsWindow);
|
||||
}
|
||||
|
||||
statsWindow.on('blur', () => {
|
||||
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* layer that keeps the two views consistent by construction.
|
||||
* 2. Otherwise (embedded track nobody parsed, a source whose timings mpv has shifted)
|
||||
* fall back to timing alone. No authoring metadata is available live -- mpv delivers
|
||||
* `sub-text-ass` after `sub-start`/`sub-end`, so any ASS text read here belongs to the
|
||||
* `sub-text/ass` after `sub-start`/`sub-end`, so any ASS text read here belongs to the
|
||||
* previous event -- which puts this layer in the same position as the SRT path in
|
||||
* `subtitle-cue-dedup`, and it uses that path's deliberately strict bounds.
|
||||
*/
|
||||
|
||||
@@ -24,10 +24,17 @@ import {
|
||||
shouldForwardStartupArgvViaAppControl,
|
||||
applyBackgroundBootstrapCommandLineSwitches,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
resolveAppControlHandoffTimeoutMs,
|
||||
resolveLinuxPasswordStoreValue,
|
||||
spawnDetachedApp,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('app-control handoffs allow for macOS application activation latency', () => {
|
||||
assert.equal(resolveAppControlHandoffTimeoutMs('darwin'), 3000);
|
||||
assert.equal(resolveAppControlHandoffTimeoutMs('linux'), 500);
|
||||
assert.equal(resolveAppControlHandoffTimeoutMs('win32'), 500);
|
||||
});
|
||||
|
||||
test('detached app launch policy stays in the startup runtime utilities', () => {
|
||||
const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
|
||||
const runtimeSource = fs.readFileSync(
|
||||
|
||||
@@ -14,6 +14,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
const MAX_TRANSPORTED_APP_ARGS = 256;
|
||||
const APP_NAME = 'SubMiner';
|
||||
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
|
||||
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
|
||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||
'--alang',
|
||||
'--audio-file',
|
||||
@@ -186,6 +188,14 @@ export function shouldForwardStartupArgvViaAppControl(
|
||||
return hasExplicitCommand(args);
|
||||
}
|
||||
|
||||
export function resolveAppControlHandoffTimeoutMs(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): number {
|
||||
return platform === 'darwin'
|
||||
? MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS
|
||||
: DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
|
||||
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
||||
if (rawCount === undefined) {
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import {
|
||||
normalizeLaunchMpvTargets,
|
||||
normalizeStartupArgv,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
resolveAppControlHandoffTimeoutMs,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
sanitizeHelpEnv,
|
||||
@@ -214,7 +215,7 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
|
||||
|
||||
const result = await sendAppControlCommand(process.argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 500,
|
||||
timeoutMs: resolveAppControlHandoffTimeoutMs(),
|
||||
});
|
||||
if (result.ok) {
|
||||
app.exit(0);
|
||||
|
||||
+5
-1
@@ -331,6 +331,7 @@ import {
|
||||
acquireYoutubeSubtitleTrack,
|
||||
acquireYoutubeSubtitleTracks,
|
||||
} from './core/services/youtube/generate';
|
||||
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
|
||||
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
|
||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||
@@ -5009,6 +5010,9 @@ function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
|
||||
|
||||
function initializeOverlayRuntime(): void {
|
||||
initializeOverlayRuntimeHandler();
|
||||
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
|
||||
overlayModalRuntime.primeModalWindow();
|
||||
}
|
||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
@@ -5466,7 +5470,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
senderWindow === modalWindow &&
|
||||
!senderWindow.isDestroyed()
|
||||
) {
|
||||
senderWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(senderWindow);
|
||||
senderWindow.hide();
|
||||
}
|
||||
handleOverlayModalClosedHandler(modal);
|
||||
|
||||
@@ -16,6 +16,7 @@ type MockWindow = {
|
||||
loading: boolean;
|
||||
url: string;
|
||||
contentReady: boolean;
|
||||
documentLoaded: boolean;
|
||||
loadCallbacks: Array<() => void>;
|
||||
readyToShowCallbacks: Array<() => void>;
|
||||
};
|
||||
@@ -31,6 +32,7 @@ function createMockWindow(): MockWindow & {
|
||||
getShowCount: () => number;
|
||||
getHideCount: () => number;
|
||||
show: () => void;
|
||||
showInactive: () => void;
|
||||
hide: () => void;
|
||||
destroy: () => void;
|
||||
focus: () => void;
|
||||
@@ -61,6 +63,7 @@ function createMockWindow(): MockWindow & {
|
||||
loading: false,
|
||||
url: 'file:///overlay/index.html?layer=modal',
|
||||
contentReady: true,
|
||||
documentLoaded: true,
|
||||
loadCallbacks: [],
|
||||
readyToShowCallbacks: [],
|
||||
};
|
||||
@@ -84,6 +87,10 @@ function createMockWindow(): MockWindow & {
|
||||
state.visible = true;
|
||||
state.showCount += 1;
|
||||
},
|
||||
showInactive: () => {
|
||||
state.visible = true;
|
||||
state.showCount += 1;
|
||||
},
|
||||
hide: () => {
|
||||
state.visible = false;
|
||||
state.hideCount += 1;
|
||||
@@ -96,6 +103,10 @@ function createMockWindow(): MockWindow & {
|
||||
state.focused = true;
|
||||
},
|
||||
emitDidFinishLoad: () => {
|
||||
state.documentLoaded = true;
|
||||
(
|
||||
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
|
||||
).__subminerOverlayDocumentLoaded = true;
|
||||
const callbacks = state.loadCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
@@ -197,9 +208,22 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'documentLoaded', {
|
||||
get: () => state.documentLoaded,
|
||||
set: (value: boolean) => {
|
||||
state.documentLoaded = value;
|
||||
(
|
||||
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
|
||||
).__subminerOverlayDocumentLoaded = value;
|
||||
},
|
||||
});
|
||||
|
||||
(
|
||||
window as typeof window & { __subminerOverlayContentReady?: boolean }
|
||||
).__subminerOverlayContentReady = state.contentReady;
|
||||
(
|
||||
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
|
||||
).__subminerOverlayDocumentLoaded = state.documentLoaded;
|
||||
|
||||
return window;
|
||||
}
|
||||
@@ -259,6 +283,73 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
|
||||
assert.deepEqual(window.sent, [['jimaku:open']]);
|
||||
});
|
||||
|
||||
for (const platform of ['darwin', 'win32'] as const) {
|
||||
test(`primeModalWindow creates and warms a hidden modal on ${platform}`, () => {
|
||||
const modalWindow = createMockWindow();
|
||||
modalWindow.loading = true;
|
||||
modalWindow.url = '';
|
||||
modalWindow.contentReady = false;
|
||||
modalWindow.documentLoaded = false;
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = null;
|
||||
let createCalls = 0;
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
createCalls += 1;
|
||||
currentModal = modalWindow;
|
||||
return modalWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 1, y: 2, width: 300, height: 200 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform },
|
||||
);
|
||||
|
||||
assert.equal(runtime.primeModalWindow(), true);
|
||||
assert.equal(createCalls, 1);
|
||||
assert.equal(modalWindow.isVisible(), false);
|
||||
|
||||
modalWindow.loading = false;
|
||||
modalWindow.url = 'file:///overlay/index.html?layer=modal';
|
||||
modalWindow.emitDidFinishLoad();
|
||||
modalWindow.emitReadyToShow();
|
||||
modalWindow.contentReady = true;
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(createCalls, 1);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
assert.deepEqual(modalWindow.sent, [['session-help:open']]);
|
||||
});
|
||||
}
|
||||
|
||||
test('primeModalWindow leaves Linux modal creation lazy', () => {
|
||||
let createCalls = 0;
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => {
|
||||
createCalls += 1;
|
||||
return createMockWindow() as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'linux' },
|
||||
);
|
||||
|
||||
assert.equal(runtime.primeModalWindow(), false);
|
||||
assert.equal(createCalls, 0);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
@@ -301,7 +392,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
@@ -311,15 +402,18 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
});
|
||||
|
||||
test('handleOverlayModalClosed hides modal window only after all pending modals close', () => {
|
||||
test('handleOverlayModalClosed keeps the modal window warm after all pending modals close', () => {
|
||||
const window = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'darwin' },
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
@@ -342,7 +436,9 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
||||
assert.equal(window.isDestroyed(), false);
|
||||
|
||||
runtime.handleOverlayModalClosed('subsync');
|
||||
assert.equal(window.isDestroyed(), true);
|
||||
assert.equal(window.isDestroyed(), false);
|
||||
assert.equal(window.isVisible(), false);
|
||||
assert.equal(window.ignoreMouseEvents, true);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
|
||||
@@ -464,6 +560,46 @@ test('modal window path restores visible main overlay before modal input deactiv
|
||||
assert.deepEqual(events, ['state:true:visible:true', 'state:false:visible:true']);
|
||||
});
|
||||
|
||||
test('macOS maps a new modal panel before focusing SubMiner and hiding the subtitle overlay', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const events: string[] = [];
|
||||
const showInactive = modalWindow.showInactive;
|
||||
modalWindow.showInactive = () => {
|
||||
events.push('show-inactive');
|
||||
showInactive();
|
||||
};
|
||||
const hideMainWindow = mainWindow.hide;
|
||||
mainWindow.hide = () => {
|
||||
events.push('hide-main');
|
||||
hideMainWindow();
|
||||
};
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
platform: 'darwin',
|
||||
focusApplication: () => events.push('focus-application'),
|
||||
},
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
|
||||
assert.deepEqual(events, ['show-inactive', 'focus-application', 'hide-main']);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
assert.equal(mainWindow.isVisible(), false);
|
||||
});
|
||||
|
||||
test('modal window path runs final close handoff before modal input deactivates', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
@@ -650,15 +786,18 @@ test('handleOverlayModalClosed is a no-op when no modal window can be targeted',
|
||||
assert.deepEqual(state, []);
|
||||
});
|
||||
|
||||
test('handleOverlayModalClosed destroys modal window for single kiku modal', () => {
|
||||
test('handleOverlayModalClosed hides and retains modal window for single kiku modal', () => {
|
||||
const window = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'darwin' },
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'kiku:field-grouping-open',
|
||||
@@ -669,7 +808,9 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', ()
|
||||
);
|
||||
runtime.handleOverlayModalClosed('kiku');
|
||||
|
||||
assert.equal(window.isDestroyed(), true);
|
||||
assert.equal(window.isDestroyed(), false);
|
||||
assert.equal(window.isVisible(), false);
|
||||
assert.equal(window.ignoreMouseEvents, true);
|
||||
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
|
||||
});
|
||||
|
||||
@@ -719,8 +860,10 @@ test('modal fallback reveal skips showing window when content is not ready', asy
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => {
|
||||
test('sendToActiveOverlayWindow delivers on first modal load without waiting for ready-to-show', () => {
|
||||
const window = createMockWindow();
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
@@ -738,16 +881,100 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow does not infer document readiness from a pending file URL', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
window.documentLoaded = false;
|
||||
window.loading = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow rejects stale content readiness during document reload', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = true;
|
||||
window.documentLoaded = false;
|
||||
window.loading = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, [['session-help:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => {
|
||||
const window = createMockWindow();
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
@@ -773,29 +1000,73 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||
});
|
||||
|
||||
test('modal reopen creates a fresh window after close destroys the previous one', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const secondWindow = createMockWindow();
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
||||
test('modal reopen reuses the warm window and shows it immediately on macOS', () => {
|
||||
const modalWindow = createMockWindow();
|
||||
let createCalls = 0;
|
||||
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => {
|
||||
createCalls += 1;
|
||||
return modalWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
{ platform: 'darwin' },
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.equal(modalWindow.isDestroyed(), false);
|
||||
assert.equal(modalWindow.isVisible(), false);
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(createCalls, 0);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
assert.equal(modalWindow.getShowCount(), 2);
|
||||
});
|
||||
|
||||
test('modal reopen on Windows uses a fresh prewarmed interactive window', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const replacementWindow = createMockWindow();
|
||||
let currentModal = firstWindow;
|
||||
let createCalls = 0;
|
||||
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
createCalls += 1;
|
||||
currentModal = replacementWindow;
|
||||
return replacementWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'win32' },
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
@@ -804,30 +1075,30 @@ test('modal reopen creates a fresh window after close destroys the previous one'
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.equal(firstWindow.isDestroyed(), true);
|
||||
assert.equal(currentModal, replacementWindow);
|
||||
assert.equal(replacementWindow.isVisible(), false);
|
||||
assert.equal(createCalls, 1);
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
const sent = runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(currentModal, secondWindow);
|
||||
assert.equal(secondWindow.getShowCount(), 0);
|
||||
assert.equal(createCalls, 1);
|
||||
assert.equal(replacementWindow.isVisible(), true);
|
||||
assert.equal(replacementWindow.ignoreMouseEvents, false);
|
||||
assert.deepEqual(replacementWindow.sent, [['session-help:open']]);
|
||||
});
|
||||
|
||||
test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const secondWindow = createMockWindow();
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
||||
test('modal reopen on the warm window notifies state change for each lifecycle', () => {
|
||||
const modalWindow = createMockWindow();
|
||||
const state: boolean[] = [];
|
||||
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
},
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
@@ -835,6 +1106,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
|
||||
onModalStateChange: (active: boolean): void => {
|
||||
state.push(active);
|
||||
},
|
||||
platform: 'darwin',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -845,7 +1117,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false]);
|
||||
assert.equal(firstWindow.isDestroyed(), true);
|
||||
assert.equal(modalWindow.isDestroyed(), false);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
@@ -853,7 +1125,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false, true]);
|
||||
assert.equal(currentModal, secondWindow);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
});
|
||||
|
||||
test('visible stale modal window is made interactive again before reopening', () => {
|
||||
|
||||
+101
-19
@@ -2,7 +2,11 @@ import type { BrowserWindow } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
||||
import { applyOverlayClickThrough } from '../core/services/overlay-click-through';
|
||||
import {
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||
} from '../core/services/overlay-window-flags';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
|
||||
@@ -39,6 +43,7 @@ export interface OverlayWindowResolver {
|
||||
}
|
||||
|
||||
export interface OverlayModalRuntime {
|
||||
primeModalWindow: () => boolean;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
@@ -59,6 +64,8 @@ export interface OverlayModalRuntime {
|
||||
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
|
||||
|
||||
export interface OverlayModalRuntimeOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
focusApplication?: () => void;
|
||||
onModalStateChange?: (isActive: boolean) => void;
|
||||
onFinalModalClosed?: () => void;
|
||||
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
|
||||
@@ -79,6 +86,11 @@ export function createOverlayModalRuntimeService(
|
||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
||||
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
|
||||
const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>();
|
||||
const platform = options.platform ?? process.platform;
|
||||
const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
|
||||
const reuseModalWindowAfterClose = platform === 'darwin';
|
||||
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
|
||||
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
||||
@@ -134,7 +146,11 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === false) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
|
||||
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
|
||||
@@ -145,6 +161,50 @@ export function createOverlayModalRuntimeService(
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
};
|
||||
|
||||
const isWindowLoadedForIpc = (window: BrowserWindow): boolean => {
|
||||
if (window.isDestroyed() || window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] !== true) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
};
|
||||
|
||||
const markModalWindowPrimed = (window: BrowserWindow): void => {
|
||||
if (deps.getModalWindow() !== window || !isWindowLoadedForIpc(window)) {
|
||||
return;
|
||||
}
|
||||
modalWindowPrimedForImmediateShow = true;
|
||||
};
|
||||
|
||||
const primeModalWindow = (): boolean => {
|
||||
if (!shouldPrimeModalWindow) {
|
||||
return false;
|
||||
}
|
||||
const modalWindow = resolveModalWindow();
|
||||
if (!modalWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||
if (isWindowReadyForIpc(modalWindow)) {
|
||||
modalWindowPrimedForImmediateShow = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!modalWindowPrimeListenersRegistered.has(modalWindow)) {
|
||||
modalWindowPrimeListenersRegistered.add(modalWindow);
|
||||
modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow));
|
||||
modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const elevateModalWindow = (window: BrowserWindow): void => {
|
||||
if (window.isDestroyed()) return;
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||
@@ -205,16 +265,19 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
|
||||
let delivered = false;
|
||||
const deliverWhenReady = (): void => {
|
||||
if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
|
||||
const deliver = (isReady: () => boolean): void => {
|
||||
if (delivered || window.isDestroyed() || !isReady()) {
|
||||
return;
|
||||
}
|
||||
delivered = true;
|
||||
sendNow(window);
|
||||
};
|
||||
|
||||
window.webContents.once('did-finish-load', deliverWhenReady);
|
||||
window.once('ready-to-show', deliverWhenReady);
|
||||
// A hidden macOS panel may not emit ready-to-show until it is presented. The
|
||||
// renderer can safely receive IPC as soon as its document has finished loading.
|
||||
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
|
||||
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
|
||||
deliver(() => isWindowLoadedForIpc(window));
|
||||
};
|
||||
|
||||
const showModalWindow = (
|
||||
@@ -224,13 +287,20 @@ export function createOverlayModalRuntimeService(
|
||||
} = { passThroughMouseEvents: false },
|
||||
): void => {
|
||||
setWindowFocusable(window);
|
||||
requestOverlayApplicationFocus();
|
||||
if (!window.isVisible()) {
|
||||
const wasVisible = window.isVisible();
|
||||
if (!wasVisible && platform === 'darwin') {
|
||||
// Mapping the panel first keeps it attached to mpv's active fullscreen Space.
|
||||
window.showInactive();
|
||||
focusApplication();
|
||||
} else {
|
||||
focusApplication();
|
||||
}
|
||||
if (!wasVisible && platform !== 'darwin') {
|
||||
window.show();
|
||||
}
|
||||
elevateModalWindow(window);
|
||||
if (options.passThroughMouseEvents) {
|
||||
window.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(window, platform === 'win32');
|
||||
} else {
|
||||
window.setIgnoreMouseEvents(false);
|
||||
}
|
||||
@@ -245,11 +315,11 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||
setWindowFocusable(window);
|
||||
requestOverlayApplicationFocus();
|
||||
window.setIgnoreMouseEvents(false);
|
||||
elevateModalWindow(window);
|
||||
|
||||
if (window.isVisible()) {
|
||||
focusApplication();
|
||||
window.focus();
|
||||
window.webContents.focus();
|
||||
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
||||
@@ -291,7 +361,7 @@ export function createOverlayModalRuntimeService(
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
return;
|
||||
}
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, platform === 'win32');
|
||||
mainWindowMousePassthroughForcedByModal = true;
|
||||
return;
|
||||
}
|
||||
@@ -447,9 +517,21 @@ export function createOverlayModalRuntimeService(
|
||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||
clearPendingModalWindowReveal();
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
modalWindow.destroy();
|
||||
if (reuseModalWindowAfterClose) {
|
||||
applyOverlayClickThrough(modalWindow, false);
|
||||
modalWindow.hide();
|
||||
markModalWindowPrimed(modalWindow);
|
||||
} else {
|
||||
modalWindow.destroy();
|
||||
modalWindowPrimedForImmediateShow = false;
|
||||
// Reusing a transparent click-through BrowserWindow can leave later modal sessions
|
||||
// non-interactive on Windows. Recycle the renderer after every close, then warm its
|
||||
// replacement so the next shortcut still opens promptly.
|
||||
if (platform === 'win32') {
|
||||
primeModalWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
modalWindowPrimedForImmediateShow = false;
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
setMainWindowVisibilityForModal(false);
|
||||
try {
|
||||
@@ -478,17 +560,16 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (targetWindow.isVisible()) {
|
||||
ensureModalWindowInteractive(targetWindow);
|
||||
} else {
|
||||
showModalWindow(targetWindow);
|
||||
}
|
||||
|
||||
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
|
||||
setMainWindowMousePassthroughForModal(true);
|
||||
setMainWindowVisibilityForModal(true);
|
||||
}
|
||||
|
||||
if (targetWindow.isVisible()) {
|
||||
ensureModalWindowInteractive(targetWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
showModalWindow(targetWindow);
|
||||
};
|
||||
|
||||
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
|
||||
@@ -515,6 +596,7 @@ export function createOverlayModalRuntimeService(
|
||||
});
|
||||
|
||||
return {
|
||||
primeModalWindow,
|
||||
sendToActiveOverlayWindow,
|
||||
openRuntimeOptionsPalette,
|
||||
openJimaku,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||
|
||||
type StatsOverlayVisibilityWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
@@ -8,7 +10,7 @@ function makeOverlayMousePassive(window: StatsOverlayVisibilityWindow | null): v
|
||||
if (!window || window.isDestroyed() || !window.isVisible()) {
|
||||
return;
|
||||
}
|
||||
window.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(window);
|
||||
}
|
||||
|
||||
export function createStatsOverlayVisibilityChangeHandler(deps: {
|
||||
|
||||
@@ -31,6 +31,20 @@ function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; command
|
||||
return { spawn, children, commands };
|
||||
}
|
||||
|
||||
async function waitForResult<T>(promise: Promise<T>, timeoutMs = 3000): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('Timed out waiting for result.')), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout !== null) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => {
|
||||
const { spawn, children, commands } = makeSpawn();
|
||||
const events: SyncProgressEvent[] = [];
|
||||
@@ -96,7 +110,9 @@ test('runSyncLauncher settles after exit when close never arrives', async () =>
|
||||
// so `close` never fires.
|
||||
child.emit('exit', 1, null);
|
||||
|
||||
const result = await handle.done;
|
||||
// Keep the isolated Bun test process alive while the production drain timer
|
||||
// remains unref'ed, and fail instead of hanging if the result never settles.
|
||||
const result = await waitForResult(handle.done);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /remote refused/);
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export function runSyncLauncher(options: {
|
||||
spawn?: SyncLauncherSpawn;
|
||||
timeoutMs?: number;
|
||||
}): SyncLauncherRunHandle {
|
||||
const spawn =
|
||||
const spawn: SyncLauncherSpawn =
|
||||
options.spawn ??
|
||||
((command, args) => {
|
||||
// The child must boot as a full Electron app (its entry handles
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type BrowserWindow, screen } from 'electron';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
||||
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
|
||||
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
||||
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
|
||||
@@ -603,7 +604,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
if (active) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-46
@@ -1,4 +1,4 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import koffi from 'koffi';
|
||||
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
||||
|
||||
@@ -173,43 +173,125 @@ function getProcessNameByPid(pid: number): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
const processCommandLineCache = new Map<number, string>();
|
||||
// Short-lived cache so the 250ms poll doesn't re-query every top-level window's process
|
||||
// on each pass. The TTL bounds staleness from PID reuse.
|
||||
const PROCESS_NAME_CACHE_TTL_MS = 5_000;
|
||||
const PROCESS_NAME_CACHE_PRUNE_THRESHOLD = 512;
|
||||
const processNameCache = new Map<number, { name: string | null; expiresAtMs: number }>();
|
||||
|
||||
function getCachedProcessNameByPid(pid: number): string | null {
|
||||
const nowMs = Date.now();
|
||||
const cached = processNameCache.get(pid);
|
||||
if (cached && cached.expiresAtMs > nowMs) {
|
||||
return cached.name;
|
||||
}
|
||||
const name = getProcessNameByPid(pid);
|
||||
processNameCache.set(pid, { name, expiresAtMs: nowMs + PROCESS_NAME_CACHE_TTL_MS });
|
||||
return name;
|
||||
}
|
||||
|
||||
function pruneExpiredProcessNames(nowMs: number): void {
|
||||
if (processNameCache.size <= PROCESS_NAME_CACHE_PRUNE_THRESHOLD) return;
|
||||
for (const [pid, entry] of processNameCache) {
|
||||
if (entry.expiresAtMs <= nowMs) {
|
||||
processNameCache.delete(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessCommandLineCacheEntry =
|
||||
| { state: 'resolved'; commandLine: string; expiresAtMs: number; refreshInFlight: boolean }
|
||||
| { state: 'pending' }
|
||||
| { state: 'failed'; retryAtMs: number; backoffMs: number };
|
||||
|
||||
const COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS = 2_000;
|
||||
const COMMAND_LINE_RETRY_MAX_BACKOFF_MS = 30_000;
|
||||
// A process command line never changes, so a resolved entry only has to expire to survive
|
||||
// Windows PID reuse (a dead mpv's PID handed to a new instance, whose stale socket path would
|
||||
// otherwise match the wrong window forever). Longer than the process-name TTL because each
|
||||
// refresh costs a PowerShell spawn, and the cached value keeps being served while the refresh
|
||||
// runs, so expiry never interrupts window matching.
|
||||
const COMMAND_LINE_CACHE_TTL_MS = 60_000;
|
||||
const processCommandLineCache = new Map<number, ProcessCommandLineCacheEntry>();
|
||||
|
||||
function queryProcessCommandLine(
|
||||
pid: number,
|
||||
onResult: (commandLine: string | null) => void,
|
||||
): void {
|
||||
execFile(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
`$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 1500,
|
||||
},
|
||||
(error, stdout) => {
|
||||
const output = error ? '' : stdout.trim();
|
||||
onResult(output.length > 0 ? output : null);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Resolves a process command line via a background PowerShell lookup. Returns null until the
|
||||
// first lookup completes; the caller's next poll picks up the cached result. Failures are
|
||||
// negative-cached with exponential backoff: the synchronous version of this lookup could
|
||||
// block the main thread for its full 1.5s timeout on every 250ms poll, which (combined with
|
||||
// the forward:true mouse hook) stalled mouse input system-wide.
|
||||
function getProcessCommandLineByPid(pid: number): string | null {
|
||||
if (processCommandLineCache.has(pid)) {
|
||||
return processCommandLineCache.get(pid) ?? null;
|
||||
const entry = processCommandLineCache.get(pid);
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (entry?.state === 'resolved') {
|
||||
if (nowMs >= entry.expiresAtMs && !entry.refreshInFlight) {
|
||||
entry.refreshInFlight = true;
|
||||
queryProcessCommandLine(pid, (commandLine) => {
|
||||
processCommandLineCache.set(pid, {
|
||||
state: 'resolved',
|
||||
// A failed refresh is usually a transient query error rather than a dead process
|
||||
// (a gone process owns no window, so it is never looked up again). Keep the last
|
||||
// known command line and re-check after the next TTL.
|
||||
commandLine: commandLine ?? entry.commandLine,
|
||||
expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS,
|
||||
refreshInFlight: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
return entry.commandLine;
|
||||
}
|
||||
|
||||
let commandLine: string | null = null;
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
`$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 1500,
|
||||
},
|
||||
).trim();
|
||||
commandLine = output.length > 0 ? output : null;
|
||||
} catch {
|
||||
commandLine = null;
|
||||
}
|
||||
if (entry?.state === 'pending') return null;
|
||||
if (entry?.state === 'failed' && nowMs < entry.retryAtMs) return null;
|
||||
|
||||
if (commandLine !== null) {
|
||||
processCommandLineCache.set(pid, commandLine);
|
||||
} else {
|
||||
processCommandLineCache.delete(pid);
|
||||
}
|
||||
return commandLine;
|
||||
const nextBackoffMs =
|
||||
entry?.state === 'failed'
|
||||
? Math.min(entry.backoffMs * 2, COMMAND_LINE_RETRY_MAX_BACKOFF_MS)
|
||||
: COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS;
|
||||
processCommandLineCache.set(pid, { state: 'pending' });
|
||||
queryProcessCommandLine(pid, (commandLine) => {
|
||||
if (commandLine !== null) {
|
||||
processCommandLineCache.set(pid, {
|
||||
state: 'resolved',
|
||||
commandLine,
|
||||
expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS,
|
||||
refreshInFlight: false,
|
||||
});
|
||||
} else {
|
||||
processCommandLineCache.set(pid, {
|
||||
state: 'failed',
|
||||
retryAtMs: Date.now() + nextBackoffMs,
|
||||
backoffMs: nextBackoffMs,
|
||||
});
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult {
|
||||
@@ -217,8 +299,7 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
||||
const matches: MpvWindowMatch[] = [];
|
||||
let hasMinimized = false;
|
||||
let hasFocused = false;
|
||||
const processNameCache = new Map<number, string | null>();
|
||||
const processCommandLineLookupCache = new Map<number, string | null>();
|
||||
pruneExpiredProcessNames(Date.now());
|
||||
|
||||
const cb = koffi.register((hwnd: number, _lParam: number) => {
|
||||
if (!IsWindowVisible(hwnd)) return true;
|
||||
@@ -228,21 +309,12 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
||||
const pidValue = pid[0]!;
|
||||
if (pidValue === 0) return true;
|
||||
|
||||
let processName = processNameCache.get(pidValue);
|
||||
if (processName === undefined) {
|
||||
processName = getProcessNameByPid(pidValue);
|
||||
processNameCache.set(pidValue, processName);
|
||||
}
|
||||
|
||||
const processName = getCachedProcessNameByPid(pidValue);
|
||||
if (!processName || processName.toLowerCase() !== 'mpv') return true;
|
||||
|
||||
let commandLine: string | null = null;
|
||||
if (targetSocketPath) {
|
||||
commandLine = processCommandLineLookupCache.get(pidValue) ?? null;
|
||||
if (!processCommandLineLookupCache.has(pidValue)) {
|
||||
commandLine = getProcessCommandLineByPid(pidValue);
|
||||
processCommandLineLookupCache.set(pidValue, commandLine);
|
||||
}
|
||||
commandLine = getProcessCommandLineByPid(pidValue);
|
||||
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user