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
@@ -4,6 +4,8 @@ const path = require('node:path');
|
||||
|
||||
const LINUX_FFMPEG_LIBRARY = 'libffmpeg.so';
|
||||
const MACOS_WINDOW_HELPER = 'get-mpv-window-macos';
|
||||
const MACOS_CONTROLLER_HELPER = 'macos-controller-watch';
|
||||
const MACOS_HELPERS = [MACOS_WINDOW_HELPER, MACOS_CONTROLLER_HELPER];
|
||||
const DEFAULT_MACOS_APP_BUNDLE = 'SubMiner.app';
|
||||
|
||||
function resolveMacOSAppBundlePath(context) {
|
||||
@@ -52,7 +54,7 @@ async function stageLinuxAppImageSharedLibrary(
|
||||
return true;
|
||||
}
|
||||
|
||||
async function verifyMacOSWindowHelper(
|
||||
async function verifyMacOSHelpers(
|
||||
context,
|
||||
deps = {
|
||||
access: (filePath, mode) => fs.access(filePath, mode),
|
||||
@@ -60,27 +62,28 @@ async function verifyMacOSWindowHelper(
|
||||
) {
|
||||
if (context.electronPlatformName !== 'darwin') return false;
|
||||
|
||||
const appBundlePath = resolveMacOSAppBundlePath(context);
|
||||
const helperPath = path.join(
|
||||
appBundlePath,
|
||||
const scriptsDir = path.join(
|
||||
resolveMacOSAppBundlePath(context),
|
||||
'Contents',
|
||||
'Resources',
|
||||
'scripts',
|
||||
MACOS_WINDOW_HELPER,
|
||||
);
|
||||
|
||||
try {
|
||||
await deps.access(helperPath, fsSync.constants.X_OK);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
`macOS packaging requires ${MACOS_WINDOW_HELPER} at ${helperPath}. Run bun run build:assets on macOS with swiftc available before packaging.`,
|
||||
);
|
||||
for (const helperName of MACOS_HELPERS) {
|
||||
const helperPath = path.join(scriptsDir, helperName);
|
||||
try {
|
||||
await deps.access(helperPath, fsSync.constants.X_OK);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
`macOS packaging requires ${helperName} at ${helperPath}. Run bun run build:assets on macOS with swiftc available before packaging.`,
|
||||
);
|
||||
}
|
||||
if (error && typeof error === 'object' && error.code === 'EACCES') {
|
||||
throw new Error(`macOS helper is not executable: ${helperPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (error && typeof error === 'object' && error.code === 'EACCES') {
|
||||
throw new Error(`macOS window helper is not executable: ${helperPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -103,17 +106,17 @@ async function stageBundledBunRuntime(context, deps = {}) {
|
||||
|
||||
async function afterPack(context, deps = {}) {
|
||||
await stageLinuxAppImageSharedLibrary(context);
|
||||
await verifyMacOSWindowHelper(context);
|
||||
await verifyMacOSHelpers(context);
|
||||
await stageBundledBunRuntime(context, deps);
|
||||
await (deps.auditPackage ?? require('./package-audit.cjs').auditPackage)(context);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LINUX_FFMPEG_LIBRARY,
|
||||
MACOS_WINDOW_HELPER,
|
||||
MACOS_HELPERS,
|
||||
resolveMacOSAppBundlePath,
|
||||
stageBundledBunRuntime,
|
||||
stageLinuxAppImageSharedLibrary,
|
||||
verifyMacOSWindowHelper,
|
||||
verifyMacOSHelpers,
|
||||
default: afterPack,
|
||||
};
|
||||
@@ -6,13 +6,13 @@ import test from 'node:test';
|
||||
|
||||
const {
|
||||
LINUX_FFMPEG_LIBRARY,
|
||||
MACOS_WINDOW_HELPER,
|
||||
MACOS_HELPERS,
|
||||
default: afterPack,
|
||||
stageLinuxAppImageSharedLibrary,
|
||||
verifyMacOSWindowHelper,
|
||||
verifyMacOSHelpers,
|
||||
} = require('./electron-builder-after-pack.cjs') as {
|
||||
LINUX_FFMPEG_LIBRARY: string;
|
||||
MACOS_WINDOW_HELPER: string;
|
||||
MACOS_HELPERS: string[];
|
||||
default: (
|
||||
context: {
|
||||
appOutDir: string;
|
||||
@@ -34,7 +34,7 @@ const {
|
||||
appOutDir: string;
|
||||
electronPlatformName: string;
|
||||
}) => Promise<boolean>;
|
||||
verifyMacOSWindowHelper: (context: {
|
||||
verifyMacOSHelpers: (context: {
|
||||
appOutDir: string;
|
||||
electronPlatformName: string;
|
||||
}) => Promise<boolean>;
|
||||
@@ -88,24 +88,19 @@ test('stageLinuxAppImageSharedLibrary skips non-Linux packaging contexts', async
|
||||
}
|
||||
});
|
||||
|
||||
test('verifyMacOSWindowHelper accepts packaged macOS helper binary', async () => {
|
||||
test('verifyMacOSHelpers accepts packaged macOS helper binaries', async () => {
|
||||
const workspace = createWorkspace('subminer-after-pack-macos-helper');
|
||||
const appOutDir = path.join(workspace, 'SubMiner-darwin-arm64');
|
||||
const helperPath = path.join(
|
||||
appOutDir,
|
||||
'SubMiner.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'scripts',
|
||||
MACOS_WINDOW_HELPER,
|
||||
);
|
||||
const scriptsDir = path.join(appOutDir, 'SubMiner.app', 'Contents', 'Resources', 'scripts');
|
||||
|
||||
fs.mkdirSync(path.dirname(helperPath), { recursive: true });
|
||||
fs.writeFileSync(helperPath, 'compiled helper', 'utf8');
|
||||
fs.chmodSync(helperPath, 0o755);
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
for (const helperName of MACOS_HELPERS) {
|
||||
fs.writeFileSync(path.join(scriptsDir, helperName), 'compiled helper', 'utf8');
|
||||
fs.chmodSync(path.join(scriptsDir, helperName), 0o755);
|
||||
}
|
||||
|
||||
try {
|
||||
const verified = await verifyMacOSWindowHelper({
|
||||
const verified = await verifyMacOSHelpers({
|
||||
appOutDir,
|
||||
electronPlatformName: 'darwin',
|
||||
});
|
||||
@@ -116,19 +111,22 @@ test('verifyMacOSWindowHelper accepts packaged macOS helper binary', async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('verifyMacOSWindowHelper throws when macOS helper binary is missing', async () => {
|
||||
test('verifyMacOSHelpers throws when a macOS helper binary is missing', async () => {
|
||||
const workspace = createWorkspace('subminer-after-pack-macos-helper-missing');
|
||||
const appOutDir = path.join(workspace, 'SubMiner-darwin-arm64');
|
||||
const scriptsDir = path.join(appOutDir, 'SubMiner.app', 'Contents', 'Resources', 'scripts');
|
||||
|
||||
fs.mkdirSync(path.join(appOutDir, 'SubMiner.app', 'Contents', 'Resources'), { recursive: true });
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(scriptsDir, 'get-mpv-window-macos'), 'compiled helper', 'utf8');
|
||||
fs.chmodSync(path.join(scriptsDir, 'get-mpv-window-macos'), 0o755);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
verifyMacOSWindowHelper({
|
||||
verifyMacOSHelpers({
|
||||
appOutDir,
|
||||
electronPlatformName: 'darwin',
|
||||
}),
|
||||
/macOS packaging requires get-mpv-window-macos/,
|
||||
/macOS packaging requires macos-controller-watch/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env swift
|
||||
//
|
||||
// macos-controller-watch.swift
|
||||
// SubMiner - Report controller button presses on macOS while SubMiner is in the background
|
||||
//
|
||||
// macOS only delivers GameController input to the frontmost app, so the overlay sees no
|
||||
// presses while mpv is in front. This helper opts in to background events and prints one
|
||||
// line per press so SubMiner can focus the overlay and replay the press there.
|
||||
//
|
||||
// Usage: macos-controller-watch (long-running; exits when stdin closes)
|
||||
// Output: "press <index> <connected pads>" per button press, using the W3C standard gamepad
|
||||
// button indices. SubMiner only replays a press when one pad is connected: nothing identifies
|
||||
// a pad to both GameController and Chromium, so with several it could hit the wrong one.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import GameController
|
||||
|
||||
private func emit(_ line: String) {
|
||||
FileHandle.standardOutput.write(Data((line + "\n").utf8))
|
||||
}
|
||||
|
||||
/// Standard gamepad mapping (https://w3c.github.io/gamepad/#remapping), matching the
|
||||
/// indices Chromium reports for the same pad.
|
||||
private func standardButtons(_ pad: GCExtendedGamepad) -> [(Int, GCControllerButtonInput?)] {
|
||||
return [
|
||||
(0, pad.buttonA),
|
||||
(1, pad.buttonB),
|
||||
(2, pad.buttonX),
|
||||
(3, pad.buttonY),
|
||||
(4, pad.leftShoulder),
|
||||
(5, pad.rightShoulder),
|
||||
(6, pad.leftTrigger),
|
||||
(7, pad.rightTrigger),
|
||||
(8, pad.buttonOptions),
|
||||
(9, pad.buttonMenu),
|
||||
(10, pad.leftThumbstickButton),
|
||||
(11, pad.rightThumbstickButton),
|
||||
(12, pad.dpad.up),
|
||||
(13, pad.dpad.down),
|
||||
(14, pad.dpad.left),
|
||||
(15, pad.dpad.right),
|
||||
(16, pad.buttonHome),
|
||||
]
|
||||
}
|
||||
|
||||
private func connectedPadCount() -> Int {
|
||||
return GCController.controllers().filter { $0.extendedGamepad != nil }.count
|
||||
}
|
||||
|
||||
private func watch(_ controller: GCController) {
|
||||
guard let pad = controller.extendedGamepad else { return }
|
||||
for (index, button) in standardButtons(pad) {
|
||||
button?.pressedChangedHandler = { _, _, pressed in
|
||||
if pressed {
|
||||
emit("press \(index) \(connectedPadCount())")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit with SubMiner: it holds our stdin open for as long as it runs.
|
||||
private func exitWhenStdinCloses() {
|
||||
Thread.detachNewThread {
|
||||
while !FileHandle.standardInput.availableData.isEmpty {}
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.prohibited)
|
||||
GCController.shouldMonitorBackgroundEvents = true
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .GCControllerDidConnect,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { notification in
|
||||
if let controller = notification.object as? GCController {
|
||||
watch(controller)
|
||||
}
|
||||
}
|
||||
GCController.controllers().forEach(watch)
|
||||
|
||||
exitWhenStdinCloses()
|
||||
app.run()
|
||||
@@ -14,9 +14,8 @@ const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
|
||||
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
|
||||
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift');
|
||||
// Swift helpers compiled on macOS and shipped in Resources/scripts.
|
||||
const MACOS_HELPERS = ['get-mpv-window-macos', 'macos-controller-watch'];
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
@@ -45,9 +44,10 @@ function copySyncUiAssets() {
|
||||
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||
function fallbackToMacosSource(helperName) {
|
||||
const sourceCopyPath = path.join(scriptsOutputDir, `${helperName}.swift`);
|
||||
copyFile(path.join(scriptDir, `${helperName}.swift`), sourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${sourceCopyPath}\n`);
|
||||
}
|
||||
|
||||
// Pin the minimum macOS to the app's own floor (Electron's `minos`). Without an
|
||||
@@ -64,34 +64,34 @@ function shouldSkipMacosHelperBuild() {
|
||||
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
||||
}
|
||||
|
||||
function buildMacosHelper() {
|
||||
function buildMacosHelper(helperName) {
|
||||
if (shouldSkipMacosHelperBuild()) {
|
||||
process.stdout.write('Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)\n');
|
||||
fallbackToMacosSource();
|
||||
fallbackToMacosSource(helperName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.stdout.write('Skipping macOS helper build (not on macOS)\n');
|
||||
fallbackToMacosSource();
|
||||
fallbackToMacosSource(helperName);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDir(scriptsOutputDir);
|
||||
const sourcePath = path.join(scriptDir, `${helperName}.swift`);
|
||||
const binaryPath = path.join(scriptsOutputDir, helperName);
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'swiftc',
|
||||
['-O', '-target', macosHelperTarget(), macosHelperSourcePath, '-o', macosHelperBinaryPath],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
fs.chmodSync(macosHelperBinaryPath, 0o755);
|
||||
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
|
||||
execFileSync('swiftc', ['-O', '-target', macosHelperTarget(), sourcePath, '-o', binaryPath], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
process.stdout.write(`Built macOS helper: ${binaryPath}\n`);
|
||||
} catch (error) {
|
||||
process.stdout.write('Failed to compile macOS helper; using source fallback.\n');
|
||||
fallbackToMacosSource();
|
||||
process.stdout.write(`Failed to compile macOS helper ${helperName}; using source fallback.\n`);
|
||||
// A binary from an earlier build would otherwise pass the after-pack check and ship stale.
|
||||
fs.rmSync(binaryPath, { force: true });
|
||||
fallbackToMacosSource(helperName);
|
||||
if (error instanceof Error) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ function main() {
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
buildMacosHelper();
|
||||
MACOS_HELPERS.forEach(buildMacosHelper);
|
||||
stageIntegrationKeys();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user