mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
fix(controller): pick up macOS presses while mpv is in front (#321)
This commit is contained in:
1 parent
fa3051f9cb
commit
8e29cb3c55
17 files changed
+614
-63
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
handleBackgroundControllerPress,
|
||||
parseControllerWatchLine,
|
||||
resolveMacOSControllerWatchCommand,
|
||||
} from './macos-controller-watch';
|
||||
|
||||
test('parseControllerWatchLine reads press lines only', () => {
|
||||
assert.deepEqual(parseControllerWatchLine('press 7 2\n'), { buttonIndex: 7, controllerCount: 2 });
|
||||
assert.equal(parseControllerWatchLine('press 7'), null);
|
||||
assert.equal(parseControllerWatchLine('release 7 1'), null);
|
||||
});
|
||||
|
||||
function fakeFs(files: Record<string, number>) {
|
||||
return {
|
||||
existsSync: (filePath: string) => filePath in files,
|
||||
statSync: (filePath: string) => ({ mtimeMs: files[filePath] }),
|
||||
} as unknown as Parameters<typeof resolveMacOSControllerWatchCommand>[1];
|
||||
}
|
||||
|
||||
test('resolveMacOSControllerWatchCommand prefers the packaged binary', () => {
|
||||
const packaged = path.join('/res', 'scripts', 'macos-controller-watch');
|
||||
assert.deepEqual(
|
||||
resolveMacOSControllerWatchCommand(
|
||||
{ resourcesPath: '/res', mainDirname: '/repo/dist' },
|
||||
fakeFs({ [packaged]: 1 }),
|
||||
),
|
||||
{ command: packaged, args: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveMacOSControllerWatchCommand runs the source when the build is stale', () => {
|
||||
const built = path.join('/repo/dist', 'scripts', 'macos-controller-watch');
|
||||
const source = path.join('/repo', 'scripts', 'macos-controller-watch.swift');
|
||||
const options = { resourcesPath: undefined, mainDirname: '/repo/dist' };
|
||||
|
||||
assert.deepEqual(
|
||||
resolveMacOSControllerWatchCommand(options, fakeFs({ [built]: 2, [source]: 1 })),
|
||||
{
|
||||
command: built,
|
||||
args: [],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveMacOSControllerWatchCommand(options, fakeFs({ [built]: 1, [source]: 2 })),
|
||||
{
|
||||
command: 'swift',
|
||||
args: [source],
|
||||
},
|
||||
);
|
||||
assert.equal(resolveMacOSControllerWatchCommand(options, fakeFs({})), null);
|
||||
});
|
||||
|
||||
function window(name: string, visible = true) {
|
||||
return { name, isDestroyed: () => false, isVisible: () => visible };
|
||||
}
|
||||
|
||||
function run(overrides: {
|
||||
controllerCount?: number;
|
||||
enabled?: boolean;
|
||||
mpvFocused?: boolean;
|
||||
modal?: ReturnType<typeof window> | null;
|
||||
main?: ReturnType<typeof window> | null;
|
||||
}): string[] {
|
||||
const calls: string[] = [];
|
||||
const press = { buttonIndex: 4, controllerCount: overrides.controllerCount ?? 1 };
|
||||
handleBackgroundControllerPress(press, {
|
||||
isControllerEnabled: () => overrides.enabled ?? true,
|
||||
isMpvFocused: () => overrides.mpvFocused ?? true,
|
||||
getModalWindow: () => overrides.modal ?? null,
|
||||
getMainWindow: () => (overrides.main === undefined ? window('main') : overrides.main),
|
||||
focusWindow: (target) => calls.push(`focus:${target.name}`),
|
||||
replayPress: (target, index) => calls.push(`replay:${target.name}:${index}`),
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('a press while mpv is in front focuses the overlay and replays it', () => {
|
||||
assert.deepEqual(run({}), ['focus:main', 'replay:main:4']);
|
||||
});
|
||||
|
||||
test('with several pads connected a press focuses the overlay without replaying', () => {
|
||||
assert.deepEqual(run({ controllerCount: 2 }), ['focus:main']);
|
||||
});
|
||||
|
||||
test('an open modal window takes the press instead of the main overlay', () => {
|
||||
assert.deepEqual(run({ modal: window('modal') }), ['focus:modal', 'replay:modal:4']);
|
||||
});
|
||||
|
||||
test('presses are ignored unless mpv is in front, the controller is on and an overlay shows', () => {
|
||||
assert.deepEqual(run({ mpvFocused: false }), []);
|
||||
assert.deepEqual(run({ enabled: false }), []);
|
||||
assert.deepEqual(run({ main: window('main', false) }), []);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { spawn as spawnProcess } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as readline from 'node:readline';
|
||||
import { isCompiledMacOSHelperCurrent } from '../../window-trackers/macos-tracker';
|
||||
|
||||
// macOS only delivers GameController input to the frontmost app, so the overlay's Gamepad API
|
||||
// sees nothing while mpv is in front. `scripts/macos-controller-watch.swift` opts in to
|
||||
// background events and reports each button press; SubMiner then focuses the overlay (after
|
||||
// which Chromium reads the pad itself) and replays that first press there.
|
||||
|
||||
const HELPER_NAME = 'macos-controller-watch';
|
||||
|
||||
export type MacOSControllerWatchCommand = { command: string; args: string[] };
|
||||
|
||||
/**
|
||||
* Finds the helper: the packaged binary in Resources, a compiled build in dist/scripts that is
|
||||
* newer than its source, or the Swift source run through `swift` in a dev checkout.
|
||||
*/
|
||||
export function resolveMacOSControllerWatchCommand(
|
||||
options: {
|
||||
resourcesPath: string | undefined;
|
||||
/** Directory holding the built main process (dist/ in a checkout). */
|
||||
mainDirname: string;
|
||||
},
|
||||
helperFs: Pick<typeof fs, 'existsSync' | 'statSync'> = fs,
|
||||
): MacOSControllerWatchCommand | null {
|
||||
const sourcePath = path.join(options.mainDirname, '..', 'scripts', `${HELPER_NAME}.swift`);
|
||||
if (options.resourcesPath) {
|
||||
const packagedPath = path.join(options.resourcesPath, 'scripts', HELPER_NAME);
|
||||
if (helperFs.existsSync(packagedPath)) return { command: packagedPath, args: [] };
|
||||
}
|
||||
const builtPath = path.join(options.mainDirname, 'scripts', HELPER_NAME);
|
||||
if (isCompiledMacOSHelperCurrent(builtPath, sourcePath, helperFs)) {
|
||||
return { command: builtPath, args: [] };
|
||||
}
|
||||
if (helperFs.existsSync(sourcePath)) return { command: 'swift', args: [sourcePath] };
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ControllerWatchPress = { buttonIndex: number; controllerCount: number };
|
||||
|
||||
/** Parses a helper line ("press <standard-mapping button index> <connected pads>"). */
|
||||
export function parseControllerWatchLine(line: string): ControllerWatchPress | null {
|
||||
const match = /^press (\d+) (\d+)$/.exec(line.trim());
|
||||
return match ? { buttonIndex: Number(match[1]), controllerCount: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
type ControllerWatchLogger = {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
/** Runs the helper until `stop` is called; the helper also exits when SubMiner does. */
|
||||
export function startMacOSControllerWatch(options: {
|
||||
command: MacOSControllerWatchCommand;
|
||||
onPress: (press: ControllerWatchPress) => void;
|
||||
logger: ControllerWatchLogger;
|
||||
spawn?: typeof spawnProcess;
|
||||
}): { stop: () => void } {
|
||||
const spawn = options.spawn ?? spawnProcess;
|
||||
const child = spawn(options.command.command, options.command.args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stopped = false;
|
||||
|
||||
readline.createInterface({ input: child.stdout }).on('line', (line) => {
|
||||
const press = parseControllerWatchLine(line);
|
||||
if (press) options.onPress(press);
|
||||
});
|
||||
readline.createInterface({ input: child.stderr }).on('line', (line) => {
|
||||
options.logger.warn(`[${HELPER_NAME}] ${line}`);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
options.logger.warn(`Failed to start ${HELPER_NAME}`, error);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (!stopped) options.logger.warn(`${HELPER_NAME} exited`, { code, signal });
|
||||
});
|
||||
options.logger.info(`Started ${HELPER_NAME}: ${options.command.command}`);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
child.stdin?.end();
|
||||
child.kill();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type FocusableWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A press the overlay missed because mpv was in front: focus the overlay window that owns the
|
||||
* controller (a visible modal, else the main overlay) and replay the press in it. Presses made
|
||||
* while another app is in front are left alone, so a pad used elsewhere is not hijacked. With
|
||||
* several pads connected the press only focuses: it might not be from the pad the overlay reads.
|
||||
*/
|
||||
export function handleBackgroundControllerPress<TWindow extends FocusableWindow>(
|
||||
press: ControllerWatchPress,
|
||||
deps: {
|
||||
isControllerEnabled: () => boolean;
|
||||
isMpvFocused: () => boolean;
|
||||
getModalWindow: () => TWindow | null;
|
||||
getMainWindow: () => TWindow | null;
|
||||
focusWindow: (window: TWindow) => void;
|
||||
replayPress: (window: TWindow, buttonIndex: number) => void;
|
||||
},
|
||||
): void {
|
||||
if (!deps.isControllerEnabled() || !deps.isMpvFocused()) return;
|
||||
const target = [deps.getModalWindow(), deps.getMainWindow()].find(
|
||||
(window): window is TWindow => window !== null && !window.isDestroyed() && window.isVisible(),
|
||||
);
|
||||
if (!target) return;
|
||||
deps.focusWindow(target);
|
||||
if (press.controllerCount === 1) deps.replayPress(target, press.buttonIndex);
|
||||
}
|
||||
Reference in new issue
Block a user