Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/main/driverSources.js
Original file line number Diff line number Diff line change
@@ -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 };
17 changes: 17 additions & 0 deletions src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
67 changes: 55 additions & 12 deletions src/renderer/components/DriverStatusBanner.jsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 (
<AnimatePresence>
Expand All @@ -43,18 +71,33 @@ export default function DriverStatusBanner() {
features won't work yet.
</span>
</div>
<div className="flex gap-3">
<div className="flex flex-wrap items-center gap-4">
{missing.map((d) => (
<button
key={d.key}
onClick={() => system.openExternal(d.url)}
className="rounded-md border border-amber-500/40 px-2.5 py-1 text-xs font-medium text-amber-300 transition-colors hover:bg-amber-500/20"
>
Download {d.name}
</button>
<div key={d.sourceKey} className="flex items-center gap-2">
<button
onClick={() => handleInstall(d)}
disabled={installing === d.sourceKey}
className="flex items-center gap-1.5 rounded-md border border-amber-500/40 px-2.5 py-1 text-xs font-medium text-amber-300 transition-colors hover:bg-amber-500/20 disabled:opacity-50"
>
{installing === d.sourceKey ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Download size={12} />
)}
{installing === d.sourceKey ? 'Downloading...' : `Install ${d.name}`}
{versions[d.sourceKey] ? ` (${versions[d.sourceKey]})` : ''}
</button>
<button
onClick={() => system.openExternal(d.url)}
className="text-xs text-amber-300/70 hover:text-amber-300 hover:underline"
>
or open page
</button>
</div>
))}
</div>
</div>
{installError && <div className="px-6 pb-2 text-xs text-red-400">{installError}</div>}
</motion.div>
)}
</AnimatePresence>
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading