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);