Prepare Windows release and signing process (#16)

This commit is contained in:
2026-03-08 19:51:30 -07:00
committed by GitHub
parent 34d2dce8dc
commit c799a8de3c
113 changed files with 5042 additions and 386 deletions

View File

@@ -47,6 +47,14 @@ test('parseArgs ignores missing value after --log-level', () => {
assert.equal(args.start, true);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
assert.equal(args.launchMpv, true);
assert.deepEqual(args.launchMpvTargets, ['C:\\a.mkv', 'C:\\b.mkv']);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), false);
});
test('parseArgs handles jellyfin item listing controls', () => {
const args = parseArgs([
'--jellyfin-items',
@@ -76,6 +84,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(stopOnly), true);
assert.equal(shouldStartApp(stopOnly), false);
const launchMpv = parseArgs(['--launch-mpv']);
assert.equal(launchMpv.launchMpv, true);
assert.deepEqual(launchMpv.launchMpvTargets, []);
assert.equal(hasExplicitCommand(launchMpv), true);
assert.equal(shouldStartApp(launchMpv), false);
const toggle = parseArgs(['--toggle-visible-overlay']);
assert.equal(hasExplicitCommand(toggle), true);
assert.equal(shouldStartApp(toggle), true);

View File

@@ -1,6 +1,8 @@
export interface CliArgs {
background: boolean;
start: boolean;
launchMpv: boolean;
launchMpvTargets: string[];
stop: boolean;
toggle: boolean;
toggleVisibleOverlay: boolean;
@@ -68,6 +70,8 @@ export function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,
@@ -123,6 +127,11 @@ export function parseArgs(argv: string[]): CliArgs {
if (arg === '--background') args.background = true;
else if (arg === '--start') args.start = true;
else if (arg === '--launch-mpv') {
args.launchMpv = true;
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
break;
}
else if (arg === '--stop') args.stop = true;
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
@@ -297,6 +306,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
return (
args.background ||
args.start ||
args.launchMpv ||
args.stop ||
args.toggle ||
args.toggleVisibleOverlay ||
@@ -342,6 +352,7 @@ export function shouldStartApp(args: CliArgs): boolean {
if (
args.background ||
args.start ||
args.launchMpv ||
args.toggle ||
args.toggleVisibleOverlay ||
args.settings ||
@@ -361,6 +372,9 @@ export function shouldStartApp(args: CliArgs): boolean {
args.jellyfinPlay ||
args.texthooker
) {
if (args.launchMpv) {
return false;
}
return true;
}
return false;

View File

@@ -17,6 +17,7 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--help\s+Show this help/);
assert.match(output, /default: 7777/);
assert.match(output, /--launch-mpv/);
assert.match(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--anilist-status/);

View File

@@ -12,6 +12,7 @@ ${B}Usage:${R} subminer ${D}[command] [options]${R}
${B}Session${R}
--background Start in tray/background mode
--start Connect to mpv and launch overlay
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
--stop Stop the running instance
--texthooker Start texthooker server only ${D}(no overlay)${R}
@@ -68,7 +69,7 @@ ${B}Jellyfin${R}
${B}Options${R}
--socket ${D}PATH${R} mpv IPC socket path
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos)${R}
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos, windows)${R}
--port ${D}PORT${R} Texthooker server port ${D}(default: ${defaultTexthookerPort})${R}
--log-level ${D}LEVEL${R} ${D}debug | info | warn | error${R}
--debug Enable debug mode ${D}(alias: --dev)${R}

View File

@@ -10,19 +10,32 @@ function existsSyncFrom(paths: string[]): (candidate: string) => boolean {
test('resolveConfigBaseDirs trims xdg value and deduplicates fallback dir', () => {
const homeDir = '/home/tester';
const baseDirs = resolveConfigBaseDirs(' /home/tester/.config ', homeDir);
assert.deepEqual(baseDirs, [path.join(homeDir, '.config')]);
const trimmedXdgConfigHome = '/home/tester/.config';
const fallbackDir = path.posix.join(homeDir, '.config');
const baseDirs = resolveConfigBaseDirs(` ${trimmedXdgConfigHome} `, homeDir, 'linux');
const expected = Array.from(new Set([trimmedXdgConfigHome, fallbackDir]));
assert.deepEqual(baseDirs, expected);
});
test('resolveConfigBaseDirs prefers APPDATA on windows and deduplicates fallback dir', () => {
const homeDir = 'C:\\Users\\tester';
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
const baseDirs = resolveConfigBaseDirs(undefined, homeDir, 'win32', ` ${appDataDir} `);
assert.deepEqual(baseDirs, [appDataDir]);
});
test('resolveConfigDir prefers xdg SubMiner config when present', () => {
const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config';
const configDir = path.join(xdgConfigHome, 'SubMiner');
const existsSync = existsSyncFrom([path.join(configDir, 'config.jsonc')]);
const configDir = path.posix.join(xdgConfigHome, 'SubMiner');
const existsSync = existsSyncFrom([path.posix.join(configDir, 'config.jsonc')]);
const resolved = resolveConfigDir({
xdgConfigHome,
homeDir,
platform: 'linux',
existsSync,
});
@@ -37,20 +50,22 @@ test('resolveConfigDir ignores lowercase subminer candidate', () => {
const resolved = resolveConfigDir({
xdgConfigHome: '/tmp/missing-xdg',
homeDir,
platform: 'linux',
existsSync,
});
assert.equal(resolved, '/tmp/missing-xdg/SubMiner');
assert.equal(resolved, path.posix.join('/tmp/missing-xdg', 'SubMiner'));
});
test('resolveConfigDir falls back to existing directory when file is missing', () => {
const homeDir = '/home/tester';
const configDir = path.join(homeDir, '.config', 'SubMiner');
const configDir = path.posix.join(homeDir, '.config', 'SubMiner');
const existsSync = existsSyncFrom([configDir]);
const resolved = resolveConfigDir({
xdgConfigHome: '/tmp/missing-xdg',
homeDir,
platform: 'linux',
existsSync,
});
@@ -61,17 +76,18 @@ test('resolveConfigFilePath prefers jsonc before json', () => {
const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config';
const existsSync = existsSyncFrom([
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
path.join(xdgConfigHome, 'SubMiner', 'config.json'),
path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
path.posix.join(xdgConfigHome, 'SubMiner', 'config.json'),
]);
const resolved = resolveConfigFilePath({
xdgConfigHome,
homeDir,
platform: 'linux',
existsSync,
});
assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
assert.equal(resolved, path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
});
test('resolveConfigFilePath keeps legacy fallback output path', () => {
@@ -82,8 +98,40 @@ test('resolveConfigFilePath keeps legacy fallback output path', () => {
const resolved = resolveConfigFilePath({
xdgConfigHome,
homeDir,
platform: 'linux',
existsSync,
});
assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
assert.equal(resolved, path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
});
test('resolveConfigDir prefers APPDATA SubMiner config on windows when present', () => {
const homeDir = 'C:\\Users\\tester';
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
const configDir = path.win32.join(appDataDir, 'SubMiner');
const existsSync = existsSyncFrom([path.win32.join(configDir, 'config.jsonc')]);
const resolved = resolveConfigDir({
platform: 'win32',
appDataDir,
homeDir,
existsSync,
});
assert.equal(resolved, configDir);
});
test('resolveConfigFilePath uses APPDATA fallback output path on windows', () => {
const homeDir = 'C:\\Users\\tester';
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
const existsSync = existsSyncFrom([]);
const resolved = resolveConfigFilePath({
platform: 'win32',
appDataDir,
homeDir,
existsSync,
});
assert.equal(resolved, path.win32.join(appDataDir, 'SubMiner', 'config.jsonc'));
});

View File

@@ -3,6 +3,8 @@ import path from 'node:path';
type ExistsSync = (candidate: string) => boolean;
type ConfigPathOptions = {
platform?: NodeJS.Platform;
appDataDir?: string;
xdgConfigHome?: string;
homeDir: string;
existsSync: ExistsSync;
@@ -13,11 +15,24 @@ type ConfigPathOptions = {
const DEFAULT_APP_NAMES = ['SubMiner'] as const;
const DEFAULT_FILE_NAMES = ['config.jsonc', 'config.json'] as const;
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
export function resolveConfigBaseDirs(
xdgConfigHome: string | undefined,
homeDir: string,
platform: NodeJS.Platform = process.platform,
appDataDir?: string,
): string[] {
const fallbackBaseDir = path.join(homeDir, '.config');
const platformPath = getPlatformPath(platform);
if (platform === 'win32') {
const roamingBaseDir = platformPath.join(homeDir, 'AppData', 'Roaming');
const primaryBaseDir = appDataDir?.trim() || roamingBaseDir;
return Array.from(new Set([primaryBaseDir, roamingBaseDir]));
}
const fallbackBaseDir = platformPath.join(homeDir, '.config');
const primaryBaseDir = xdgConfigHome?.trim() || fallbackBaseDir;
return Array.from(new Set([primaryBaseDir, fallbackBaseDir]));
}
@@ -31,14 +46,21 @@ function getDefaultAppName(options: ConfigPathOptions): string {
}
export function resolveConfigDir(options: ConfigPathOptions): string {
const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
const platform = options.platform ?? process.platform;
const platformPath = getPlatformPath(platform);
const baseDirs = resolveConfigBaseDirs(
options.xdgConfigHome,
options.homeDir,
platform,
options.appDataDir,
);
const appNames = getAppNames(options);
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const dir = path.join(baseDir, appName);
const dir = platformPath.join(baseDir, appName);
for (const fileName of DEFAULT_FILE_NAMES) {
if (options.existsSync(path.join(dir, fileName))) {
if (options.existsSync(platformPath.join(dir, fileName))) {
return dir;
}
}
@@ -47,24 +69,31 @@ export function resolveConfigDir(options: ConfigPathOptions): string {
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const dir = path.join(baseDir, appName);
const dir = platformPath.join(baseDir, appName);
if (options.existsSync(dir)) {
return dir;
}
}
}
return path.join(baseDirs[0]!, getDefaultAppName(options));
return platformPath.join(baseDirs[0]!, getDefaultAppName(options));
}
export function resolveConfigFilePath(options: ConfigPathOptions): string {
const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
const platform = options.platform ?? process.platform;
const platformPath = getPlatformPath(platform);
const baseDirs = resolveConfigBaseDirs(
options.xdgConfigHome,
options.homeDir,
platform,
options.appDataDir,
);
const appNames = getAppNames(options);
for (const baseDir of baseDirs) {
for (const appName of appNames) {
for (const fileName of DEFAULT_FILE_NAMES) {
const candidate = path.join(baseDir, appName, fileName);
const candidate = platformPath.join(baseDir, appName, fileName);
if (options.existsSync(candidate)) {
return candidate;
}
@@ -72,5 +101,5 @@ export function resolveConfigFilePath(options: ConfigPathOptions): string {
}
}
return path.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!);
return platformPath.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!);
}

View File

@@ -112,7 +112,7 @@ export function generateConfigTemplate(
lines.push(' *');
lines.push(' * This file is auto-generated from src/config/definitions.ts.');
lines.push(
' * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.',
' * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS.',
);
lines.push(' */');
lines.push('{');

View File

@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,

View File

@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,

View File

@@ -33,9 +33,30 @@ function makeDbPath(): string {
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
if (!fs.existsSync(dir)) {
return;
}
const bunRuntime = globalThis as typeof globalThis & {
Bun?: {
gc?: (force?: boolean) => void;
};
};
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (process.platform !== 'win32' || err.code !== 'EBUSY') {
throw error;
}
bunRuntime.Bun?.gc?.(true);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
}
}
// libsql keeps Windows file handles alive after close when prepared statements were used.
}
test('seam: resolveBoundedInt keeps fallback for invalid values', () => {

View File

@@ -20,9 +20,30 @@ function makeDbPath(): string {
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
if (!fs.existsSync(dir)) {
return;
}
const bunRuntime = globalThis as typeof globalThis & {
Bun?: {
gc?: (force?: boolean) => void;
};
};
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (process.platform !== 'win32' || err.code !== 'EBUSY') {
throw error;
}
bunRuntime.Bun?.gc?.(true);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
}
}
// libsql keeps Windows file handles alive after close when prepared statements were used.
}
test('ensureSchema creates immersion core tables', () => {

View File

@@ -90,6 +90,9 @@ export function initializeOverlayRuntime(options: {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
};
windowTracker.onTargetWindowFocusChange = () => {
options.syncOverlayShortcuts();
};
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
if (options.isVisibleOverlayVisible()) {

View File

@@ -21,8 +21,8 @@ function createMainWindowRecorder() {
focus: () => {
calls.push('focus');
},
setIgnoreMouseEvents: () => {
calls.push('mouse-ignore');
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
};
@@ -122,6 +122,85 @@ test('non-macOS keeps fallback visible overlay behavior when tracker is not read
assert.ok(!calls.includes('osd'));
});
test('Windows visible overlay stays click-through and does not steal focus while tracked', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('Windows keeps visible overlay hidden while tracker is not ready', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
} as never);
assert.equal(trackerWarning, true);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('update-bounds'));
});
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;

View File

@@ -14,6 +14,7 @@ export function updateVisibleOverlayVisibility(args: {
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
isMacOSPlatform?: boolean;
isWindowsPlatform?: boolean;
showOverlayLoadingOsd?: (message: string) => void;
resolveFallbackBounds?: () => WindowGeometry;
}): void {
@@ -21,9 +22,24 @@ export function updateVisibleOverlayVisibility(args: {
return;
}
const mainWindow = args.mainWindow;
const showPassiveVisibleOverlay = (): void => {
if (args.isWindowsPlatform) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
} else {
mainWindow.setIgnoreMouseEvents(false);
}
args.ensureOverlayWindowLevel(mainWindow);
mainWindow.show();
if (!args.isWindowsPlatform) {
mainWindow.focus();
}
};
if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false);
args.mainWindow.hide();
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
@@ -35,31 +51,27 @@ export function updateVisibleOverlayVisibility(args: {
args.updateVisibleOverlayBounds(geometry);
}
args.syncPrimaryOverlayWindowLayer('visible');
args.mainWindow.setIgnoreMouseEvents(false);
args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show();
args.mainWindow.focus();
showPassiveVisibleOverlay();
args.enforceOverlayLayerOrder();
args.syncOverlayShortcuts();
return;
}
if (!args.windowTracker) {
if (args.isMacOSPlatform) {
if (args.isMacOSPlatform || args.isWindowsPlatform) {
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
args.showOverlayLoadingOsd?.('Overlay loading...');
if (args.isMacOSPlatform) {
args.showOverlayLoadingOsd?.('Overlay loading...');
}
}
args.mainWindow.hide();
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
args.setTrackerNotReadyWarningShown(false);
args.syncPrimaryOverlayWindowLayer('visible');
args.mainWindow.setIgnoreMouseEvents(false);
args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show();
args.mainWindow.focus();
showPassiveVisibleOverlay();
args.enforceOverlayLayerOrder();
args.syncOverlayShortcuts();
return;
@@ -72,8 +84,8 @@ export function updateVisibleOverlayVisibility(args: {
}
}
if (args.isMacOSPlatform) {
args.mainWindow.hide();
if (args.isMacOSPlatform || args.isWindowsPlatform) {
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
@@ -83,10 +95,7 @@ export function updateVisibleOverlayVisibility(args: {
args.updateVisibleOverlayBounds(fallbackBounds);
args.syncPrimaryOverlayWindowLayer('visible');
args.mainWindow.setIgnoreMouseEvents(false);
args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show();
args.mainWindow.focus();
showPassiveVisibleOverlay();
args.enforceOverlayLayerOrder();
args.syncOverlayShortcuts();
}

View File

@@ -1,11 +1,9 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
import { BrowserWindow } from 'electron';
import * as path from 'path';
import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
const { BrowserWindow: ElectronBrowserWindow } = electron;
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
@@ -20,7 +18,7 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
.loadFile(htmlPath, {
query: { layer },
})
.catch((err: unknown) => {
.catch((err) => {
logger.error('Failed to load HTML file:', err);
});
}
@@ -65,6 +63,11 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
window.setFullScreenable(false);
return;
}
if (process.platform === 'win32') {
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.moveTop();
return;
}
window.setAlwaysOnTop(true);
}
@@ -92,7 +95,8 @@ export function createOverlayWindow(
onWindowClosed: (kind: OverlayWindowKind) => void;
},
): BrowserWindow {
const window = new ElectronBrowserWindow({
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const window = new BrowserWindow({
show: false,
width: 800,
height: 600,
@@ -106,6 +110,7 @@ export function createOverlayWindow(
hasShadow: false,
focusable: true,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true,
@@ -162,6 +167,9 @@ export function createOverlayWindow(
window.on('blur', () => {
if (!window.isDestroyed()) {
options.ensureOverlayWindowLevel(window);
if (kind === 'visible' && window.isVisible()) {
window.moveTop();
}
}
});

View File

@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,

View File

@@ -147,6 +147,28 @@ function writeExecutableScript(filePath: string, content: string): void {
fs.chmodSync(filePath, 0o755);
}
function toShellPath(filePath: string): string {
if (process.platform !== 'win32') {
return filePath;
}
return filePath.replace(/\\/g, '/').replace(/^([A-Za-z]):\//, (_, driveLetter: string) => {
return `/mnt/${driveLetter.toLowerCase()}/`;
});
}
function fromShellPath(filePath: string): string {
if (process.platform !== 'win32') {
return filePath;
}
return filePath
.replace(/^\/mnt\/([a-z])\//, (_, driveLetter: string) => {
return `${driveLetter.toUpperCase()}:/`;
})
.replace(/\//g, '\\');
}
test('runSubsyncManual constructs ffsubsync command and returns success', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-'));
const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log');
@@ -162,7 +184,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
ffsubsyncPath,
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
`#!/bin/sh\n: > "${toShellPath(ffsubsyncLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(ffsubsyncLogPath)}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
);
const sentCommands: Array<Array<string | number>> = [];
@@ -204,14 +226,14 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
assert.equal(ffArgs[0], videoPath);
assert.equal(ffArgs[0], toShellPath(videoPath));
assert.ok(ffArgs.includes('-i'));
assert.ok(ffArgs.includes(primaryPath));
assert.ok(ffArgs.includes(toShellPath(primaryPath)));
assert.ok(ffArgs.includes('--reference-stream'));
assert.ok(ffArgs.includes('0:2'));
const ffOutputFlagIndex = ffArgs.indexOf('-o');
assert.equal(ffOutputFlagIndex >= 0, true);
assert.equal(ffArgs[ffOutputFlagIndex + 1], primaryPath);
assert.equal(ffArgs[ffOutputFlagIndex + 1], toShellPath(primaryPath));
assert.equal(sentCommands[0]?.[0], 'sub_add');
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
});
@@ -231,7 +253,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
ffsubsyncPath,
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
`#!/bin/sh\n: > "${toShellPath(ffsubsyncLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(ffsubsyncLogPath)}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
);
const deps = makeDeps({
@@ -273,7 +295,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
const ffOutputFlagIndex = ffArgs.indexOf('-o');
assert.equal(ffOutputFlagIndex >= 0, true);
const outputPath = ffArgs[ffOutputFlagIndex + 1];
assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt'));
assert.equal(outputPath, toShellPath(path.join(tmpDir, 'episode.ja_retimed.srt')));
});
test('runSubsyncManual reports ffsubsync command failures with details', async () => {
@@ -346,7 +368,7 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
alassPath,
`#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\nexit 1\n`,
`#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\nexit 1\n`,
);
const deps = makeDeps({
@@ -393,8 +415,8 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
assert.equal(typeof result.message, 'string');
assert.equal(result.message.startsWith('alass synchronization failed'), true);
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
assert.equal(alassArgs[0], sourcePath);
assert.equal(alassArgs[1], primaryPath);
assert.equal(alassArgs[0], toShellPath(sourcePath));
assert.equal(alassArgs[1], toShellPath(primaryPath));
});
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
@@ -482,7 +504,7 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
ffsubsyncPath,
`#!/bin/sh\nmkdir -p "${tmpDir}"\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nprev=""\nout=""\nfor arg in "$@"; do\n if [ "$prev" = "--reference-stream" ]; then :; fi\n if [ "$prev" = "-o" ]; then out="$arg"; fi\n prev="$arg"\ndone\nif [ -n "$out" ]; then : > "$out"; fi`,
`#!/bin/sh\nmkdir -p "${toShellPath(tmpDir)}"\n: > "${toShellPath(ffsubsyncLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(ffsubsyncLogPath)}"; done\nprev=""\nout=""\nfor arg in "$@"; do\n if [ "$prev" = "--reference-stream" ]; then :; fi\n if [ "$prev" = "-o" ]; then out="$arg"; fi\n prev="$arg"\ndone\nif [ -n "$out" ]; then : > "$out"; fi`,
);
const deps = makeDeps({
@@ -526,5 +548,5 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
const outputPath = ffArgs[syncOutputIndex + 1];
assert.equal(typeof outputPath, 'string');
assert.ok(outputPath!.length > 0);
assert.equal(fs.readFileSync(outputPath!, 'utf8'), '');
assert.equal(fs.readFileSync(fromShellPath(outputPath!), 'utf8'), '');
});

View File

@@ -8,18 +8,21 @@ import {
} from './yomitan-extension-paths';
test('getYomitanExtensionSearchPaths prioritizes generated build output before packaged fallbacks', () => {
const repoRoot = path.resolve('repo');
const resourcesPath = path.join(path.sep, 'opt', 'SubMiner', 'resources');
const userDataPath = path.join(path.sep, 'Users', 'kyle', '.config', 'SubMiner');
const searchPaths = getYomitanExtensionSearchPaths({
cwd: '/repo',
moduleDir: '/repo/dist/core/services',
resourcesPath: '/opt/SubMiner/resources',
userDataPath: '/Users/kyle/.config/SubMiner',
cwd: repoRoot,
moduleDir: path.join(repoRoot, 'dist', 'core', 'services'),
resourcesPath,
userDataPath,
});
assert.deepEqual(searchPaths, [
path.join('/repo', 'build', 'yomitan'),
path.join('/opt/SubMiner/resources', 'yomitan'),
path.join(repoRoot, 'build', 'yomitan'),
path.join(resourcesPath, 'yomitan'),
'/usr/share/SubMiner/yomitan',
path.join('/Users/kyle/.config/SubMiner', 'yomitan'),
path.join(userDataPath, 'yomitan'),
]);
});

42
src/logger.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { resolveDefaultLogFilePath } from './logger';
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
const resolved = resolveDefaultLogFilePath({
platform: 'win32',
homeDir: 'C:\\Users\\tester',
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
});
assert.equal(
path.normalize(resolved),
path.normalize(
path.join(
'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner',
'logs',
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
),
),
);
});
test('resolveDefaultLogFilePath uses .config on linux', () => {
const resolved = resolveDefaultLogFilePath({
platform: 'linux',
homeDir: '/home/tester',
});
assert.equal(
resolved,
path.join(
'/home/tester',
'.config',
'SubMiner',
'logs',
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
),
);
});

View File

@@ -116,8 +116,26 @@ function resolveLogFilePath(): string {
if (envPath) {
return envPath;
}
return resolveDefaultLogFilePath({
platform: process.platform,
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
});
}
export function resolveDefaultLogFilePath(options?: {
platform?: NodeJS.Platform;
homeDir?: string;
appDataDir?: string;
}): string {
const date = new Date().toISOString().slice(0, 10);
return path.join(os.homedir(), '.config', 'SubMiner', 'logs', `SubMiner-${date}.log`);
const platform = options?.platform ?? process.platform;
const homeDir = options?.homeDir ?? os.homedir();
const baseDir =
platform === 'win32'
? path.join(options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'), 'SubMiner')
: path.join(homeDir, '.config', 'SubMiner');
return path.join(baseDir, 'logs', `SubMiner-${date}.log`);
}
function appendToLogFile(line: string): void {

View File

@@ -2,32 +2,53 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeStartupArgv,
normalizeLaunchMpvTargets,
sanitizeHelpEnv,
sanitizeLaunchMpvEnv,
sanitizeStartupEnv,
sanitizeBackgroundEnv,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
} from './main-entry-runtime';
test('normalizeStartupArgv defaults no-arg startup to --start --background', () => {
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage'], {}), [
'SubMiner.AppImage',
'--start',
'--background',
]);
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage', '--password-store', 'gnome-libsecret'], {}),
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
);
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--background'], {}), [
'SubMiner.AppImage',
'--background',
'--start',
]);
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--help'], {}), [
'SubMiner.AppImage',
'--help',
]);
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage'], {}), [
'SubMiner.AppImage',
'--start',
'--background',
]);
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage', '--password-store', 'gnome-libsecret'], {}),
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
);
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--background'], {}), [
'SubMiner.AppImage',
'--background',
'--start',
]);
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--help'], {}), [
'SubMiner.AppImage',
'--help',
]);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('normalizeStartupArgv defaults no-arg Windows startup to --start only', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
assert.deepEqual(normalizeStartupArgv(['SubMiner.exe'], {}), ['SubMiner.exe', '--start']);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
@@ -37,6 +58,18 @@ test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], { ELECTRON_RUN_AS_NODE: '1' }), false);
});
test('launch-mpv entry helpers detect and normalize targets', () => {
assert.equal(shouldHandleLaunchMpvAtEntry(['SubMiner.exe', '--launch-mpv'], {}), true);
assert.equal(
shouldHandleLaunchMpvAtEntry(['SubMiner.exe', '--launch-mpv'], { ELECTRON_RUN_AS_NODE: '1' }),
false,
);
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv']), []);
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
'C:\\a.mkv',
]);
});
test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => {
const env = sanitizeStartupEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
@@ -53,6 +86,14 @@ test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
assert.equal('VK_INSTANCE_LAYERS' in env, false);
});
test('sanitizeLaunchMpvEnv suppresses warnings and lsfg layer', () => {
const env = sanitizeLaunchMpvEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
});
assert.equal(env.NODE_NO_WARNINGS, '1');
assert.equal('VK_INSTANCE_LAYERS' in env, false);
});
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
const env = sanitizeBackgroundEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',

View File

@@ -45,6 +45,9 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
if (effectiveArgs.length === 0) {
if (process.platform === 'win32') {
return [...argv, START_ARG];
}
return [...argv, START_ARG, BACKGROUND_ARG];
}
@@ -72,6 +75,15 @@ export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessE
return args.help && !shouldStartApp(args);
}
export function shouldHandleLaunchMpvAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
return parseCliArgs(argv).launchMpv;
}
export function normalizeLaunchMpvTargets(argv: string[]): string[] {
return parseCliArgs(argv).launchMpvTargets;
}
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
if (!env.NODE_NO_WARNINGS) {
@@ -85,6 +97,10 @@ export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return sanitizeStartupEnv(baseEnv);
}
export function sanitizeLaunchMpvEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return sanitizeStartupEnv(baseEnv);
}
export function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = sanitizeStartupEnv(baseEnv);
env[BACKGROUND_CHILD_ENV] = '1';

View File

@@ -1,13 +1,19 @@
import { spawn } from 'node:child_process';
import { app, dialog } from 'electron';
import { printHelp } from './cli/help';
import {
normalizeLaunchMpvTargets,
normalizeStartupArgv,
sanitizeStartupEnv,
sanitizeBackgroundEnv,
sanitizeHelpEnv,
sanitizeLaunchMpvEnv,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
} from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
const DEFAULT_TEXTHOOKER_PORT = 5174;
@@ -46,4 +52,25 @@ if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
process.exit(0);
}
require('./main.js');
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
applySanitizedEnv(sanitizedEnv);
void app.whenReady().then(() => {
const result = launchWindowsMpv(
normalizeLaunchMpvTargets(process.argv),
createWindowsMpvLaunchDeps({
getEnv: (name) => process.env[name],
showError: (title, content) => {
dialog.showErrorBox(title, content);
},
}),
);
app.exit(result.ok ? 0 : 1);
});
} else {
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
if (!gotSingleInstanceLock) {
app.exit(0);
}
require('./main.js');
}

View File

@@ -99,6 +99,7 @@ import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { RuntimeOptionsManager } from './runtime-options';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
import { createLogger, setLogLevel, type LogLevelSource } from './logger';
import { resolveDefaultLogFilePath } from './logger';
import {
commandNeedsOverlayRuntime,
parseArgs,
@@ -310,12 +311,17 @@ import {
createMaybeFocusExistingFirstRunSetupWindowHandler,
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
type FirstRunSetupAction,
type FirstRunSetupSubmission,
} from './main/runtime/first-run-setup-window';
import {
detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation,
} from './main/runtime/first-run-setup-plugin';
import {
applyWindowsMpvShortcuts,
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
} from './main/runtime/windows-mpv-shortcuts';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
@@ -344,6 +350,10 @@ import {
} from './main/runtime/composers';
import { createStartupBootstrapRuntimeDeps } from './main/startup';
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
import {
registerSecondInstanceHandlerEarly,
requestSingleInstanceLockEarly,
} from './main/early-single-instance';
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
@@ -362,6 +372,10 @@ import { createMediaRuntimeService } from './main/media-runtime';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import {
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
shouldForceOverrideYomitanAnkiServer,
} from './main/runtime/yomitan-anki-server';
import {
type AnilistMediaGuessRuntimeState,
type StartupState,
@@ -401,7 +415,11 @@ if (process.platform === 'linux') {
app.setName('SubMiner');
const DEFAULT_TEXTHOOKER_PORT = 5174;
const DEFAULT_MPV_LOG_FILE = path.join(os.homedir(), '.cache', 'SubMiner', 'mp.log');
const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
platform: process.platform,
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
});
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
const ANILIST_SETUP_RESPONSE_TYPE = 'token';
const ANILIST_DEFAULT_CLIENT_ID = '36084';
@@ -462,6 +480,8 @@ function applyJellyfinMpvDefaults(
}
const CONFIG_DIR = resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
@@ -480,7 +500,7 @@ const configService = (() => {
{
logError: (details) => console.error(details),
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
quit: () => app.quit(),
quit: () => requestAppQuit(),
},
);
}
@@ -552,6 +572,22 @@ const appLogger = {
},
};
const runtimeRegistry = createMainRuntimeRegistry();
const appLifecycleApp = {
requestSingleInstanceLock: () => requestSingleInstanceLockEarly(app),
quit: () => app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {
registerSecondInstanceHandlerEarly(
app,
listener as (_event: unknown, argv: string[]) => void,
);
return app;
}
app.on(event as Parameters<typeof app.on>[0], listener as (...args: any[]) => void);
return app;
},
whenReady: () => app.whenReady(),
};
const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({
platform: process.platform,
@@ -568,11 +604,23 @@ if (!fs.existsSync(USER_DATA_PATH)) {
}
app.setPath('userData', USER_DATA_PATH);
process.on('SIGINT', () => {
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
function requestAppQuit(): void {
if (!forceQuitTimer) {
forceQuitTimer = setTimeout(() => {
logger.warn('App quit timed out; forcing process exit.');
app.exit(0);
}, 2000);
}
app.quit();
}
process.on('SIGINT', () => {
requestAppQuit();
});
process.on('SIGTERM', () => {
app.quit();
requestAppQuit();
});
const overlayManager = createOverlayManager();
@@ -623,7 +671,13 @@ const appState = createAppState({
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
let firstRunSetupMessage: string | null = null;
const resolveWindowsMpvShortcutRuntimePaths = () =>
resolveWindowsMpvShortcutPaths({
appDataDir: app.getPath('appData'),
desktopDir: app.getPath('desktop'),
});
const firstRunSetupService = createFirstRunSetupService({
platform: process.platform,
configDir: CONFIG_DIR,
getYomitanDictionaryCount: async () => {
await ensureYomitanExtensionLoaded();
@@ -650,6 +704,31 @@ const firstRunSetupService = createFirstRunSetupService({
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}),
detectWindowsMpvShortcuts: () => {
if (process.platform !== 'win32') {
return {
startMenuInstalled: false,
desktopInstalled: false,
};
}
return detectWindowsMpvShortcuts(resolveWindowsMpvShortcutRuntimePaths());
},
applyWindowsMpvShortcuts: async (preferences) => {
if (process.platform !== 'win32') {
return {
ok: true,
status: 'unknown' as const,
message: '',
};
}
return applyWindowsMpvShortcuts({
preferences,
paths: resolveWindowsMpvShortcutRuntimePaths(),
exePath: process.execPath,
writeShortcutLink: (shortcutPath, operation, details) =>
shell.writeShortcutLink(shortcutPath, operation, details),
});
},
onStateChanged: (state) => {
appState.firstRunSetupCompleted = state.status === 'completed';
if (appTray) {
@@ -969,8 +1048,22 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
appState.shortcutsRegistered = registered;
},
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
isMacOSPlatform: () => process.platform === 'darwin',
isTrackedMpvWindowFocused: () => appState.windowTracker?.isFocused() ?? false,
isOverlayShortcutContextActive: () => {
if (process.platform !== 'win32') {
return true;
}
if (!overlayManager.getVisibleOverlayVisible()) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker || !windowTracker.isTracking()) {
return false;
}
return windowTracker.isTargetWindowFocused();
},
showMpvOsd: (text: string) => showMpvOsd(text),
openRuntimeOptionsPalette: () => {
openRuntimeOptionsPalette();
@@ -1080,22 +1173,26 @@ const configHotReloadRuntime = createConfigHotReloadRuntime(
);
const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
platform: process.platform,
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
userDataPath: USER_DATA_PATH,
appUserDataPath: app.getPath('userData'),
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
cwd: process.cwd(),
joinPath: (...parts) => path.join(...parts),
});
const buildFrequencyDictionaryRootsHandler = createBuildFrequencyDictionaryRootsMainHandler({
platform: process.platform,
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
userDataPath: USER_DATA_PATH,
appUserDataPath: app.getPath('userData'),
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
cwd: process.cwd(),
joinPath: (...parts) => path.join(...parts),
});
@@ -1292,6 +1389,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
overlayShortcutsRuntime.syncOverlayShortcuts();
},
isMacOSPlatform: () => process.platform === 'darwin',
isWindowsPlatform: () => process.platform === 'win32',
showOverlayLoadingOsd: (message: string) => {
showMpvOsd(message);
},
@@ -1687,28 +1785,37 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
canFinish: snapshot.canFinish,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
message: firstRunSetupMessage,
};
},
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (action: FirstRunSetupAction) => {
if (action === 'install-plugin') {
handleAction: async (submission: FirstRunSetupSubmission) => {
if (submission.action === 'install-plugin') {
const snapshot = await firstRunSetupService.installMpvPlugin();
firstRunSetupMessage = snapshot.message;
return;
}
if (action === 'open-yomitan-settings') {
if (submission.action === 'configure-windows-mpv-shortcuts') {
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
startMenuEnabled: submission.startMenuEnabled === true,
desktopEnabled: submission.desktopEnabled === true,
});
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'open-yomitan-settings') {
openYomitanSettings();
firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.';
return;
}
if (action === 'refresh') {
if (submission.action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
firstRunSetupMessage = snapshot.message;
return;
}
if (action === 'skip-plugin') {
if (submission.action === 'skip-plugin') {
await firstRunSetupService.skipPluginInstall();
firstRunSetupMessage = 'mpv plugin installation skipped.';
return;
@@ -1731,6 +1838,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
await firstRunSetupService.markSetupCancelled();
},
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
quitApp: () => requestAppQuit(),
clearSetupWindow: () => {
appState.firstRunSetupWindow = null;
},
@@ -2151,7 +2260,7 @@ const {
app.on('open-url', listener);
},
registerSecondInstance: (listener) => {
app.on('second-instance', listener);
registerSecondInstanceHandlerEarly(app, listener);
},
handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl),
findAnilistSetupDeepLinkArgvUrl: (argv) => findAnilistSetupDeepLinkArgvUrl(argv),
@@ -2202,6 +2311,14 @@ const {
clearJellyfinSetupWindow: () => {
appState.jellyfinSetupWindow = null;
},
getFirstRunSetupWindow: () => appState.firstRunSetupWindow,
clearFirstRunSetupWindow: () => {
appState.firstRunSetupWindow = null;
},
getYomitanSettingsWindow: () => appState.yomitanSettingsWindow,
clearYomitanSettingsWindow: () => {
appState.yomitanSettingsWindow = null;
},
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop();
@@ -2266,10 +2383,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
failHandlers: {
logError: (details) => logger.error(details),
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
setExitCode: (code) => {
process.exitCode = code;
},
quit: () => app.quit(),
quit: () => requestAppQuit(),
},
},
criticalConfigErrorMainDeps: {
@@ -2277,10 +2391,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
failHandlers: {
logError: (message) => logger.error(message),
showErrorBox: (title, message) => dialog.showErrorBox(title, message),
setExitCode: (code) => {
process.exitCode = code;
},
quit: () => app.quit(),
quit: () => requestAppQuit(),
},
},
appReadyRuntimeMainDeps: {
@@ -2432,7 +2543,7 @@ const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntime
ReturnType<typeof createStartupBootstrapRuntimeDeps>
>({
appLifecycleRuntimeRunnerMainDeps: {
app,
app: appLifecycleApp,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
@@ -2476,7 +2587,7 @@ const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntime
setExitCode: (code) => {
process.exitCode = code;
},
quitApp: () => app.quit(),
quitApp: () => requestAppQuit(),
logGenerateConfigError: (message) => logger.error(message),
startAppLifecycle,
}),
@@ -2510,6 +2621,7 @@ const handleCliCommand = createCliCommandRuntimeHandler({
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
getInitialArgs: () => appState.initialArgs,
isBackgroundMode: () => appState.backgroundMode,
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
ensureTray: () => ensureTray(),
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
hasImmersionTracker: () => Boolean(appState.immersionTracker),
@@ -2526,10 +2638,10 @@ const {
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
tokenizeSubtitle,
isTokenizationWarmupReady,
createMecabTokenizerAndCheck,
prewarmSubtitleDictionaries,
startBackgroundWarmups,
isTokenizationWarmupReady,
} = composeMpvRuntimeHandlers<
MpvIpcClient,
ReturnType<typeof createTokenizerDepsRuntime>,
@@ -2541,7 +2653,7 @@ const {
scheduleQuitCheck: (callback) => {
setTimeout(callback, 500);
},
quitApp: () => app.quit(),
quitApp: () => requestAppQuit(),
reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped();
},
@@ -2566,12 +2678,6 @@ const {
}
mediaRuntime.updateCurrentMediaPath(path);
},
signalAutoplayReadyIfWarm: (path) => {
if (!isTokenizationWarmupReady()) {
return;
}
maybeSignalPluginAutoplayReady({ text: path, tokens: null }, { forceWhilePaused: true });
},
restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles();
},
@@ -2588,6 +2694,15 @@ const {
syncImmersionMediaState: () => {
immersionMediaRuntime.syncFromCurrentMediaState();
},
signalAutoplayReadyIfWarm: () => {
if (!isTokenizationWarmupReady()) {
return;
}
maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
},
scheduleCharacterDictionarySync: () => {
characterDictionaryAutoSyncRuntime.scheduleSync();
},
@@ -2849,13 +2964,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
let lastSyncedYomitanAnkiServer: string | null = null;
function getPreferredYomitanAnkiServerUrl(): string {
const config = getResolvedConfig().ankiConnect;
if (config.proxy?.enabled) {
const host = config.proxy.host || '127.0.0.1';
const port = config.proxy.port || 8766;
return `http://${host}:${port}`;
}
return config.url;
return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect);
}
function getYomitanParserRuntimeDeps() {
@@ -2894,7 +3003,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
},
},
{
forceOverride: getResolvedConfig().ankiConnect.proxy?.enabled === true,
forceOverride: shouldForceOverrideYomitanAnkiServer(getResolvedConfig().ankiConnect),
},
);
@@ -3244,7 +3353,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
overlayModalRuntime.notifyOverlayModalOpened(modal);
},
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => app.quit(),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleRaw: () => appState.currentSubText,
@@ -3345,7 +3454,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => app.quit(),
stopApp: () => requestAppQuit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
@@ -3395,11 +3504,12 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
openAnilistSetupWindow: () => openAnilistSetupWindow(),
quitApp: () => app.quit(),
quitApp: () => requestAppQuit(),
},
ensureTrayDeps: {
getTray: () => appTray,

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
registerSecondInstanceHandlerEarly,
requestSingleInstanceLockEarly,
resetEarlySingleInstanceStateForTests,
} from './early-single-instance';
function createFakeApp(lockValue = true) {
let requestCalls = 0;
let secondInstanceListener: ((_event: unknown, argv: string[]) => void) | null = null;
return {
app: {
requestSingleInstanceLock: () => {
requestCalls += 1;
return lockValue;
},
on: (_event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => {
secondInstanceListener = listener;
},
},
emitSecondInstance: (argv: string[]) => {
secondInstanceListener?.({}, argv);
},
getRequestCalls: () => requestCalls,
};
}
test('requestSingleInstanceLockEarly caches the lock result per process', () => {
resetEarlySingleInstanceStateForTests();
const fake = createFakeApp(true);
assert.equal(requestSingleInstanceLockEarly(fake.app), true);
assert.equal(requestSingleInstanceLockEarly(fake.app), true);
assert.equal(fake.getRequestCalls(), 1);
});
test('registerSecondInstanceHandlerEarly replays queued argv and forwards new events', () => {
resetEarlySingleInstanceStateForTests();
const fake = createFakeApp(true);
const calls: string[][] = [];
assert.equal(requestSingleInstanceLockEarly(fake.app), true);
fake.emitSecondInstance(['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer']);
registerSecondInstanceHandlerEarly(fake.app, (_event, argv) => {
calls.push(argv);
});
fake.emitSecondInstance(['SubMiner.exe', '--start', '--show-visible-overlay']);
assert.deepEqual(calls, [
['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer'],
['SubMiner.exe', '--start', '--show-visible-overlay'],
]);
});

View File

@@ -0,0 +1,54 @@
interface ElectronSecondInstanceAppLike {
requestSingleInstanceLock: () => boolean;
on: (
event: 'second-instance',
listener: (_event: unknown, argv: string[]) => void,
) => unknown;
}
let cachedSingleInstanceLock: boolean | null = null;
let secondInstanceListenerAttached = false;
const secondInstanceArgvHistory: string[][] = [];
const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>();
function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void {
if (secondInstanceListenerAttached) return;
app.on('second-instance', (event, argv) => {
const clonedArgv = [...argv];
secondInstanceArgvHistory.push(clonedArgv);
for (const handler of secondInstanceHandlers) {
handler(event, [...clonedArgv]);
}
});
secondInstanceListenerAttached = true;
}
export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean {
attachSecondInstanceListener(app);
if (cachedSingleInstanceLock !== null) {
return cachedSingleInstanceLock;
}
cachedSingleInstanceLock = app.requestSingleInstanceLock();
return cachedSingleInstanceLock;
}
export function registerSecondInstanceHandlerEarly(
app: ElectronSecondInstanceAppLike,
handler: (_event: unknown, argv: string[]) => void,
): () => void {
attachSecondInstanceListener(app);
secondInstanceHandlers.add(handler);
for (const argv of secondInstanceArgvHistory) {
handler(undefined, [...argv]);
}
return () => {
secondInstanceHandlers.delete(handler);
};
}
export function resetEarlySingleInstanceStateForTests(): void {
cachedSingleInstanceLock = null;
secondInstanceListenerAttached = false;
secondInstanceArgvHistory.length = 0;
secondInstanceHandlers.clear();
}

View File

@@ -6,10 +6,9 @@ import {
import {
refreshOverlayShortcutsRuntime,
registerOverlayShortcuts,
shouldActivateOverlayShortcuts,
syncOverlayShortcutsRuntime,
unregisterOverlayShortcutsRuntime,
} from '../core/services/overlay-shortcut';
} from '../core/services';
import { runOverlayShortcutLocalFallback } from '../core/services/overlay-shortcut-handler';
export interface OverlayShortcutRuntimeServiceInput {
@@ -17,8 +16,7 @@ export interface OverlayShortcutRuntimeServiceInput {
getShortcutsRegistered: () => boolean;
setShortcutsRegistered: (registered: boolean) => void;
isOverlayRuntimeInitialized: () => boolean;
isMacOSPlatform: () => boolean;
isTrackedMpvWindowFocused: () => boolean;
isOverlayShortcutContextActive?: () => boolean;
showMpvOsd: (text: string) => void;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
@@ -93,11 +91,7 @@ export function createOverlayShortcutsRuntimeService(
};
const shouldOverlayShortcutsBeActive = () =>
shouldActivateOverlayShortcuts({
overlayRuntimeInitialized: input.isOverlayRuntimeInitialized(),
isMacOSPlatform: input.isMacOSPlatform(),
trackedMpvWindowFocused: input.isTrackedMpvWindowFocused(),
});
input.isOverlayRuntimeInitialized() && (input.isOverlayShortcutContextActive?.() ?? true);
return {
tryHandleOverlayShortcutLocalFallback: (inputEvent) =>

View File

@@ -16,6 +16,7 @@ export interface OverlayVisibilityRuntimeDeps {
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
isMacOSPlatform: () => boolean;
isWindowsPlatform: () => boolean;
showOverlayLoadingOsd: (message: string) => void;
resolveFallbackBounds: () => WindowGeometry;
}
@@ -45,6 +46,7 @@ export function createOverlayVisibilityRuntimeService(
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
isMacOSPlatform: deps.isMacOSPlatform(),
isWindowsPlatform: deps.isWindowsPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
});

View File

@@ -29,12 +29,16 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
destroyFirstRunSetupWindow: () => calls.push('destroy-first-run-window'),
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 22);
assert.equal(calls.length, 26);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));

View File

@@ -19,6 +19,10 @@ export function createOnWillQuitCleanupHandler(deps: {
clearAnilistSetupWindow: () => void;
destroyJellyfinSetupWindow: () => void;
clearJellyfinSetupWindow: () => void;
destroyFirstRunSetupWindow: () => void;
clearFirstRunSetupWindow: () => void;
destroyYomitanSettingsWindow: () => void;
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -43,6 +47,10 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.clearAnilistSetupWindow();
deps.destroyJellyfinSetupWindow();
deps.clearJellyfinSetupWindow();
deps.destroyFirstRunSetupWindow();
deps.clearFirstRunSetupWindow();
deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow();
deps.stopJellyfinRemoteSession();
deps.stopDiscordPresenceService();
};

View File

@@ -46,6 +46,12 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
getFirstRunSetupWindow: () => ({ destroy: () => calls.push('destroy-first-run-window') }),
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
getYomitanSettingsWindow: () => ({
destroy: () => calls.push('destroy-yomitan-settings-window'),
}),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
@@ -61,6 +67,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('clear-reconnect-ref'));
assert.ok(calls.includes('destroy-immersion'));
assert.ok(calls.includes('clear-immersion-ref'));
assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('stop-discord-presence'));
assert.equal(reconnectTimer, null);
@@ -95,6 +103,10 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
stopDiscordPresenceService: () => {},
});

View File

@@ -44,6 +44,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearAnilistSetupWindow: () => void;
getJellyfinSetupWindow: () => Destroyable | null;
clearJellyfinSetupWindow: () => void;
getFirstRunSetupWindow: () => Destroyable | null;
clearFirstRunSetupWindow: () => void;
getYomitanSettingsWindow: () => Destroyable | null;
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
@@ -98,6 +102,14 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
deps.getJellyfinSetupWindow()?.destroy();
},
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
destroyFirstRunSetupWindow: () => {
deps.getFirstRunSetupWindow()?.destroy();
},
clearFirstRunSetupWindow: () => deps.clearFirstRunSetupWindow(),
destroyYomitanSettingsWindow: () => {
deps.getYomitanSettingsWindow()?.destroy();
},
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});

View File

@@ -35,6 +35,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {},
stopDiscordPresenceService: () => {},
},

View File

@@ -9,6 +9,7 @@ import {
test('dictionary roots main handler returns expected root list', () => {
const roots = createBuildDictionaryRootsMainHandler({
platform: 'darwin',
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
@@ -44,6 +45,7 @@ test('jlpt dictionary runtime main deps builder maps search paths and log prefix
test('frequency dictionary roots main handler returns expected root list', () => {
const roots = createBuildFrequencyDictionaryRootsMainHandler({
platform: 'darwin',
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
@@ -59,6 +61,42 @@ test('frequency dictionary roots main handler returns expected root list', () =>
assert.equal(roots[10], '/repo');
});
test('dictionary roots main handler uses APPDATA-style roots on windows', () => {
const roots = createBuildDictionaryRootsMainHandler({
platform: 'win32',
dirname: 'C:\\repo\\dist\\main',
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
homeDir: 'C:\\Users\\a',
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
cwd: 'C:\\repo',
joinPath: (...parts) => parts.join('\\'),
})();
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
});
test('frequency dictionary roots main handler uses APPDATA-style roots on windows', () => {
const roots = createBuildFrequencyDictionaryRootsMainHandler({
platform: 'win32',
dirname: 'C:\\repo\\dist\\main',
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
homeDir: 'C:\\Users\\a',
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
cwd: 'C:\\repo',
joinPath: (...parts) => parts.join('\\'),
})();
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
});
test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => {
const calls: string[] = [];
const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({

View File

@@ -3,53 +3,93 @@ import type { FrequencyDictionaryLookup, JlptLevel } from '../../types';
type JlptLookup = (term: string) => JlptLevel | null;
export function createBuildDictionaryRootsMainHandler(deps: {
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return () => [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
deps.userDataPath,
deps.appUserDataPath,
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
deps.cwd,
];
return () => {
const platformRoots =
deps.platform === 'win32'
? [
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'SubMiner',
),
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'subminer',
),
]
: [
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
];
return [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
deps.userDataPath,
deps.appUserDataPath,
...platformRoots,
deps.cwd,
];
};
}
export function createBuildFrequencyDictionaryRootsMainHandler(deps: {
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return () => [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
deps.userDataPath,
deps.appUserDataPath,
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
deps.cwd,
];
return () => {
const platformRoots =
deps.platform === 'win32'
? [
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'SubMiner',
),
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'subminer',
),
]
: [
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
];
return [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
deps.userDataPath,
deps.appUserDataPath,
...platformRoots,
deps.cwd,
];
};
}
export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: {

View File

@@ -54,8 +54,10 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
fs.mkdirSync(path.dirname(installPaths.pluginEntrypointPath), { recursive: true });
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(path.join(installPaths.scriptsDir, 'subminer-loader.lua'), '-- old loader');
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
@@ -72,7 +74,7 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(
fs.readFileSync(path.join(installPaths.pluginDir, 'main.lua'), 'utf8'),
fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'),
'-- packaged plugin',
);
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
@@ -83,6 +85,10 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')),
true,
);
assert.equal(
scriptsDirEntries.some((entry) => entry.startsWith('subminer-loader.lua.bak.')),
true,
);
assert.equal(
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
true,
@@ -90,17 +96,71 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
});
});
test('installFirstRunPluginToDefaultLocation reports unsupported platforms', () => {
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir: '/tmp/home',
xdgConfigHome: '/tmp/xdg',
dirname: '/tmp/dist/main/runtime',
appPath: '/tmp/app',
resourcesPath: '/tmp/resources',
});
test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defaults', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
assert.equal(result.ok, false);
assert.equal(result.pluginInstallStatus, 'failed');
assert.match(result.message, /not supported/i);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(
fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'),
'-- packaged plugin',
);
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\n',
);
});
});
test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(
path.join(pluginRoot, 'subminer.conf'),
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
});
assert.equal(result.ok, true);
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
);
});
});

View File

@@ -12,6 +12,25 @@ function backupExistingPath(targetPath: string): void {
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
}
function resolveLegacyPluginLoaderPath(installPaths: MpvInstallPaths): string {
return path.join(installPaths.scriptsDir, 'subminer.lua');
}
function resolveLegacyPluginDebugLoaderPath(installPaths: MpvInstallPaths): string {
return path.join(installPaths.scriptsDir, 'subminer-loader.lua');
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
const content = fs.readFileSync(configPath, 'utf8');
const updated = content.replace(
/^socket_path=.*$/m,
'socket_path=\\\\.\\pipe\\subminer-socket',
);
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
export function resolvePackagedFirstRunPluginAssets(deps: {
dirname: string;
appPath: string;
@@ -32,7 +51,11 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
for (const root of roots) {
const pluginDirSource = joinPath(root, 'subminer');
const pluginConfigSource = joinPath(root, 'subminer.conf');
if (existsSync(pluginDirSource) && existsSync(pluginConfigSource)) {
if (
existsSync(pluginDirSource) &&
existsSync(pluginConfigSource) &&
existsSync(joinPath(pluginDirSource, 'main.lua'))
) {
return { pluginDirSource, pluginConfigSource };
}
}
@@ -45,7 +68,11 @@ export function detectInstalledFirstRunPlugin(
deps?: { existsSync?: (candidate: string) => boolean },
): boolean {
const existsSync = deps?.existsSync ?? fs.existsSync;
return existsSync(installPaths.pluginDir) && existsSync(installPaths.pluginConfigPath);
return (
existsSync(installPaths.pluginEntrypointPath) &&
existsSync(installPaths.pluginDir) &&
existsSync(installPaths.pluginConfigPath)
);
}
export function installFirstRunPluginToDefaultLocation(options: {
@@ -86,10 +113,15 @@ export function installFirstRunPluginToDefaultLocation(options: {
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
backupExistingPath(resolveLegacyPluginLoaderPath(installPaths));
backupExistingPath(resolveLegacyPluginDebugLoaderPath(installPaths));
backupExistingPath(installPaths.pluginDir);
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}
return {
ok: true,

View File

@@ -21,6 +21,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,
@@ -169,3 +171,79 @@ test('setup service marks cancelled when popup closes before completion', async
assert.equal(cancelled.state.status, 'cancelled');
});
});
test('setup service reflects detected Windows mpv shortcuts before preferences are persisted', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
platform: 'win32',
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
detectWindowsMpvShortcuts: async () => ({
startMenuInstalled: false,
desktopInstalled: true,
}),
onStateChanged: () => undefined,
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.windowsMpvShortcuts.startMenuEnabled, false);
assert.equal(snapshot.windowsMpvShortcuts.desktopEnabled, true);
assert.equal(snapshot.windowsMpvShortcuts.startMenuInstalled, false);
assert.equal(snapshot.windowsMpvShortcuts.desktopInstalled, true);
});
});
test('setup service persists Windows mpv shortcut preferences and status with one state write', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const stateChanges: string[] = [];
const service = createFirstRunSetupService({
platform: 'win32',
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
applyWindowsMpvShortcuts: async () => ({
ok: true,
status: 'installed',
message: 'shortcuts updated',
}),
onStateChanged: (state) => {
stateChanges.push(state.windowsMpvShortcutLastStatus);
},
});
await service.ensureSetupStateInitialized();
stateChanges.length = 0;
const snapshot = await service.configureWindowsMpvShortcuts({
startMenuEnabled: false,
desktopEnabled: true,
});
assert.equal(snapshot.windowsMpvShortcuts.startMenuEnabled, false);
assert.equal(snapshot.windowsMpvShortcuts.desktopEnabled, true);
assert.equal(snapshot.state.windowsMpvShortcutLastStatus, 'installed');
assert.equal(snapshot.message, 'shortcuts updated');
assert.deepEqual(stateChanges, ['installed']);
});
});

View File

@@ -7,16 +7,28 @@ import {
readSetupState,
writeSetupState,
type SetupPluginInstallStatus,
type SetupWindowsMpvShortcutInstallStatus,
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
export interface SetupWindowsMpvShortcutSnapshot {
supported: boolean;
startMenuEnabled: boolean;
desktopEnabled: boolean;
startMenuInstalled: boolean;
desktopInstalled: boolean;
status: 'installed' | 'optional' | 'skipped' | 'failed';
message: string | null;
}
export interface SetupStatusSnapshot {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
message: string | null;
state: SetupState;
}
@@ -37,6 +49,10 @@ export interface FirstRunSetupService {
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
configureWindowsMpvShortcuts: (preferences: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<SetupStatusSnapshot>;
isSetupCompleted: () => boolean;
}
@@ -44,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
return Boolean(
args.toggle ||
args.toggleVisibleOverlay ||
args.launchMpv ||
args.settings ||
args.show ||
args.hide ||
@@ -95,15 +112,51 @@ function getPluginStatus(
return 'optional';
}
function getWindowsMpvShortcutStatus(
state: SetupState,
installed: { startMenuInstalled: boolean; desktopInstalled: boolean },
): SetupWindowsMpvShortcutSnapshot['status'] {
if (installed.startMenuInstalled || installed.desktopInstalled) return 'installed';
if (state.windowsMpvShortcutLastStatus === 'skipped') return 'skipped';
if (state.windowsMpvShortcutLastStatus === 'failed') return 'failed';
return 'optional';
}
function getEffectiveWindowsMpvShortcutPreferences(
state: SetupState,
installed: { startMenuInstalled: boolean; desktopInstalled: boolean },
): { startMenuEnabled: boolean; desktopEnabled: boolean } {
if (state.windowsMpvShortcutLastStatus === 'unknown') {
return {
startMenuEnabled: installed.startMenuInstalled,
desktopEnabled: installed.desktopInstalled,
};
}
return {
startMenuEnabled: state.windowsMpvShortcutPreferences.startMenuEnabled,
desktopEnabled: state.windowsMpvShortcutPreferences.desktopEnabled,
};
}
export function createFirstRunSetupService(deps: {
platform?: NodeJS.Platform;
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>;
detectWindowsMpvShortcuts?: () =>
| { startMenuInstalled: boolean; desktopInstalled: boolean }
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
applyWindowsMpvShortcuts?: (preferences: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<{ ok: boolean; status: SetupWindowsMpvShortcutInstallStatus; message: string }>;
onStateChanged?: (state: SetupState) => void;
}): FirstRunSetupService {
const setupStatePath = getSetupStatePath(deps.configDir);
const configFilePaths = getDefaultConfigFilePaths(deps.configDir);
const isWindows = (deps.platform ?? process.platform) === 'win32';
let completed = false;
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
@@ -117,6 +170,17 @@ export function createFirstRunSetupService(deps: {
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const dictionaryCount = await deps.getYomitanDictionaryCount();
const pluginInstalled = await deps.detectPluginInstalled();
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
: undefined;
const installedWindowsMpvShortcuts = {
startMenuInstalled: detectedWindowsMpvShortcuts?.startMenuInstalled ?? false,
desktopInstalled: detectedWindowsMpvShortcuts?.desktopInstalled ?? false,
};
const effectiveWindowsMpvShortcutPreferences = getEffectiveWindowsMpvShortcutPreferences(
state,
installedWindowsMpvShortcuts,
);
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
return {
@@ -125,6 +189,15 @@ export function createFirstRunSetupService(deps: {
canFinish: dictionaryCount >= 1,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
windowsMpvShortcuts: {
supported: isWindows,
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
desktopEnabled: effectiveWindowsMpvShortcutPreferences.desktopEnabled,
startMenuInstalled: installedWindowsMpvShortcuts.startMenuInstalled,
desktopInstalled: installedWindowsMpvShortcuts.desktopInstalled,
status: getWindowsMpvShortcutStatus(state, installedWindowsMpvShortcuts),
message: null,
},
message,
state,
} satisfies SetupStatusSnapshot;
@@ -220,6 +293,33 @@ export function createFirstRunSetupService(deps: {
result.message,
);
},
configureWindowsMpvShortcuts: async (preferences) => {
if (!isWindows || !deps.applyWindowsMpvShortcuts) {
return refreshWithState(
writeState({
...readState(),
windowsMpvShortcutPreferences: {
startMenuEnabled: preferences.startMenuEnabled,
desktopEnabled: preferences.desktopEnabled,
},
}),
null,
);
}
const result = await deps.applyWindowsMpvShortcuts(preferences);
const latestState = readState();
return refreshWithState(
writeState({
...latestState,
windowsMpvShortcutPreferences: {
startMenuEnabled: preferences.startMenuEnabled,
desktopEnabled: preferences.desktopEnabled,
},
windowsMpvShortcutLastStatus: result.status,
}),
result.message,
);
},
isSetupCompleted: () => completed || isSetupCompleted(readState()),
};
}

View File

@@ -4,6 +4,7 @@ import {
buildFirstRunSetupHtml,
createHandleFirstRunSetupNavigationHandler,
createMaybeFocusExistingFirstRunSetupWindowHandler,
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
} from './first-run-setup-window';
@@ -14,6 +15,14 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
canFinish: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: 'Waiting for dictionaries',
});
@@ -31,6 +40,14 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
canFinish: true,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcuts: {
supported: true,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: true,
desktopInstalled: false,
status: 'installed',
},
message: null,
});
@@ -60,8 +77,8 @@ test('first-run setup navigation handler prevents default and dispatches action'
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async (action) => {
calls.push(action);
handleAction: async (submission) => {
calls.push(submission.action);
},
logError: (message) => calls.push(message),
});
@@ -75,3 +92,71 @@ test('first-run setup navigation handler prevents default and dispatches action'
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
});
test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: () => {},
},
loadURL: async () => undefined,
on: (event: 'closed', callback: () => void) => {
if (event === 'closed') {
closedHandler = callback;
}
},
isDestroyed: () => false,
close: () => calls.push('close-window'),
focus: () => {},
}) as never,
getSetupSnapshot: async () => ({
configReady: false,
dictionaryCount: 0,
canFinish: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: () => null,
handleAction: async () => undefined,
markSetupInProgress: async () => undefined,
markSetupCancelled: async () => {
calls.push('cancelled');
},
isSetupCompleted: () => false,
shouldQuitWhenClosedIncomplete: () => true,
quitApp: () => {
calls.push('quit');
},
clearSetupWindow: () => {
calls.push('clear');
},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
if (typeof closedHandler !== 'function') {
throw new Error('expected closed handler');
}
closedHandler();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['set', 'cancelled', 'clear', 'quit']);
});

View File

@@ -16,17 +16,32 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
export type FirstRunSetupAction =
| 'install-plugin'
| 'configure-windows-mpv-shortcuts'
| 'open-yomitan-settings'
| 'refresh'
| 'skip-plugin'
| 'finish';
export interface FirstRunSetupSubmission {
action: FirstRunSetupAction;
startMenuEnabled?: boolean;
desktopEnabled?: boolean;
}
export interface FirstRunSetupHtmlModel {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: {
supported: boolean;
startMenuEnabled: boolean;
desktopEnabled: boolean;
startMenuInstalled: boolean;
desktopInstalled: boolean;
status: 'installed' | 'optional' | 'skipped' | 'failed';
};
message: string | null;
}
@@ -61,6 +76,43 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.pluginStatus === 'skipped'
? 'muted'
: 'warn';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
: model.windowsMpvShortcuts.status === 'skipped'
? 'Skipped'
: model.windowsMpvShortcuts.status === 'failed'
? 'Failed'
: 'Optional';
const windowsShortcutTone =
model.windowsMpvShortcuts.status === 'installed'
? 'ready'
: model.windowsMpvShortcuts.status === 'failed'
? 'danger'
: model.windowsMpvShortcuts.status === 'skipped'
? 'muted'
: 'warn';
const windowsShortcutCard = model.windowsMpvShortcuts.supported
? `
<div class="card block">
<div class="card-head">
<div>
<strong>Windows mpv launcher</strong>
<div class="meta">Create standalone \`SubMiner mpv\` shortcuts that run \`SubMiner.exe --launch-mpv\`.</div>
<div class="meta">Installed: Start Menu ${model.windowsMpvShortcuts.startMenuInstalled ? 'yes' : 'no'}, Desktop ${model.windowsMpvShortcuts.desktopInstalled ? 'yes' : 'no'}</div>
</div>
${renderStatusBadge(windowsShortcutLabel, windowsShortcutTone)}
</div>
<form
class="shortcut-form"
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-windows-mpv-shortcuts', startMenu: document.getElementById('shortcut-start-menu').checked ? '1' : '0', desktop: document.getElementById('shortcut-desktop').checked ? '1' : '0' }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
>
<label><input id="shortcut-start-menu" type="checkbox" ${model.windowsMpvShortcuts.startMenuEnabled ? 'checked' : ''} /> Create Start Menu shortcut</label>
<label><input id="shortcut-desktop" type="checkbox" ${model.windowsMpvShortcuts.desktopEnabled ? 'checked' : ''} /> Create Desktop shortcut</label>
<button type="submit">Apply mpv launcher shortcuts</button>
</form>
</div>`
: '';
return `<!doctype html>
<html>
@@ -109,10 +161,30 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
align-items: center;
gap: 12px;
}
.card.block {
display: block;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.meta {
color: var(--muted);
font-size: 12px;
}
.shortcut-form {
display: grid;
gap: 8px;
margin-top: 12px;
}
label {
color: var(--muted);
display: flex;
align-items: center;
gap: 8px;
}
.badge {
display: inline-flex;
align-items: center;
@@ -192,6 +264,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
model.dictionaryCount >= 1 ? 'ready' : 'warn',
)}
</div>
${windowsShortcutCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
@@ -208,7 +281,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
export function parseFirstRunSetupSubmissionUrl(
rawUrl: string,
): { action: FirstRunSetupAction } | null {
): FirstRunSetupSubmission | null {
if (!rawUrl.startsWith('subminer://first-run-setup')) {
return null;
}
@@ -216,6 +289,7 @@ export function parseFirstRunSetupSubmissionUrl(
const action = parsed.searchParams.get('action');
if (
action !== 'install-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
action !== 'skip-plugin' &&
@@ -223,6 +297,13 @@ export function parseFirstRunSetupSubmissionUrl(
) {
return null;
}
if (action === 'configure-windows-mpv-shortcuts') {
return {
action,
startMenuEnabled: parsed.searchParams.get('startMenu') === '1',
desktopEnabled: parsed.searchParams.get('desktop') === '1',
};
}
return { action };
}
@@ -238,15 +319,15 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
}
export function createHandleFirstRunSetupNavigationHandler(deps: {
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<unknown>;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (submission: FirstRunSetupSubmission) => Promise<unknown>;
logError: (message: string, error: unknown) => void;
}) {
return (params: { url: string; preventDefault: () => void }): boolean => {
const submission = deps.parseSubmissionUrl(params.url);
if (!submission) return false;
params.preventDefault();
void deps.handleAction(submission.action).catch((error) => {
void deps.handleAction(submission).catch((error) => {
deps.logError('Failed handling first-run setup action', error);
});
return true;
@@ -260,11 +341,13 @@ export function createOpenFirstRunSetupWindowHandler<
createSetupWindow: () => TWindow;
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
markSetupInProgress: () => Promise<unknown>;
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
shouldQuitWhenClosedIncomplete: () => boolean;
quitApp: () => void;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
encodeURIComponent: (value: string) => string;
@@ -286,8 +369,8 @@ export function createOpenFirstRunSetupWindowHandler<
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: deps.parseSubmissionUrl,
handleAction: async (action) => {
const result = await deps.handleAction(action);
handleAction: async (submission) => {
const result = await deps.handleAction(submission);
if (result?.closeWindow) {
if (!setupWindow.isDestroyed()) {
setupWindow.close();
@@ -313,12 +396,16 @@ export function createOpenFirstRunSetupWindowHandler<
});
setupWindow.on('closed', () => {
if (!deps.isSetupCompleted()) {
const setupCompleted = deps.isSetupCompleted();
if (!setupCompleted) {
void deps.markSetupCancelled().catch((error) => {
deps.logError('Failed marking first-run setup cancelled', error);
});
}
deps.clearSetupWindow();
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
deps.quitApp();
}
});
void deps

View File

@@ -7,6 +7,7 @@ test('initial args handler no-ops without initial args', () => {
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => null,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
@@ -26,6 +27,7 @@ test('initial args handler ensures tray in background mode', () => {
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => {
ensuredTray = true;
},
@@ -46,6 +48,7 @@ test('initial args handler auto-connects mpv when needed', () => {
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
@@ -71,6 +74,7 @@ test('initial args handler forwards args to cli handler', () => {
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
@@ -84,3 +88,23 @@ test('initial args handler forwards args to cli handler', () => {
handleInitialArgs();
assert.deepEqual(seenSources, ['initial']);
});
test('initial args handler can ensure tray outside background mode when requested', () => {
let ensuredTray = false;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => true,
ensureTray: () => {
ensuredTray = true;
},
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
logInfo: () => {},
handleCliCommand: () => {},
});
handleInitialArgs();
assert.equal(ensuredTray, true);
});

View File

@@ -8,6 +8,7 @@ type MpvClientLike = {
export function createHandleInitialArgsHandler(deps: {
getInitialArgs: () => CliArgs | null;
isBackgroundMode: () => boolean;
shouldEnsureTrayOnStartup: () => boolean;
ensureTray: () => void;
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
@@ -19,7 +20,7 @@ export function createHandleInitialArgsHandler(deps: {
const initialArgs = deps.getInitialArgs();
if (!initialArgs) return;
if (deps.isBackgroundMode()) {
if (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup()) {
deps.ensureTray();
}

View File

@@ -9,6 +9,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
const deps = createBuildHandleInitialArgsMainDepsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => calls.push('ensure-tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
@@ -19,6 +20,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
assert.equal(deps.getInitialArgs(), args);
assert.equal(deps.isBackgroundMode(), true);
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
assert.equal(deps.isTexthookerOnlyMode(), false);
assert.equal(deps.hasImmersionTracker(), true);
assert.equal(deps.getMpvClient(), mpvClient);

View File

@@ -3,6 +3,7 @@ import type { CliArgs } from '../../cli/args';
export function createBuildHandleInitialArgsMainDepsHandler(deps: {
getInitialArgs: () => CliArgs | null;
isBackgroundMode: () => boolean;
shouldEnsureTrayOnStartup: () => boolean;
ensureTray: () => void;
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
@@ -13,6 +14,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
return () => ({
getInitialArgs: () => deps.getInitialArgs(),
isBackgroundMode: () => deps.isBackgroundMode(),
shouldEnsureTrayOnStartup: () => deps.shouldEnsureTrayOnStartup(),
ensureTray: () => deps.ensureTray(),
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
hasImmersionTracker: () => deps.hasImmersionTracker(),

View File

@@ -7,6 +7,7 @@ test('initial args runtime handler composes main deps and runs initial command f
const handleInitialArgs = createInitialArgsRuntimeHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,

View File

@@ -48,6 +48,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
@@ -82,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.maybeProbeAnilistDuration('media-key');
deps.ensureAnilistMediaGuess('media-key');
deps.syncImmersionMediaState();
deps.signalAutoplayReadyIfWarm('/tmp/video');
deps.updateCurrentMediaTitle('title');
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title');
@@ -100,6 +102,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));

View File

@@ -13,8 +13,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
calls.push(`registered:${registered}`);
},
isOverlayRuntimeInitialized: () => true,
isMacOSPlatform: () => true,
isTrackedMpvWindowFocused: () => false,
isOverlayShortcutContextActive: () => false,
showMpvOsd: (text) => calls.push(`osd:${text}`),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJimaku: () => calls.push('jimaku'),
@@ -42,8 +41,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
})();
assert.equal(deps.isOverlayRuntimeInitialized(), true);
assert.equal(deps.isMacOSPlatform(), true);
assert.equal(deps.isTrackedMpvWindowFocused(), false);
assert.equal(deps.isOverlayShortcutContextActive?.(), false);
assert.equal(deps.getShortcutsRegistered(), false);
deps.setShortcutsRegistered(true);
assert.equal(shortcutsRegistered, true);

View File

@@ -8,8 +8,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
getShortcutsRegistered: () => deps.getShortcutsRegistered(),
setShortcutsRegistered: (registered: boolean) => deps.setShortcutsRegistered(registered),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isMacOSPlatform: () => deps.isMacOSPlatform(),
isTrackedMpvWindowFocused: () => deps.isTrackedMpvWindowFocused(),
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openJimaku: () => deps.openJimaku(),

View File

@@ -25,6 +25,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
isMacOSPlatform: () => true,
isWindowsPlatform: () => false,
showOverlayLoadingOsd: () => calls.push('overlay-loading-osd'),
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 20, height: 20 }),
})();
@@ -39,6 +40,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
deps.enforceOverlayLayerOrder();
deps.syncOverlayShortcuts();
assert.equal(deps.isMacOSPlatform(), true);
assert.equal(deps.isWindowsPlatform(), false);
deps.showOverlayLoadingOsd('Overlay loading...');
assert.deepEqual(deps.resolveFallbackBounds(), { x: 0, y: 0, width: 20, height: 20 });
assert.equal(trackerNotReadyWarningShown, true);

View File

@@ -18,6 +18,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
isMacOSPlatform: () => deps.isMacOSPlatform(),
isWindowsPlatform: () => deps.isWindowsPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
});

View File

@@ -43,6 +43,7 @@ test('build tray template handler wires actions and init guards', () => {
buildTrayMenuTemplateRuntime: (handlers) => {
handlers.openOverlay();
handlers.openFirstRunSetup();
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openRuntimeOptions();
handlers.openJellyfinSetup();
@@ -58,6 +59,7 @@ test('build tray template handler wires actions and init guards', () => {
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
@@ -71,6 +73,7 @@ test('build tray template handler wires actions and init guards', () => {
'init',
'visible:true',
'setup',
'setup',
'yomitan',
'runtime-options',
'jellyfin',

View File

@@ -31,6 +31,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -42,6 +44,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -60,6 +63,10 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
deps.openFirstRunSetupWindow();
},
showFirstRunSetup: deps.showFirstRunSetup(),
openWindowsMpvLauncherSetup: () => {
deps.openFirstRunSetupWindow();
},
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
openYomitanSettings: () => {
deps.openYomitanSettings();
},

View File

@@ -27,6 +27,7 @@ test('tray main deps builders return mapped handlers', () => {
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
@@ -38,6 +39,8 @@ test('tray main deps builders return mapped handlers', () => {
openOverlay: () => calls.push('open-overlay'),
openFirstRunSetup: () => calls.push('open-setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'),
openJellyfinSetup: () => calls.push('open-jellyfin'),

View File

@@ -30,6 +30,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -41,6 +43,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -54,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,

View File

@@ -29,6 +29,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
},
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openJellyfinSetupWindow: () => {},

View File

@@ -32,6 +32,8 @@ test('tray menu template contains expected entries and handlers', () => {
openOverlay: () => calls.push('overlay'),
openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openJellyfinSetup: () => calls.push('jellyfin'),
@@ -39,10 +41,10 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 8);
assert.equal(template.length, 9);
template[0]!.click?.();
template[6]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[7]!.click?.();
template[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[8]!.click?.();
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
});
@@ -51,6 +53,8 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
openOverlay: () => undefined,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openJellyfinSetup: () => undefined,
@@ -61,4 +65,5 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
.filter(Boolean);
assert.equal(labels.includes('Complete Setup'), false);
assert.equal(labels.includes('Manage Windows mpv launcher'), false);
});

View File

@@ -33,6 +33,8 @@ export type TrayMenuActionHandlers = {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -58,6 +60,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
},
]
: []),
...(handlers.showWindowsMpvLauncherSetup
? [
{
label: 'Manage Windows mpv launcher',
click: handlers.openWindowsMpvLauncherSetup,
},
]
: []),
{
label: 'Open Yomitan Settings',
click: handlers.openYomitanSettings,

View File

@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildWindowsMpvLaunchArgs,
launchWindowsMpv,
resolveWindowsMpvPath,
type WindowsMpvLaunchDeps,
} from './windows-mpv-launch';
function createDeps(overrides: Partial<WindowsMpvLaunchDeps> = {}): WindowsMpvLaunchDeps {
return {
getEnv: () => undefined,
runWhere: () => ({ status: 1, stdout: '' }),
fileExists: () => false,
spawnDetached: () => undefined,
showError: () => undefined,
...overrides,
};
}
test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
}),
);
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
});
test('resolveWindowsMpvPath falls back to where.exe output', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\nC:\\other\\mpv.exe\r\n' }),
fileExists: (candidate) => candidate === 'C:\\tools\\mpv.exe',
}),
);
assert.equal(resolved, 'C:\\tools\\mpv.exe');
});
test('buildWindowsMpvLaunchArgs keeps pseudo-gui profile and targets', () => {
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
'--player-operation-mode=pseudo-gui',
'--profile=subminer',
'C:\\a.mkv',
'C:\\b.mkv',
]);
});
test('launchWindowsMpv reports missing mpv path', () => {
const errors: string[] = [];
const result = launchWindowsMpv(
[],
createDeps({
showError: (_title, content) => errors.push(content),
}),
);
assert.equal(result.ok, false);
assert.equal(result.mpvPath, '');
assert.match(errors[0] ?? '', /Could not find mpv\.exe/i);
});
test('launchWindowsMpv spawns detached mpv with targets', () => {
const calls: string[] = [];
const result = launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
}),
);
assert.equal(result.ok, true);
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.deepEqual(calls, [
'C:\\mpv\\mpv.exe',
'--player-operation-mode=pseudo-gui|--profile=subminer|C:\\video.mkv',
]);
});
test('launchWindowsMpv reports spawn failures with path context', () => {
const errors: string[] = [];
const result = launchWindowsMpv(
[],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: () => {
throw new Error('spawn failed');
},
showError: (_title, content) => errors.push(content),
}),
);
assert.equal(result.ok, false);
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
assert.match(errors[0] ?? '', /C:\\mpv\\mpv\.exe/i);
});

View File

@@ -0,0 +1,100 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
spawnDetached: (command: string, args: string[]) => void;
showError: (title: string, content: string) => void;
}
function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : '';
}
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
if (envPath && deps.fileExists(envPath)) {
return envPath;
}
const whereResult = deps.runWhere();
if (whereResult.status === 0) {
const firstPath = whereResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && deps.fileExists(line));
if (firstPath) {
return firstPath;
}
}
return '';
}
export function buildWindowsMpvLaunchArgs(targets: string[]): string[] {
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets];
}
export function launchWindowsMpv(
targets: string[],
deps: WindowsMpvLaunchDeps,
): { ok: boolean; mpvPath: string } {
const mpvPath = resolveWindowsMpvPath(deps);
if (!mpvPath) {
deps.showError(
'SubMiner mpv launcher',
'Could not find mpv.exe. Install mpv and add it to PATH, or set SUBMINER_MPV_PATH.',
);
return { ok: false, mpvPath: '' };
}
try {
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets));
return { ok: true, mpvPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.showError('SubMiner mpv launcher', `Failed to launch mpv.\nPath: ${mpvPath}\n${message}`);
return { ok: false, mpvPath };
}
}
export function createWindowsMpvLaunchDeps(options: {
getEnv?: (name: string) => string | undefined;
fileExists?: (candidate: string) => boolean;
showError: (title: string, content: string) => void;
}): WindowsMpvLaunchDeps {
return {
getEnv: options.getEnv ?? ((name) => process.env[name]),
runWhere: () => {
const result = spawnSync('where.exe', ['mpv.exe'], {
encoding: 'utf8',
windowsHide: true,
});
return {
status: result.status,
stdout: result.stdout ?? '',
error: result.error ?? undefined,
};
},
fileExists:
options.fileExists ??
((candidate) => {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}),
spawnDetached: (command, args) => {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
},
showError: options.showError,
};
}

View File

@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyWindowsMpvShortcuts,
buildWindowsMpvShortcutDetails,
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
resolveWindowsStartMenuProgramsDir,
} from './windows-mpv-shortcuts';
test('resolveWindowsStartMenuProgramsDir derives Programs folder from APPDATA', () => {
assert.equal(
resolveWindowsStartMenuProgramsDir('C:\\Users\\tester\\AppData\\Roaming'),
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs',
);
});
test('resolveWindowsMpvShortcutPaths builds start menu and desktop lnk paths', () => {
const paths = resolveWindowsMpvShortcutPaths({
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
desktopDir: 'C:\\Users\\tester\\Desktop',
});
assert.equal(
paths.startMenuPath,
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\SubMiner mpv.lnk',
);
assert.equal(paths.desktopPath, 'C:\\Users\\tester\\Desktop\\SubMiner mpv.lnk');
});
test('buildWindowsMpvShortcutDetails targets SubMiner.exe with --launch-mpv', () => {
assert.deepEqual(buildWindowsMpvShortcutDetails('C:\\Apps\\SubMiner\\SubMiner.exe'), {
target: 'C:\\Apps\\SubMiner\\SubMiner.exe',
args: '--launch-mpv',
cwd: 'C:\\Apps\\SubMiner',
description: 'Launch mpv with the SubMiner profile',
icon: 'C:\\Apps\\SubMiner\\SubMiner.exe',
iconIndex: 0,
});
});
test('detectWindowsMpvShortcuts reflects existing shortcuts', () => {
const detected = detectWindowsMpvShortcuts(
{
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
(candidate) => candidate === 'C:\\Desktop\\SubMiner mpv.lnk',
);
assert.deepEqual(detected, {
startMenuInstalled: false,
desktopInstalled: true,
});
});
test('applyWindowsMpvShortcuts creates enabled shortcuts and removes disabled ones', () => {
const writes: string[] = [];
const removes: string[] = [];
const result = applyWindowsMpvShortcuts({
preferences: {
startMenuEnabled: true,
desktopEnabled: false,
},
paths: {
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
writeShortcutLink: (shortcutPath, operation, details) => {
writes.push(`${shortcutPath}|${operation}|${details.target}|${details.args}`);
return true;
},
rmSync: (candidate) => {
removes.push(candidate);
},
mkdirSync: () => undefined,
});
assert.equal(result.ok, true);
assert.equal(result.status, 'installed');
assert.deepEqual(writes, [
'C:\\Programs\\SubMiner mpv.lnk|replace|C:\\Apps\\SubMiner\\SubMiner.exe|--launch-mpv',
]);
assert.deepEqual(removes, ['C:\\Desktop\\SubMiner mpv.lnk']);
});
test('applyWindowsMpvShortcuts returns skipped when both shortcuts are disabled', () => {
const removes: string[] = [];
const result = applyWindowsMpvShortcuts({
preferences: {
startMenuEnabled: false,
desktopEnabled: false,
},
paths: {
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
writeShortcutLink: () => true,
rmSync: (candidate) => {
removes.push(candidate);
},
mkdirSync: () => undefined,
});
assert.equal(result.ok, true);
assert.equal(result.status, 'skipped');
assert.deepEqual(removes, ['C:\\Programs\\SubMiner mpv.lnk', 'C:\\Desktop\\SubMiner mpv.lnk']);
});
test('applyWindowsMpvShortcuts reports write failures', () => {
const result = applyWindowsMpvShortcuts({
preferences: {
startMenuEnabled: true,
desktopEnabled: true,
},
paths: {
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
writeShortcutLink: (shortcutPath) => shortcutPath.endsWith('Desktop\\SubMiner mpv.lnk'),
mkdirSync: () => undefined,
});
assert.equal(result.ok, false);
assert.equal(result.status, 'failed');
assert.match(result.message, /C:\\Programs\\SubMiner mpv\.lnk/);
});

View File

@@ -0,0 +1,117 @@
import fs from 'node:fs';
import path from 'node:path';
export const WINDOWS_MPV_SHORTCUT_NAME = 'SubMiner mpv.lnk';
export interface WindowsMpvShortcutPaths {
startMenuPath: string;
desktopPath: string;
}
export interface WindowsShortcutLinkDetails {
target: string;
args?: string;
cwd?: string;
description?: string;
icon?: string;
iconIndex?: number;
}
export interface WindowsMpvShortcutInstallResult {
ok: boolean;
status: 'installed' | 'skipped' | 'failed';
message: string;
}
export function resolveWindowsStartMenuProgramsDir(appDataDir: string): string {
return path.join(appDataDir, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
}
export function resolveWindowsMpvShortcutPaths(options: {
appDataDir: string;
desktopDir: string;
}): WindowsMpvShortcutPaths {
return {
startMenuPath: path.join(resolveWindowsStartMenuProgramsDir(options.appDataDir), WINDOWS_MPV_SHORTCUT_NAME),
desktopPath: path.join(options.desktopDir, WINDOWS_MPV_SHORTCUT_NAME),
};
}
export function detectWindowsMpvShortcuts(
paths: WindowsMpvShortcutPaths,
existsSync: (candidate: string) => boolean = fs.existsSync,
): { startMenuInstalled: boolean; desktopInstalled: boolean } {
return {
startMenuInstalled: existsSync(paths.startMenuPath),
desktopInstalled: existsSync(paths.desktopPath),
};
}
export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcutLinkDetails {
return {
target: exePath,
args: '--launch-mpv',
cwd: path.dirname(exePath),
description: 'Launch mpv with the SubMiner profile',
icon: exePath,
iconIndex: 0,
};
}
export function applyWindowsMpvShortcuts(options: {
preferences: { startMenuEnabled: boolean; desktopEnabled: boolean };
paths: WindowsMpvShortcutPaths;
exePath: string;
writeShortcutLink: (
shortcutPath: string,
operation: 'create' | 'update' | 'replace',
details: WindowsShortcutLinkDetails,
) => boolean;
rmSync?: (candidate: string, options: { force: true }) => void;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
}): WindowsMpvShortcutInstallResult {
const rmSync = options.rmSync ?? fs.rmSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const details = buildWindowsMpvShortcutDetails(options.exePath);
const failures: string[] = [];
const ensureShortcut = (shortcutPath: string): void => {
mkdirSync(path.dirname(shortcutPath), { recursive: true });
const ok = options.writeShortcutLink(shortcutPath, 'replace', details);
if (!ok) {
failures.push(shortcutPath);
}
};
const removeShortcut = (shortcutPath: string): void => {
rmSync(shortcutPath, { force: true });
};
if (options.preferences.startMenuEnabled) ensureShortcut(options.paths.startMenuPath);
else removeShortcut(options.paths.startMenuPath);
if (options.preferences.desktopEnabled) ensureShortcut(options.paths.desktopPath);
else removeShortcut(options.paths.desktopPath);
if (failures.length > 0) {
return {
ok: false,
status: 'failed',
message: `Failed to create Windows mpv shortcuts: ${failures.join(', ')}`,
};
}
if (!options.preferences.startMenuEnabled && !options.preferences.desktopEnabled) {
return {
ok: true,
status: 'skipped',
message: 'Disabled Windows mpv shortcuts.',
};
}
return {
ok: true,
status: 'installed',
message: 'Updated Windows mpv shortcuts.',
};
}

View File

@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { AnkiConnectConfig } from '../../types';
import {
getPreferredYomitanAnkiServerUrl,
shouldForceOverrideYomitanAnkiServer,
} from './yomitan-anki-server';
function createConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
return {
enabled: false,
url: 'http://127.0.0.1:8765',
proxy: {
enabled: true,
host: '127.0.0.1',
port: 8766,
upstreamUrl: 'http://127.0.0.1:8765',
},
...overrides,
} as AnkiConnectConfig;
}
test('prefers upstream AnkiConnect when SubMiner integration is disabled', () => {
const config = createConfig({
enabled: false,
proxy: {
enabled: true,
host: '127.0.0.1',
port: 8766,
upstreamUrl: 'http://127.0.0.1:8765',
},
});
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
});
test('prefers SubMiner proxy when SubMiner integration and proxy are enabled', () => {
const config = createConfig({
enabled: true,
proxy: {
enabled: true,
host: '127.0.0.1',
port: 9988,
upstreamUrl: 'http://127.0.0.1:8765',
},
});
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:9988');
assert.equal(shouldForceOverrideYomitanAnkiServer(config), true);
});
test('falls back to upstream AnkiConnect when proxy transport is disabled', () => {
const config = createConfig({
enabled: true,
proxy: {
enabled: false,
host: '127.0.0.1',
port: 8766,
upstreamUrl: 'http://127.0.0.1:8765',
},
});
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
});

View File

@@ -0,0 +1,15 @@
import type { AnkiConnectConfig } from '../../types';
export function getPreferredYomitanAnkiServerUrl(config: AnkiConnectConfig): string {
if (config.enabled === true && config.proxy?.enabled === true) {
const host = config.proxy.host || '127.0.0.1';
const port = config.proxy.port || 8766;
return `http://${host}:${port}`;
}
return config.url || 'http://127.0.0.1:8765';
}
export function shouldForceOverrideYomitanAnkiServer(config: AnkiConnectConfig): boolean {
return config.enabled === true && config.proxy?.enabled === true;
}

View File

@@ -5,6 +5,8 @@ import { resolve } from 'node:path';
const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml');
const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8');
const makefilePath = resolve(__dirname, '../Makefile');
const makefile = readFileSync(makefilePath, 'utf8');
test('publish release leaves prerelease unset so gh creates a normal release', () => {
assert.ok(!releaseWorkflow.includes('--prerelease'));
@@ -18,3 +20,13 @@ test('release workflow generates release notes from committed changelog output',
assert.match(releaseWorkflow, /bun run changelog:release-notes/);
assert.ok(!releaseWorkflow.includes('git log --pretty=format:"- %s"'));
});
test('release workflow includes the Windows installer in checksums and uploaded assets', () => {
assert.match(releaseWorkflow, /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/);
assert.match(releaseWorkflow, /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/);
});
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
assert.match(makefile, /windows\) printf '%s\\n' "\[INFO\] Windows builds run via: bun run build:win" ;;/);
assert.match(makefile, /bun \.\/scripts\/configure-plugin-binary-path\.mjs/);
});

View File

@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createInMemorySubtitlePositionController } from './position-state.js';
function withWindow<T>(windowValue: unknown, callback: () => T): T {
const previousWindow = (globalThis as { window?: unknown }).window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: windowValue,
});
try {
return callback();
} finally {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
}
function createContext(subtitleHeight: number) {
return {
dom: {
subtitleContainer: {
style: {
position: '',
left: '',
top: '',
right: '',
transform: '',
marginBottom: '',
},
offsetHeight: subtitleHeight,
},
},
state: {
currentYPercent: null,
persistedSubtitlePosition: { yPercent: 10 },
},
};
}
test('subtitle position clamp keeps tall subtitles inside the overlay viewport', () => {
withWindow(
{
innerHeight: 1000,
electronAPI: {
saveSubtitlePosition: () => {},
},
},
() => {
const ctx = createContext(300);
const controller = createInMemorySubtitlePositionController(ctx as never);
controller.applyYPercent(80);
assert.equal(ctx.state.currentYPercent, 68.8);
assert.equal(ctx.dom.subtitleContainer.style.marginBottom, '688px');
},
);
});
test('subtitle position clamp falls back to the minimum safe inset when subtitle is taller than the viewport', () => {
withWindow(
{
innerHeight: 200,
electronAPI: {
saveSubtitlePosition: () => {},
},
},
() => {
const ctx = createContext(260);
const controller = createInMemorySubtitlePositionController(ctx as never);
controller.applyYPercent(80);
assert.equal(ctx.state.currentYPercent, 6);
assert.equal(ctx.dom.subtitleContainer.style.marginBottom, '12px');
},
);
});

View File

@@ -3,6 +3,7 @@ import type { RendererContext } from '../context';
const PREFERRED_Y_PERCENT_MIN = 2;
const PREFERRED_Y_PERCENT_MAX = 80;
const SUBTITLE_EDGE_PADDING_PX = 12;
export type SubtitlePositionController = {
applyStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
@@ -11,8 +12,47 @@ export type SubtitlePositionController = {
persistSubtitlePositionPatch: (patch: Partial<SubtitlePosition>) => void;
};
function clampYPercent(yPercent: number): number {
return Math.max(PREFERRED_Y_PERCENT_MIN, Math.min(PREFERRED_Y_PERCENT_MAX, yPercent));
function getViewportHeight(): number {
return Math.max(window.innerHeight || 0, 1);
}
function getSubtitleContainerHeight(ctx: RendererContext): number {
const container = ctx.dom.subtitleContainer as HTMLElement & {
offsetHeight?: number;
getBoundingClientRect?: () => { height?: number };
};
if (typeof container.offsetHeight === 'number' && Number.isFinite(container.offsetHeight)) {
return Math.max(container.offsetHeight, 0);
}
if (typeof container.getBoundingClientRect === 'function') {
const height = container.getBoundingClientRect().height;
if (typeof height === 'number' && Number.isFinite(height)) {
return Math.max(height, 0);
}
}
return 0;
}
function resolveYPercentClampRange(ctx: RendererContext): { min: number; max: number } {
const viewportHeight = getViewportHeight();
const subtitleHeight = getSubtitleContainerHeight(ctx);
const minPercent = Math.max(PREFERRED_Y_PERCENT_MIN, (SUBTITLE_EDGE_PADDING_PX / viewportHeight) * 100);
const maxMarginBottomPx = Math.max(
SUBTITLE_EDGE_PADDING_PX,
viewportHeight - subtitleHeight - SUBTITLE_EDGE_PADDING_PX,
);
const maxPercent = Math.min(PREFERRED_Y_PERCENT_MAX, (maxMarginBottomPx / viewportHeight) * 100);
if (maxPercent < minPercent) {
return { min: minPercent, max: minPercent };
}
return { min: minPercent, max: maxPercent };
}
function clampYPercent(ctx: RendererContext, yPercent: number): number {
const { min, max } = resolveYPercentClampRange(ctx);
return Math.max(min, Math.min(max, yPercent));
}
function getPersistedYPercent(ctx: RendererContext, position: SubtitlePosition | null): number {
@@ -53,14 +93,14 @@ export function createInMemorySubtitlePositionController(
}
const marginBottom = parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60;
ctx.state.currentYPercent = clampYPercent((marginBottom / window.innerHeight) * 100);
ctx.state.currentYPercent = clampYPercent(ctx, (marginBottom / getViewportHeight()) * 100);
return ctx.state.currentYPercent;
}
function applyYPercent(yPercent: number): void {
const clampedPercent = clampYPercent(yPercent);
const clampedPercent = clampYPercent(ctx, yPercent);
ctx.state.currentYPercent = clampedPercent;
const marginBottom = (clampedPercent / 100) * window.innerHeight;
const marginBottom = (clampedPercent / 100) * getViewportHeight();
ctx.dom.subtitleContainer.style.position = '';
ctx.dom.subtitleContainer.style.left = '';
@@ -85,7 +125,7 @@ export function createInMemorySubtitlePositionController(
}
const defaultMarginBottom = 60;
const defaultYPercent = (defaultMarginBottom / window.innerHeight) * 100;
const defaultYPercent = (defaultMarginBottom / getViewportHeight()) * 100;
applyYPercent(defaultYPercent);
console.log('Applied default subtitle position from', source);
}

View File

@@ -24,13 +24,17 @@ function withTempDir(fn: (dir: string) => void): void {
}
test('getDefaultConfigDir prefers existing SubMiner config directory', () => {
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
const homeDir = path.join(path.sep, 'tmp', 'home');
const dir = getDefaultConfigDir({
xdgConfigHome: '/tmp/xdg',
homeDir: '/tmp/home',
existsSync: (candidate) => candidate === '/tmp/xdg/SubMiner/config.jsonc',
platform: 'linux',
xdgConfigHome,
homeDir,
existsSync: (candidate) =>
candidate === path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
});
assert.equal(dir, '/tmp/xdg/SubMiner');
assert.equal(dir, path.posix.join(xdgConfigHome, 'SubMiner'));
});
test('ensureDefaultConfigBootstrap creates config dir and default jsonc only when missing', () => {
@@ -61,6 +65,26 @@ test('ensureDefaultConfigBootstrap creates config dir and default jsonc only whe
});
});
test('ensureDefaultConfigBootstrap does not seed default config into an existing config directory', () => {
withTempDir((root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'existing-user-file.txt'), 'keep\n');
ensureDefaultConfigBootstrap({
configDir,
configFilePaths: getDefaultConfigFilePaths(configDir),
generateTemplate: () => 'should-not-write',
});
assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false);
assert.equal(
fs.readFileSync(path.join(configDir, 'existing-user-file.txt'), 'utf8'),
'keep\n',
);
});
});
test('readSetupState ignores invalid files and round-trips valid state', () => {
withTempDir((root) => {
const statePath = getSetupStatePath(root);
@@ -77,22 +101,126 @@ test('readSetupState ignores invalid files and round-trips valid state', () => {
});
});
test('resolveDefaultMpvInstallPaths resolves linux and macOS defaults', () => {
assert.deepEqual(resolveDefaultMpvInstallPaths('linux', '/tmp/home', '/tmp/xdg'), {
supported: true,
mpvConfigDir: '/tmp/xdg/mpv',
scriptsDir: '/tmp/xdg/mpv/scripts',
scriptOptsDir: '/tmp/xdg/mpv/script-opts',
pluginDir: '/tmp/xdg/mpv/scripts/subminer',
pluginConfigPath: '/tmp/xdg/mpv/script-opts/subminer.conf',
});
test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => {
withTempDir((root) => {
const statePath = getSetupStatePath(root);
fs.writeFileSync(
statePath,
JSON.stringify({
version: 1,
status: 'incomplete',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
}),
);
assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', '/Users/tester', undefined), {
supported: true,
mpvConfigDir: '/Users/tester/Library/Application Support/mpv',
scriptsDir: '/Users/tester/Library/Application Support/mpv/scripts',
scriptOptsDir: '/Users/tester/Library/Application Support/mpv/script-opts',
pluginDir: '/Users/tester/Library/Application Support/mpv/scripts/subminer',
pluginConfigPath: '/Users/tester/Library/Application Support/mpv/script-opts/subminer.conf',
assert.deepEqual(readSetupState(statePath), {
version: 2,
status: 'incomplete',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: {
startMenuEnabled: true,
desktopEnabled: true,
},
windowsMpvShortcutLastStatus: 'unknown',
});
});
});
test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => {
const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
assert.deepEqual(resolveDefaultMpvInstallPaths('linux', linuxHomeDir, xdgConfigHome), {
supported: true,
mpvConfigDir: path.posix.join(xdgConfigHome, 'mpv'),
scriptsDir: path.posix.join(xdgConfigHome, 'mpv', 'scripts'),
scriptOptsDir: path.posix.join(xdgConfigHome, 'mpv', 'script-opts'),
pluginEntrypointPath: path.posix.join(xdgConfigHome, 'mpv', 'scripts', 'subminer', 'main.lua'),
pluginDir: path.posix.join(xdgConfigHome, 'mpv', 'scripts', 'subminer'),
pluginConfigPath: path.posix.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
});
const macHomeDir = path.join(path.sep, 'Users', 'tester');
assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', macHomeDir, undefined), {
supported: true,
mpvConfigDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv'),
scriptsDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv', 'scripts'),
scriptOptsDir: path.posix.join(
macHomeDir,
'Library',
'Application Support',
'mpv',
'script-opts',
),
pluginEntrypointPath: path.posix.join(
macHomeDir,
'Library',
'Application Support',
'mpv',
'scripts',
'subminer',
'main.lua',
),
pluginDir: path.posix.join(
macHomeDir,
'Library',
'Application Support',
'mpv',
'scripts',
'subminer',
),
pluginConfigPath: path.posix.join(
macHomeDir,
'Library',
'Application Support',
'mpv',
'script-opts',
'subminer.conf',
),
});
assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), {
supported: true,
mpvConfigDir: path.win32.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv'),
scriptsDir: path.win32.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'scripts'),
scriptOptsDir: path.win32.join(
'C:\\Users\\tester',
'AppData',
'Roaming',
'mpv',
'script-opts',
),
pluginEntrypointPath: path.win32.join(
'C:\\Users\\tester',
'AppData',
'Roaming',
'mpv',
'scripts',
'subminer',
'main.lua',
),
pluginDir: path.win32.join(
'C:\\Users\\tester',
'AppData',
'Roaming',
'mpv',
'scripts',
'subminer',
),
pluginConfigPath: path.win32.join(
'C:\\Users\\tester',
'AppData',
'Roaming',
'mpv',
'script-opts',
'subminer.conf',
),
});
});

View File

@@ -6,15 +6,23 @@ import { resolveConfigDir } from '../config/path-resolution';
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
export interface SetupWindowsMpvShortcutPreferences {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}
export interface SetupState {
version: 1;
version: 2;
status: SetupStateStatus;
completedAt: string | null;
completionSource: SetupCompletionSource;
lastSeenYomitanDictionaryCount: number;
pluginInstallStatus: SetupPluginInstallStatus;
pluginInstallPathSummary: string | null;
windowsMpvShortcutPreferences: SetupWindowsMpvShortcutPreferences;
windowsMpvShortcutLastStatus: SetupWindowsMpvShortcutInstallStatus;
}
export interface ConfigFilePaths {
@@ -27,10 +35,15 @@ export interface MpvInstallPaths {
mpvConfigDir: string;
scriptsDir: string;
scriptOptsDir: string;
pluginEntrypointPath: string;
pluginDir: string;
pluginConfigPath: string;
}
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -39,25 +52,33 @@ function asObject(value: unknown): Record<string, unknown> | null {
export function createDefaultSetupState(): SetupState {
return {
version: 1,
version: 2,
status: 'incomplete',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: {
startMenuEnabled: true,
desktopEnabled: true,
},
windowsMpvShortcutLastStatus: 'unknown',
};
}
export function normalizeSetupState(value: unknown): SetupState | null {
const record = asObject(value);
if (!record) return null;
const version = record.version;
const status = record.status;
const pluginInstallStatus = record.pluginInstallStatus;
const completionSource = record.completionSource;
const windowsPrefs = asObject(record.windowsMpvShortcutPreferences);
const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus;
if (
record.version !== 1 ||
(version !== 1 && version !== 2) ||
(status !== 'incomplete' &&
status !== 'in_progress' &&
status !== 'completed' &&
@@ -66,6 +87,11 @@ export function normalizeSetupState(value: unknown): SetupState | null {
pluginInstallStatus !== 'installed' &&
pluginInstallStatus !== 'skipped' &&
pluginInstallStatus !== 'failed') ||
(version === 2 &&
windowsMpvShortcutLastStatus !== 'unknown' &&
windowsMpvShortcutLastStatus !== 'installed' &&
windowsMpvShortcutLastStatus !== 'skipped' &&
windowsMpvShortcutLastStatus !== 'failed') ||
(completionSource !== null &&
completionSource !== 'user' &&
completionSource !== 'legacy_auto_detected')
@@ -74,7 +100,7 @@ export function normalizeSetupState(value: unknown): SetupState | null {
}
return {
version: 1,
version: 2,
status,
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
completionSource,
@@ -87,6 +113,24 @@ export function normalizeSetupState(value: unknown): SetupState | null {
pluginInstallStatus,
pluginInstallPathSummary:
typeof record.pluginInstallPathSummary === 'string' ? record.pluginInstallPathSummary : null,
windowsMpvShortcutPreferences: {
startMenuEnabled:
version === 2 && typeof windowsPrefs?.startMenuEnabled === 'boolean'
? windowsPrefs.startMenuEnabled
: true,
desktopEnabled:
version === 2 && typeof windowsPrefs?.desktopEnabled === 'boolean'
? windowsPrefs.desktopEnabled
: true,
},
windowsMpvShortcutLastStatus:
version === 2 &&
(windowsMpvShortcutLastStatus === 'unknown' ||
windowsMpvShortcutLastStatus === 'installed' ||
windowsMpvShortcutLastStatus === 'skipped' ||
windowsMpvShortcutLastStatus === 'failed')
? windowsMpvShortcutLastStatus
: 'unknown',
};
}
@@ -95,11 +139,15 @@ export function isSetupCompleted(state: SetupState | null | undefined): boolean
}
export function getDefaultConfigDir(options?: {
platform?: NodeJS.Platform;
appDataDir?: string;
xdgConfigHome?: string;
homeDir?: string;
existsSync?: (candidate: string) => boolean;
}): string {
return resolveConfigDir({
platform: options?.platform ?? process.platform,
appDataDir: options?.appDataDir ?? process.env.APPDATA,
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
homeDir: options?.homeDir ?? os.homedir(),
existsSync: options?.existsSync ?? fs.existsSync,
@@ -160,15 +208,17 @@ export function ensureDefaultConfigBootstrap(options: {
const existsSync = options.existsSync ?? fs.existsSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
const configDirExists = existsSync(options.configDir);
mkdirSync(options.configDir, { recursive: true });
if (
existsSync(options.configFilePaths.jsoncPath) ||
existsSync(options.configFilePaths.jsonPath)
existsSync(options.configFilePaths.jsonPath) ||
configDirExists
) {
return;
}
mkdirSync(options.configDir, { recursive: true });
writeFileSync(options.configFilePaths.jsoncPath, options.generateTemplate(), 'utf8');
}
@@ -177,19 +227,21 @@ export function resolveDefaultMpvInstallPaths(
homeDir: string,
xdgConfigHome?: string,
): MpvInstallPaths {
const platformPath = getPlatformPath(platform);
const mpvConfigDir =
platform === 'darwin'
? path.join(homeDir, 'Library', 'Application Support', 'mpv')
? platformPath.join(homeDir, 'Library', 'Application Support', 'mpv')
: platform === 'linux'
? path.join(xdgConfigHome?.trim() || path.join(homeDir, '.config'), 'mpv')
: path.join(homeDir, 'AppData', 'Roaming', 'mpv');
? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv')
: platformPath.join(homeDir, 'AppData', 'Roaming', 'mpv');
return {
supported: platform === 'linux' || platform === 'darwin',
supported: platform === 'linux' || platform === 'darwin' || platform === 'win32',
mpvConfigDir,
scriptsDir: path.join(mpvConfigDir, 'scripts'),
scriptOptsDir: path.join(mpvConfigDir, 'script-opts'),
pluginDir: path.join(mpvConfigDir, 'scripts', 'subminer'),
pluginConfigPath: path.join(mpvConfigDir, 'script-opts', 'subminer.conf'),
scriptsDir: platformPath.join(mpvConfigDir, 'scripts'),
scriptOptsDir: platformPath.join(mpvConfigDir, 'script-opts'),
pluginEntrypointPath: platformPath.join(mpvConfigDir, 'scripts', 'subminer', 'main.lua'),
pluginDir: platformPath.join(mpvConfigDir, 'scripts', 'subminer'),
pluginConfigPath: platformPath.join(mpvConfigDir, 'script-opts', 'subminer.conf'),
};
}

View File

@@ -1,5 +1,6 @@
import * as fs from 'fs';
import * as childProcess from 'child_process';
import * as path from 'path';
import { DEFAULT_CONFIG } from '../config';
import { SubsyncConfig, SubsyncMode } from '../types';
@@ -45,6 +46,42 @@ export interface CommandResult {
error?: string;
}
function resolveCommandInvocation(
executable: string,
args: string[],
): { command: string; args: string[] } {
if (process.platform !== 'win32') {
return { command: executable, args };
}
const normalizeBashArg = (value: string): string => {
const normalized = value.replace(/\\/g, '/');
const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
if (!driveMatch) {
return normalized;
}
const [, driveLetter, remainder] = driveMatch;
return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`;
};
const extension = path.extname(executable).toLowerCase();
if (extension === '.ps1') {
return {
command: 'powershell.exe',
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', executable, ...args],
};
}
if (extension === '.sh') {
return {
command: 'bash',
args: [normalizeBashArg(executable), ...args.map(normalizeBashArg)],
};
}
return { command: executable, args };
}
export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig {
const resolvePath = (value: string | undefined, fallback: string): string => {
const trimmed = value?.trim();
@@ -108,7 +145,8 @@ export function runCommand(
timeoutMs = 120000,
): Promise<CommandResult> {
return new Promise((resolve) => {
const child = childProcess.spawn(executable, args, {
const invocation = resolveCommandInvocation(executable, args);
const child = childProcess.spawn(invocation.command, invocation.args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';

View File

@@ -21,17 +21,31 @@ import { WindowGeometry } from '../types';
export type GeometryChangeCallback = (geometry: WindowGeometry) => void;
export type WindowFoundCallback = (geometry: WindowGeometry) => void;
export type WindowLostCallback = () => void;
export type WindowFocusChangeCallback = (focused: boolean) => void;
export abstract class BaseWindowTracker {
protected currentGeometry: WindowGeometry | null = null;
protected windowFound: boolean = false;
protected focusKnown: boolean = false;
protected windowFocused: boolean = false;
protected targetWindowFocused: boolean = false;
public onGeometryChange: GeometryChangeCallback | null = null;
public onWindowFound: WindowFoundCallback | null = null;
public onWindowLost: WindowLostCallback | null = null;
public onWindowFocusChange: WindowFocusChangeCallback | null = null;
private onWindowFocusChangeCallback: ((focused: boolean) => void) | null = null;
public get onWindowFocusChange(): ((focused: boolean) => void) | null {
return this.onWindowFocusChangeCallback;
}
public set onWindowFocusChange(callback: ((focused: boolean) => void) | null) {
this.onWindowFocusChangeCallback = callback;
}
public get onTargetWindowFocusChange(): ((focused: boolean) => void) | null {
return this.onWindowFocusChange;
}
public set onTargetWindowFocusChange(callback: ((focused: boolean) => void) | null) {
this.onWindowFocusChange = callback;
}
abstract start(): void;
abstract stop(): void;
@@ -44,23 +58,28 @@ export abstract class BaseWindowTracker {
return this.windowFound;
}
isFocused(): boolean {
return this.focusKnown ? this.windowFocused : this.windowFound;
isTargetWindowFocused(): boolean {
return this.targetWindowFocused;
}
protected updateTargetWindowFocused(focused: boolean): void {
if (this.targetWindowFocused === focused) {
return;
}
this.targetWindowFocused = focused;
this.onWindowFocusChangeCallback?.(focused);
}
protected updateFocus(focused: boolean): void {
const changed = !this.focusKnown || this.windowFocused !== focused;
this.focusKnown = true;
this.windowFocused = focused;
if (changed) {
this.onWindowFocusChange?.(focused);
}
this.updateTargetWindowFocused(focused);
}
protected updateGeometry(newGeometry: WindowGeometry | null): void {
if (newGeometry) {
if (!this.windowFound) {
this.windowFound = true;
this.updateTargetWindowFocused(true);
if (this.onWindowFound) this.onWindowFound(newGeometry);
}
@@ -75,14 +94,9 @@ export abstract class BaseWindowTracker {
if (this.onGeometryChange) this.onGeometryChange(newGeometry);
}
} else {
const focusChanged = this.focusKnown && this.windowFocused;
this.focusKnown = false;
this.windowFocused = false;
if (focusChanged) {
this.onWindowFocusChange?.(false);
}
if (this.windowFound) {
this.windowFound = false;
this.updateTargetWindowFocused(false);
this.currentGeometry = null;
if (this.onWindowLost) this.onWindowLost();
}

View File

@@ -21,14 +21,16 @@ import { HyprlandWindowTracker } from './hyprland-tracker';
import { SwayWindowTracker } from './sway-tracker';
import { X11WindowTracker } from './x11-tracker';
import { MacOSWindowTracker } from './macos-tracker';
import { WindowsWindowTracker } from './windows-tracker';
import { createLogger } from '../logger';
const log = createLogger('tracker');
export type Compositor = 'hyprland' | 'sway' | 'x11' | 'macos' | null;
export type Compositor = 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows' | null;
export type Backend = 'auto' | Exclude<Compositor, null>;
export function detectCompositor(): Compositor {
if (process.platform === 'win32') return 'windows';
if (process.platform === 'darwin') return 'macos';
if (process.env.HYPRLAND_INSTANCE_SIGNATURE) return 'hyprland';
if (process.env.SWAYSOCK) return 'sway';
@@ -42,6 +44,7 @@ function normalizeCompositor(value: string): Compositor | null {
if (normalized === 'sway') return 'sway';
if (normalized === 'x11') return 'x11';
if (normalized === 'macos') return 'macos';
if (normalized === 'windows') return 'windows';
return null;
}
@@ -70,6 +73,8 @@ export function createWindowTracker(
return new X11WindowTracker(targetMpvSocketPath?.trim() || undefined);
case 'macos':
return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined);
case 'windows':
return new WindowsWindowTracker(targetMpvSocketPath?.trim() || undefined);
default:
log.warn('No supported compositor detected. Window tracking disabled.');
return null;
@@ -82,4 +87,5 @@ export {
SwayWindowTracker,
X11WindowTracker,
MacOSWindowTracker,
WindowsWindowTracker,
};

View File

@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseWindowTrackerHelperFocusState,
parseWindowTrackerHelperOutput,
resolveWindowsTrackerHelper,
} from './windows-helper';
test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
assert.deepEqual(parseWindowTrackerHelperOutput('120,240,1280,720'), {
x: 120,
y: 240,
width: 1280,
height: 720,
});
});
test('parseWindowTrackerHelperOutput returns null for misses and invalid payloads', () => {
assert.equal(parseWindowTrackerHelperOutput('not-found'), null);
assert.equal(parseWindowTrackerHelperOutput('1,2,3'), null);
assert.equal(parseWindowTrackerHelperOutput('1,2,0,4'), null);
});
test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => {
assert.equal(parseWindowTrackerHelperFocusState('focus=focused'), true);
assert.equal(parseWindowTrackerHelperFocusState('focus=not-focused'), false);
assert.equal(parseWindowTrackerHelperFocusState('warning\nfocus=focused\nnote'), true);
assert.equal(parseWindowTrackerHelperFocusState(''), null);
});
test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: (candidate) =>
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
helperModeEnv: 'auto',
});
assert.deepEqual(helper, {
kind: 'native',
command: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
args: [],
helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
});
});
test('resolveWindowsTrackerHelper auto mode falls back to powershell helper', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: (candidate) =>
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
helperModeEnv: 'auto',
});
assert.deepEqual(helper, {
kind: 'powershell',
command: 'powershell.exe',
args: [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
],
helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
});
});
test('resolveWindowsTrackerHelper explicit powershell mode ignores native helper', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: (candidate) =>
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe' ||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
helperModeEnv: 'powershell',
});
assert.equal(helper?.kind, 'powershell');
assert.equal(helper?.helperPath, 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1');
});
test('resolveWindowsTrackerHelper explicit native mode fails cleanly when helper is missing', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: () => false,
helperModeEnv: 'native',
});
assert.equal(helper, null);
});
test('resolveWindowsTrackerHelper explicit helper path overrides default search', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: (candidate) => candidate === 'D:\\custom\\tracker.ps1',
helperModeEnv: 'auto',
helperPathEnv: 'D:\\custom\\tracker.ps1',
});
assert.deepEqual(helper, {
kind: 'powershell',
command: 'powershell.exe',
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\custom\\tracker.ps1'],
helperPath: 'D:\\custom\\tracker.ps1',
});
});

View File

@@ -0,0 +1,284 @@
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { WindowGeometry } from '../types';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows-helper');
export type WindowsTrackerHelperKind = 'powershell' | 'native';
export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native';
export type WindowsTrackerHelperLaunchSpec = {
kind: WindowsTrackerHelperKind;
command: string;
args: string[];
helperPath: string;
};
type ResolveWindowsTrackerHelperOptions = {
dirname?: string;
resourcesPath?: string;
helperModeEnv?: string | undefined;
helperPathEnv?: string | undefined;
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
copyFileSync?: (source: string, destination: string) => void;
};
const windowsPath = path.win32;
function normalizeHelperMode(value: string | undefined): WindowsTrackerHelperMode {
const normalized = value?.trim().toLowerCase();
if (normalized === 'powershell' || normalized === 'native') {
return normalized;
}
return 'auto';
}
function inferHelperKindFromPath(helperPath: string): WindowsTrackerHelperKind | null {
const normalized = helperPath.trim().toLowerCase();
if (normalized.endsWith('.exe')) return 'native';
if (normalized.endsWith('.ps1')) return 'powershell';
return null;
}
function materializeAsarHelper(
sourcePath: string,
kind: WindowsTrackerHelperKind,
deps: Required<
Pick<ResolveWindowsTrackerHelperOptions, 'mkdirSync' | 'copyFileSync'>
>,
): string | null {
if (!sourcePath.includes('.asar')) {
return sourcePath;
}
const fileName =
kind === 'native' ? 'get-mpv-window-windows.exe' : 'get-mpv-window-windows.ps1';
const targetDir = windowsPath.join(os.tmpdir(), 'subminer', 'helpers');
const targetPath = windowsPath.join(targetDir, fileName);
try {
deps.mkdirSync(targetDir, { recursive: true });
deps.copyFileSync(sourcePath, targetPath);
log.info(`Materialized Windows helper from asar: ${targetPath}`);
return targetPath;
} catch (error) {
log.warn(`Failed to materialize Windows helper from asar: ${sourcePath}`, error);
return null;
}
}
function createLaunchSpec(
helperPath: string,
kind: WindowsTrackerHelperKind,
): WindowsTrackerHelperLaunchSpec {
if (kind === 'native') {
return {
kind,
command: helperPath,
args: [],
helperPath,
};
}
return {
kind,
command: 'powershell.exe',
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', helperPath],
helperPath,
};
}
function normalizeHelperPathOverride(
helperPathEnv: string | undefined,
mode: WindowsTrackerHelperMode,
): { path: string; kind: WindowsTrackerHelperKind } | null {
const helperPath = helperPathEnv?.trim();
if (!helperPath) {
return null;
}
const inferredKind = inferHelperKindFromPath(helperPath);
const kind = mode === 'auto' ? inferredKind : mode;
if (!kind) {
log.warn(
`Ignoring SUBMINER_WINDOWS_TRACKER_HELPER_PATH with unsupported extension: ${helperPath}`,
);
return null;
}
return { path: helperPath, kind };
}
function getHelperCandidates(dirname: string, resourcesPath: string | undefined): Array<{
path: string;
kind: WindowsTrackerHelperKind;
}> {
const scriptFileBase = 'get-mpv-window-windows';
const candidates: Array<{ path: string; kind: WindowsTrackerHelperKind }> = [];
if (resourcesPath) {
candidates.push({
path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.exe`),
kind: 'native',
});
candidates.push({
path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.ps1`),
kind: 'powershell',
});
}
candidates.push({
path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.exe`),
kind: 'native',
});
candidates.push({
path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.ps1`),
kind: 'powershell',
});
candidates.push({
path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.exe`),
kind: 'native',
});
candidates.push({
path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.ps1`),
kind: 'powershell',
});
return candidates;
}
export function parseWindowTrackerHelperOutput(output: string): WindowGeometry | null {
const result = output.trim();
if (!result || result === 'not-found') {
return null;
}
const parts = result.split(',');
if (parts.length !== 4) {
return null;
}
const [xText, yText, widthText, heightText] = parts;
const x = Number.parseInt(xText!, 10);
const y = Number.parseInt(yText!, 10);
const width = Number.parseInt(widthText!, 10);
const height = Number.parseInt(heightText!, 10);
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return null;
}
return { x, y, width, height };
}
export function parseWindowTrackerHelperFocusState(output: string): boolean | null {
const focusLine = output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith('focus='));
if (!focusLine) {
return null;
}
const value = focusLine.slice('focus='.length).trim().toLowerCase();
if (value === 'focused') {
return true;
}
if (value === 'not-focused') {
return false;
}
return null;
}
export function resolveWindowsTrackerHelper(
options: ResolveWindowsTrackerHelperOptions = {},
): WindowsTrackerHelperLaunchSpec | null {
const existsSync = options.existsSync ?? fs.existsSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const copyFileSync = options.copyFileSync ?? fs.copyFileSync;
const dirname = options.dirname ?? __dirname;
const resourcesPath = options.resourcesPath ?? process.resourcesPath;
const mode = normalizeHelperMode(
options.helperModeEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER,
);
const override = normalizeHelperPathOverride(
options.helperPathEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER_PATH,
mode,
);
if (override) {
if (!existsSync(override.path)) {
log.warn(`Configured Windows tracker helper path does not exist: ${override.path}`);
return null;
}
const helperPath = materializeAsarHelper(override.path, override.kind, {
mkdirSync,
copyFileSync,
});
return helperPath ? createLaunchSpec(helperPath, override.kind) : null;
}
const candidates = getHelperCandidates(dirname, resourcesPath);
const orderedCandidates =
mode === 'powershell'
? candidates.filter((candidate) => candidate.kind === 'powershell')
: mode === 'native'
? candidates.filter((candidate) => candidate.kind === 'native')
: candidates;
for (const candidate of orderedCandidates) {
if (!existsSync(candidate.path)) {
continue;
}
const helperPath = materializeAsarHelper(candidate.path, candidate.kind, {
mkdirSync,
copyFileSync,
});
if (!helperPath) {
continue;
}
log.info(`Using Windows helper (${candidate.kind}): ${helperPath}`);
return createLaunchSpec(helperPath, candidate.kind);
}
if (mode === 'native') {
log.warn('Windows native tracker helper requested but no helper was found.');
} else if (mode === 'powershell') {
log.warn('Windows PowerShell tracker helper requested but no helper was found.');
} else {
log.warn('Windows tracker helper not found.');
}
return null;
}

View File

@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { WindowsWindowTracker } from './windows-tracker';
test('WindowsWindowTracker skips overlapping polls while helper is in flight', async () => {
let helperCalls = 0;
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const tracker = new WindowsWindowTracker(undefined, {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => {
helperCalls += 1;
await gate;
return {
stdout: '0,0,640,360',
stderr: 'focus=focused',
};
},
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(helperCalls, 1);
assert.ok(release);
release();
await new Promise((resolve) => setTimeout(resolve, 0));
});
test('WindowsWindowTracker updates geometry from helper output', async () => {
const tracker = new WindowsWindowTracker(undefined, {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => ({
stdout: '10,20,1280,720',
stderr: 'focus=focused',
}),
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(tracker.getGeometry(), {
x: 10,
y: 20,
width: 1280,
height: 720,
});
assert.equal(tracker.isTargetWindowFocused(), true);
});
test('WindowsWindowTracker clears geometry for helper misses', async () => {
const tracker = new WindowsWindowTracker(undefined, {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => ({
stdout: 'not-found',
stderr: 'focus=not-focused',
}),
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.getGeometry(), null);
assert.equal(tracker.isTargetWindowFocused(), false);
});
test('WindowsWindowTracker retries without socket filter when filtered helper lookup misses', async () => {
const helperCalls: Array<string | null> = [];
const tracker = new WindowsWindowTracker('\\\\.\\pipe\\subminer-socket', {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async (_spec, _mode, targetMpvSocketPath) => {
helperCalls.push(targetMpvSocketPath);
if (targetMpvSocketPath) {
return {
stdout: 'not-found',
stderr: 'focus=not-focused',
};
}
return {
stdout: '25,30,1440,810',
stderr: 'focus=focused',
};
},
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(helperCalls, ['\\\\.\\pipe\\subminer-socket', null]);
assert.deepEqual(tracker.getGeometry(), {
x: 25,
y: 30,
width: 1440,
height: 810,
});
assert.equal(tracker.isTargetWindowFocused(), true);
});

View File

@@ -0,0 +1,176 @@
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { execFile, type ExecFileException } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
import {
parseWindowTrackerHelperFocusState,
parseWindowTrackerHelperOutput,
resolveWindowsTrackerHelper,
type WindowsTrackerHelperLaunchSpec,
} from './windows-helper';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows');
type WindowsTrackerRunnerResult = {
stdout: string;
stderr: string;
};
type WindowsTrackerDeps = {
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: 'geometry',
targetMpvSocketPath: string | null,
) => Promise<WindowsTrackerRunnerResult>;
};
function runHelperWithExecFile(
spec: WindowsTrackerHelperLaunchSpec,
mode: 'geometry',
targetMpvSocketPath: string | null,
): Promise<WindowsTrackerRunnerResult> {
return new Promise((resolve, reject) => {
const modeArgs =
spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
const args = targetMpvSocketPath
? [...spec.args, ...modeArgs, targetMpvSocketPath]
: [...spec.args, ...modeArgs];
execFile(
spec.command,
args,
{
encoding: 'utf-8',
timeout: 1000,
maxBuffer: 1024 * 1024,
windowsHide: true,
},
(error: ExecFileException | null, stdout: string, stderr: string) => {
if (error) {
reject(Object.assign(error, { stderr }));
return;
}
resolve({ stdout, stderr });
},
);
});
}
export class WindowsWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollInFlight = false;
private helperSpec: WindowsTrackerHelperLaunchSpec | null;
private readonly targetMpvSocketPath: string | null;
private readonly runHelper: (
spec: WindowsTrackerHelperLaunchSpec,
mode: 'geometry',
targetMpvSocketPath: string | null,
) => Promise<WindowsTrackerRunnerResult>;
private lastExecErrorFingerprint: string | null = null;
private lastExecErrorLoggedAtMs = 0;
constructor(targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.helperSpec = deps.resolveHelper ? deps.resolveHelper() : resolveWindowsTrackerHelper();
this.runHelper = deps.runHelper ?? runHelperWithExecFile;
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
private maybeLogExecError(error: Error, stderr: string): void {
const now = Date.now();
const fingerprint = `${error.message}|${stderr.trim()}`;
const shouldLog =
this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000;
if (!shouldLog) {
return;
}
this.lastExecErrorFingerprint = fingerprint;
this.lastExecErrorLoggedAtMs = now;
log.warn('Windows helper execution failed', {
helperPath: this.helperSpec?.helperPath ?? null,
helperKind: this.helperSpec?.kind ?? null,
error: error.message,
stderr: stderr.trim(),
});
}
private async runHelperWithSocketFallback(): Promise<WindowsTrackerRunnerResult> {
if (!this.helperSpec) {
return { stdout: 'not-found', stderr: '' };
}
try {
const primary = await this.runHelper(this.helperSpec, 'geometry', this.targetMpvSocketPath);
const primaryGeometry = parseWindowTrackerHelperOutput(primary.stdout);
if (primaryGeometry || !this.targetMpvSocketPath) {
return primary;
}
} catch (error) {
if (!this.targetMpvSocketPath) {
throw error;
}
}
return await this.runHelper(this.helperSpec, 'geometry', null);
}
private pollGeometry(): void {
if (this.pollInFlight || !this.helperSpec) {
return;
}
this.pollInFlight = true;
void this.runHelperWithSocketFallback()
.then(({ stdout, stderr }) => {
const geometry = parseWindowTrackerHelperOutput(stdout);
const focusState = parseWindowTrackerHelperFocusState(stderr);
this.updateTargetWindowFocused(focusState ?? Boolean(geometry));
this.updateGeometry(geometry);
})
.catch((error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error));
const stderr =
typeof error === 'object' &&
error !== null &&
'stderr' in error &&
typeof (error as { stderr?: unknown }).stderr === 'string'
? (error as { stderr: string }).stderr
: '';
this.maybeLogExecError(err, stderr);
this.updateGeometry(null);
})
.finally(() => {
this.pollInFlight = false;
});
}
}