From d97faafb99fc970ca1fb6ef71263e0d7f89a39e2 Mon Sep 17 00:00:00 2001 From: Constantinos-T Date: Sat, 1 Aug 2026 19:22:25 +0300 Subject: [PATCH] Install vJoy and HidHide directly from their official sources The driver banner used to only link out to each project's download page, leaving the user to find, download, and run the installer by hand. It now asks each project's own official source for its latest installer (vJoy via SourceForge's release API, HidHide via the GitHub releases API) and downloads and launches it directly, showing the latest available version next to the button. A manual "open page" link stays as a fallback. Neither driver is bundled inside MIM's own installer: both are kernel-mode drivers maintained upstream, so always pulling the current release avoids MIM ever shipping a stale, unpatched copy. --- src/main/driverSources.js | 115 ++++++++++++++++++ src/main/main.js | 17 +++ .../components/DriverStatusBanner.jsx | 67 ++++++++-- src/renderer/preload.js | 2 + 4 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 src/main/driverSources.js diff --git a/src/main/driverSources.js b/src/main/driverSources.js new file mode 100644 index 0000000..94df200 --- /dev/null +++ b/src/main/driverSources.js @@ -0,0 +1,115 @@ +const http = require('http'); +const https = require('https'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const { spawn } = require('child_process'); + +const REDIRECT_CODES = [301, 302, 303, 307, 308]; +const USER_AGENT = 'mojo-input-manager'; + +// SourceForge's own download URLs are plain http:// even when the JSON +// metadata comes from https, so requests need to follow whichever scheme +// the current URL (or redirect target) actually uses. +function clientFor(url) { + return url.startsWith('http://') ? http : https; +} + +// vJoy and HidHide are kernel-mode drivers, not something MIM bundles or +// maintains itself. Instead of pointing users to a webpage, MIM asks each +// project's own official source for its latest installer and runs that +// directly, so a stale copy is never shipped inside MIM's own installer. +const SOURCES = { + vjoy: { + name: 'vJoy', + infoUrl: 'https://sourceforge.net/projects/vjoystick/best_release.json', + parse(data) { + const release = data.platform_releases?.windows ?? data.release; + if (!release?.url) throw new Error('No Windows release found for vJoy.'); + return { + version: path.basename(path.dirname(release.filename)), + downloadUrl: release.url, + fileName: path.basename(release.filename) + }; + } + }, + hidhide: { + name: 'HidHide', + infoUrl: 'https://api.github.com/repos/nefarius/HidHide/releases/latest', + parse(data) { + const asset = data.assets?.find((a) => a.name.endsWith('.exe')); + if (!asset) throw new Error('No installer found in the latest HidHide release.'); + return { version: data.tag_name, downloadUrl: asset.browser_download_url, fileName: asset.name }; + } + } +}; + +function fetchJson(url, redirects = 5) { + return new Promise((resolve, reject) => { + clientFor(url) + .get(url, { headers: { 'User-Agent': USER_AGENT } }, (res) => { + if (REDIRECT_CODES.includes(res.statusCode) && res.headers.location && redirects > 0) { + res.resume(); + resolve(fetchJson(res.headers.location, redirects - 1)); + return; + } + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`Request failed with status ${res.statusCode}.`)); + return; + } + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('Could not parse the response.')); + } + }); + }) + .on('error', reject); + }); +} + +function downloadFile(url, destPath, redirects = 5) { + return new Promise((resolve, reject) => { + clientFor(url) + .get(url, { headers: { 'User-Agent': USER_AGENT } }, (res) => { + if (REDIRECT_CODES.includes(res.statusCode) && res.headers.location && redirects > 0) { + res.resume(); + resolve(downloadFile(res.headers.location, destPath, redirects - 1)); + return; + } + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`Download failed with status ${res.statusCode}.`)); + return; + } + const file = fs.createWriteStream(destPath); + res.pipe(file); + file.on('finish', () => file.close(() => resolve())); + file.on('error', reject); + }) + .on('error', reject); + }); +} + +async function getLatest(key) { + const source = SOURCES[key]; + if (!source) throw new Error(`Unknown driver: ${key}`); + const data = await fetchJson(source.infoUrl); + return { name: source.name, ...source.parse(data) }; +} + +async function downloadAndRun(key) { + const info = await getLatest(key); + const destPath = path.join(os.tmpdir(), info.fileName); + await downloadFile(info.downloadUrl, destPath); + // vJoy's and HidHide's installers request elevation themselves via their + // own manifest, so MIM doesn't need its own UAC wrapper here. + spawn(destPath, [], { detached: true, stdio: 'ignore' }).unref(); + return info; +} + +module.exports = { getLatest, downloadAndRun }; diff --git a/src/main/main.js b/src/main/main.js index 4e3a39d..ca11f4a 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -6,6 +6,7 @@ const vjoyInterface = require('./vjoyInterface'); const hidhide = require('./hidhide'); const profiles = require('./profiles'); const mappingProfiles = require('./mappingProfiles'); +const driverSources = require('./driverSources'); let activeMappingDeviceId = null; @@ -316,6 +317,22 @@ ipcMain.handle('system:open-external', (event, url) => { } }); +ipcMain.handle('system:get-driver-info', async (event, key) => { + try { + return { ok: true, info: await driverSources.getLatest(key) }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + +ipcMain.handle('system:install-driver', async (event, key) => { + try { + return { ok: true, info: await driverSources.downloadAndRun(key) }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + ipcMain.handle('system:get-app-version', () => app.getVersion()); ipcMain.handle('system:get-update-status', () => updateStatus); diff --git a/src/renderer/components/DriverStatusBanner.jsx b/src/renderer/components/DriverStatusBanner.jsx index 1db5299..69b6852 100644 --- a/src/renderer/components/DriverStatusBanner.jsx +++ b/src/renderer/components/DriverStatusBanner.jsx @@ -1,16 +1,24 @@ import { useCallback, useEffect, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; -import { AlertTriangle } from 'lucide-react'; +import { AlertTriangle, Download, Loader2 } from 'lucide-react'; const system = typeof window !== 'undefined' ? window.mim?.system : undefined; const DRIVERS = [ - { key: 'vjoyInstalled', name: 'vJoy', url: 'https://sourceforge.net/projects/vjoystick/' }, - { key: 'hidhideInstalled', name: 'HidHide', url: 'https://github.com/nefarius/HidHide/releases' } + { statusKey: 'vjoyInstalled', sourceKey: 'vjoy', name: 'vJoy', url: 'https://sourceforge.net/projects/vjoystick/' }, + { + statusKey: 'hidhideInstalled', + sourceKey: 'hidhide', + name: 'HidHide', + url: 'https://github.com/nefarius/HidHide/releases' + } ]; export default function DriverStatusBanner() { const [status, setStatus] = useState(null); + const [versions, setVersions] = useState({}); + const [installing, setInstalling] = useState(null); + const [installError, setInstallError] = useState(null); const check = useCallback(async () => { if (!system) return; @@ -24,7 +32,27 @@ export default function DriverStatusBanner() { return () => window.removeEventListener('focus', check); }, [check]); - const missing = status ? DRIVERS.filter((d) => !status[d.key]) : []; + const missing = status ? DRIVERS.filter((d) => !status[d.statusKey]) : []; + + useEffect(() => { + missing.forEach((d) => { + if (d.sourceKey in versions) return; + system?.getDriverInfo(d.sourceKey).then((result) => { + setVersions((v) => ({ ...v, [d.sourceKey]: result.ok ? result.info.version : null })); + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]); + + async function handleInstall(driver) { + setInstalling(driver.sourceKey); + setInstallError(null); + const result = await system.installDriver(driver.sourceKey); + if (!result.ok) { + setInstallError(`${driver.name}: ${result.error}`); + } + setInstalling(null); + } return ( @@ -43,18 +71,33 @@ export default function DriverStatusBanner() { features won't work yet. -
+
{missing.map((d) => ( - +
+ + +
))}
+ {installError &&
{installError}
} )}
diff --git a/src/renderer/preload.js b/src/renderer/preload.js index 1f3b165..608d960 100644 --- a/src/renderer/preload.js +++ b/src/renderer/preload.js @@ -45,6 +45,8 @@ contextBridge.exposeInMainWorld('mim', { getAppVersion: () => ipcRenderer.invoke('system:get-app-version'), getUpdateStatus: () => ipcRenderer.invoke('system:get-update-status'), checkForUpdates: () => ipcRenderer.invoke('system:check-for-updates'), + getDriverInfo: (key) => ipcRenderer.invoke('system:get-driver-info', key), + installDriver: (key) => ipcRenderer.invoke('system:install-driver', key), onUpdateStatus: (callback) => { const listener = (_event, status) => callback(status); ipcRenderer.on('updater:status', listener);