Linux: SteamVR overlay support (#1299)

* fix: open folder and select item on linux

* feat: linux wrist overlay

* feat: linux hmd overlay

* feat: replace unix sockets with shm on linux

* fix: reduce linux wrist overlay fps

* fix: hide electron offscreen windows

* fix: destroy electron offscreen windows when not in use

* fix: open folder and select item on linux

* feat: cpu, uptime and device monitoring on linux

* feat: native wayland gl context with x11 fallback on linux

* fix: use platform agnostic wording for common folders

* fix: crash dumps folder button on linux

* fix: enable missing VR notification options on linux

* fix: update cef, eslint config to include updated AppApiVr names

* merge: rebase linux VR changes to upstream

* Clean up

* Load custom file contents rather than path

Fixes loading custom file in debug mode

* fix: call SetVR on linux as well

* fix: AppApiVrElectron init, properly create and dispose of shm

* Handle avatar history error

* Lint

* Change overlay dispose logic

* macOS DOTNET_ROOT

* Remove moving dotnet bin

* Fix

* fix: init overlay on SteamVR restart

* Fix fetching empty instance, fix user dialog not fetching

* Trim direct access inputs

* Make icon higher res, because mac build would fail 😂

* macOS fixes

* will it build? that's the question

* fix: ensure offscreen windows are ready before vrinit

* will it build? that's the question

* will it build? that's the question

* meow

* one, more, time

* Fix crash and overlay ellipsis

* a

---------

Co-authored-by: Natsumi <cmcooper123@hotmail.com>
This commit is contained in:
rs189
2025-07-19 09:07:43 +09:00
committed by GitHub
parent 53723d37b0
commit a2dc6ba9a4
53 changed files with 10555 additions and 7865 deletions

View File

@@ -5,88 +5,101 @@ const { spawnSync } = require('child_process');
const DOTNET_VERSION = '9.0.7';
const DOTNET_RUNTIME_URL = `https://builds.dotnet.microsoft.com/dotnet/Runtime/${DOTNET_VERSION}/dotnet-runtime-${DOTNET_VERSION}-linux-x64.tar.gz`;
const DOTNET_RUNTIME_DIR = path.join(__dirname, '..', 'build', 'Electron', 'dotnet-runtime');
const DOTNET_BIN_DIR = path.join(DOTNET_RUNTIME_DIR, 'bin');
const DOTNET_RUNTIME_DIR = path.join(
__dirname,
'..',
'build',
'Electron',
'dotnet-runtime'
);
async function downloadFile(url, targetPath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(targetPath);
https.get(url, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Failed to download, status code: ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close(resolve);
https
.get(url, (response) => {
if (response.statusCode !== 200) {
reject(
new Error(
`Failed to download, status code: ${response.statusCode}`
)
);
return;
}
response.pipe(file);
file.on('finish', () => {
file.close(resolve);
});
})
.on('error', (err) => {
fs.unlink(targetPath, () => reject(err));
});
}).on('error', (err) => {
fs.unlink(targetPath, () => reject(err));
});
});
}
async function extractTarGz(tarGzPath, extractDir) {
return new Promise((resolve, reject) => {
const tar = spawnSync('tar', ['-xzf', tarGzPath, '-C', extractDir, '--strip-components=1'], {
stdio: 'inherit'
});
const tar = spawnSync(
'tar',
['-xzf', tarGzPath, '-C', extractDir, '--strip-components=1'],
{
stdio: 'inherit'
}
);
if (tar.status === 0) {
resolve();
} else {
reject(new Error(`tar extraction failed with status ${tar.status}`));
reject(
new Error(`tar extraction failed with status ${tar.status}`)
);
}
});
}
async function main() {
if (process.platform !== 'linux') {
console.log('Skipping .NET runtime download on non-Linux platform');
return;
}
console.log(`Downloading .NET ${DOTNET_VERSION} runtime...`);
if (!fs.existsSync(DOTNET_RUNTIME_DIR)) {
fs.mkdirSync(DOTNET_RUNTIME_DIR, { recursive: true });
}
if (!fs.existsSync(DOTNET_BIN_DIR)) {
fs.mkdirSync(DOTNET_BIN_DIR, { recursive: true });
}
const tarGzPath = path.join(DOTNET_RUNTIME_DIR, 'dotnet-runtime.tar.gz');
try {
// Download .NET runtime
await downloadFile(DOTNET_RUNTIME_URL, tarGzPath);
console.log('Download completed');
// Extract .NET runtime to a temporary directory first
const tempExtractDir = path.join(DOTNET_RUNTIME_DIR, 'temp');
if (!fs.existsSync(tempExtractDir)) {
fs.mkdirSync(tempExtractDir, { recursive: true });
}
console.log('Extracting .NET runtime...');
await extractTarGz(tarGzPath, tempExtractDir);
console.log('Extraction completed');
// Clean up tar.gz file
fs.unlinkSync(tarGzPath);
console.log('Cleanup completed');
// Move dotnet executable to bin directory
// Ensure the dotnet executable is executable
const extractedDotnet = path.join(tempExtractDir, 'dotnet');
const targetDotnet = path.join(DOTNET_BIN_DIR, 'dotnet');
if (fs.existsSync(extractedDotnet)) {
fs.renameSync(extractedDotnet, targetDotnet);
fs.chmodSync(targetDotnet, 0o755);
console.log('Moved dotnet executable to bin directory');
}
fs.chmodSync(extractedDotnet, 0o755);
// Move all other files to the root of dotnet-runtime
const files = fs.readdirSync(tempExtractDir);
for (const file of files) {
const sourcePath = path.join(tempExtractDir, file);
const targetPath = path.join(DOTNET_RUNTIME_DIR, file);
if (fs.existsSync(targetPath)) {
if (fs.lstatSync(sourcePath).isDirectory()) {
// Remove existing directory and move new one
@@ -96,16 +109,16 @@ async function main() {
fs.unlinkSync(targetPath);
}
}
fs.renameSync(sourcePath, targetPath);
}
// Clean up temp directory
fs.rmSync(tempExtractDir, { recursive: true, force: true });
console.log(`.NET runtime downloaded and extracted to: ${DOTNET_RUNTIME_DIR}`);
console.log(`dotnet executable available at: ${targetDotnet}`);
console.log(
`.NET runtime downloaded and extracted to: ${DOTNET_RUNTIME_DIR}`
);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
@@ -116,4 +129,4 @@ if (require.main === module) {
main();
}
module.exports = { downloadFile, extractTarGz };
module.exports = { downloadFile, extractTarGz };

View File

@@ -7,19 +7,35 @@ const {
Tray,
Menu,
dialog,
Notification
Notification,
nativeImage
} = require('electron');
const { spawn, spawnSync } = require('child_process');
const fs = require('fs');
const https = require('https');
// Include bundled .NET runtime
const bundledDotNetPath = path.join(process.resourcesPath, 'dotnet-runtime');
const bundledDotnet = path.join(bundledDotNetPath, 'bin', 'dotnet');
//app.disableHardwareAcceleration();
if (fs.existsSync(bundledDotnet)) {
process.env.DOTNET_ROOT = bundledDotNetPath;
process.env.PATH = `${path.dirname(bundledDotnet)}:${process.env.PATH}`;
if (process.platform === 'linux') {
// Include bundled .NET runtime
const bundledDotNetPath = path.join(
process.resourcesPath,
'dotnet-runtime'
);
if (fs.existsSync(bundledDotNetPath)) {
process.env.DOTNET_ROOT = bundledDotNetPath;
process.env.PATH = `${bundledDotNetPath}:${process.env.PATH}`;
}
} else if (process.platform === 'darwin') {
const dotnetPath = path.join('/usr/local/share/dotnet');
const dotnetPathArm = path.join('/usr/local/share/dotnet/x64');
if (fs.existsSync(dotnetPathArm)) {
process.env.DOTNET_ROOT = dotnetPathArm;
process.env.PATH = `${dotnetPathArm}:${process.env.PATH}`;
} else if (fs.existsSync(dotnetPath)) {
process.env.DOTNET_ROOT = dotnetPath;
process.env.PATH = `${dotnetPath}:${process.env.PATH}`;
}
}
if (!isDotNetInstalled()) {
@@ -30,9 +46,11 @@ if (!isDotNetInstalled()) {
);
app.quit();
});
return;
}
let isOverlayActive = false;
let appIsQuitting = false;
// Get launch arguments
let appImagePath = process.env.APPIMAGE;
const args = process.argv.slice(1);
@@ -51,6 +69,24 @@ require(path.join(rootDir, 'build/Electron/VRCX-Electron.cjs'));
const InteropApi = require('./InteropApi');
const interopApi = new InteropApi();
const WRIST_FRAME_WIDTH = 512;
const WRIST_FRAME_HEIGHT = 512;
const WRIST_FRAME_SIZE = WRIST_FRAME_WIDTH * WRIST_FRAME_HEIGHT * 4;
const WRIST_SHM_PATH = '/dev/shm/vrcx_wrist_overlay';
function createWristOverlayWindowShm() {
fs.writeFileSync(WRIST_SHM_PATH, Buffer.alloc(WRIST_FRAME_SIZE + 1));
}
const HMD_FRAME_WIDTH = 1024;
const HMD_FRAME_HEIGHT = 1024;
const HMD_FRAME_SIZE = HMD_FRAME_WIDTH * HMD_FRAME_HEIGHT * 4;
const HMD_SHM_PATH = '/dev/shm/vrcx_hmd_overlay';
function createHmdOverlayWindowShm() {
fs.writeFileSync(HMD_SHM_PATH, Buffer.alloc(HMD_FRAME_SIZE + 1));
}
const version = getVersion();
interopApi.getDotNetObject('ProgramElectron').PreInit(version, args);
interopApi.getDotNetObject('VRCXStorage').Load();
@@ -61,6 +97,10 @@ interopApi.getDotNetObject('Discord').Init();
interopApi.getDotNetObject('WebApi').Init();
interopApi.getDotNetObject('LogWatcher').Init();
interopApi.getDotNetObject('IPCServer').Init();
interopApi.getDotNetObject('SystemMonitorElectron').Init();
interopApi.getDotNetObject('AppApiVrElectron').Init();
ipcMain.handle('callDotNetMethod', (event, className, methodName, args) => {
return interopApi.callMethod(className, methodName, args);
});
@@ -142,6 +182,45 @@ ipcMain.handle('app:restart', () => {
}
});
ipcMain.handle('app:getWristOverlayWindow', () => {
if (wristOverlayWindow && wristOverlayWindow.webContents) {
return !wristOverlayWindow.webContents.isLoading() &&
wristOverlayWindow.webContents.isPainting();
}
return false;
});
ipcMain.handle('app:getHmdOverlayWindow', () => {
if (hmdOverlayWindow && hmdOverlayWindow.webContents) {
return !hmdOverlayWindow.webContents.isLoading() &&
hmdOverlayWindow.webContents.isPainting();
}
return false;
});
ipcMain.handle(
'app:updateVr',
(event, active, hmdOverlay, wristOverlay, menuButton, overlayHand) => {
if (!active) {
disposeOverlay();
return;
}
isOverlayActive = true;
if (!hmdOverlay) {
destroyHmdOverlayWindow();
} else if (active && !hmdOverlayWindow) {
createHmdOverlayWindowOffscreen();
}
if (!wristOverlay) {
destroyWristOverlayWindow();
} else if (active && !wristOverlayWindow) {
createWristOverlayWindowOffscreen();
}
}
);
function tryRelaunchWithArgs(args) {
if (
process.platform !== 'linux' ||
@@ -188,14 +267,11 @@ function createWindow() {
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
},
webContents: {
userAgent: version
}
});
applyWindowState();
const indexPath = path.join(rootDir, 'build/html/index.html');
mainWindow.loadFile(indexPath, { userAgent: version });
mainWindow.loadFile(indexPath);
// add proxy config, doesn't work, thanks electron
// const proxy = VRCXStorage.Get('VRCX_Proxy');
@@ -234,7 +310,7 @@ function createWindow() {
mainWindow.on('close', (event) => {
isCloseToTray = VRCXStorage.Get('VRCX_CloseToTray') === 'true';
if (isCloseToTray && !app.isQuitting) {
if (isCloseToTray && !appIsQuitting) {
event.preventDefault();
mainWindow.hide();
}
@@ -271,8 +347,142 @@ function createWindow() {
});
}
let wristOverlayWindow = undefined;
function createWristOverlayWindowOffscreen() {
if (!fs.existsSync(WRIST_SHM_PATH)) {
createWristOverlayWindowShm();
}
const x = parseInt(VRCXStorage.Get('VRCX_LocationX')) || 0;
const y = parseInt(VRCXStorage.Get('VRCX_LocationY')) || 0;
const width = WRIST_FRAME_WIDTH;
const height = WRIST_FRAME_HEIGHT;
wristOverlayWindow = new BrowserWindow({
x,
y,
width,
height,
icon: path.join(rootDir, 'VRCX.png'),
autoHideMenuBar: true,
transparent: true,
frame: false,
show: false,
webPreferences: {
offscreen: true,
preload: path.join(__dirname, 'preload.js')
}
});
wristOverlayWindow.webContents.setFrameRate(2);
const indexPath = path.join(rootDir, 'build/html/vr.html');
const fileUrl = `file://${indexPath}?1`;
wristOverlayWindow.loadURL(fileUrl, { userAgent: version });
// Use paint event for offscreen rendering
wristOverlayWindow.webContents.on('paint', (event, dirty, image) => {
const buffer = image.toBitmap();
//console.log('Captured wrist frame via paint event, size:', buffer.length);
writeWristFrame(buffer);
});
}
function writeWristFrame(imageBuffer) {
try {
const fd = fs.openSync(WRIST_SHM_PATH, 'r+');
const buffer = Buffer.alloc(WRIST_FRAME_SIZE + 1);
buffer[0] = 0; // not ready
imageBuffer.copy(buffer, 1, 0, WRIST_FRAME_SIZE);
buffer[0] = 1; // ready
fs.writeSync(fd, buffer);
fs.closeSync(fd);
//console.log('Wrote wrist frame to shared memory');
} catch (err) {
console.error('Error writing wrist frame to shared memory:', err);
}
}
function destroyWristOverlayWindow() {
if (wristOverlayWindow && !wristOverlayWindow.isDestroyed()) {
wristOverlayWindow.close();
}
wristOverlayWindow = undefined;
}
let hmdOverlayWindow = undefined;
function createHmdOverlayWindowOffscreen() {
if (!fs.existsSync(HMD_SHM_PATH)) {
createHmdOverlayWindowShm();
}
const x = parseInt(VRCXStorage.Get('VRCX_LocationX')) || 0;
const y = parseInt(VRCXStorage.Get('VRCX_LocationY')) || 0;
const width = HMD_FRAME_WIDTH;
const height = HMD_FRAME_HEIGHT;
hmdOverlayWindow = new BrowserWindow({
x,
y,
width,
height,
icon: path.join(rootDir, 'VRCX.png'),
autoHideMenuBar: true,
transparent: true,
frame: false,
show: false,
webPreferences: {
offscreen: true,
preload: path.join(__dirname, 'preload.js')
}
});
hmdOverlayWindow.webContents.setFrameRate(48);
const indexPath = path.join(rootDir, 'build/html/vr.html');
const fileUrl = `file://${indexPath}?2`;
hmdOverlayWindow.loadURL(fileUrl, { userAgent: version });
// Use paint event for offscreen rendering
hmdOverlayWindow.webContents.on('paint', (event, dirty, image) => {
const buffer = image.toBitmap();
//console.log('Captured HMD frame via paint event, size:', buffer.length);
writeHmdFrame(buffer);
});
}
function writeHmdFrame(imageBuffer) {
try {
const fd = fs.openSync(HMD_SHM_PATH, 'r+');
const buffer = Buffer.alloc(HMD_FRAME_SIZE + 1);
buffer[0] = 0; // not ready
imageBuffer.copy(buffer, 1, 0, HMD_FRAME_SIZE);
buffer[0] = 1; // ready
fs.writeSync(fd, buffer);
fs.closeSync(fd);
//console.log('Wrote HMD frame to shared memory');
} catch (err) {
console.error('Error writing HMD frame to shared memory:', err);
}
}
function destroyHmdOverlayWindow() {
if (hmdOverlayWindow && !hmdOverlayWindow.isDestroyed()) {
hmdOverlayWindow.close();
}
hmdOverlayWindow = undefined;
}
function createTray() {
const tray = new Tray(path.join(rootDir, 'images/tray.png'));
let tray = null;
if (process.platform === 'darwin') {
const image = nativeImage.createFromPath(
path.join(rootDir, 'images/tray.png')
);
tray = new Tray(image.resize({ width: 16, height: 16 }));
} else {
tray = new Tray(path.join(rootDir, 'images/tray.png'));
}
const contextMenu = Menu.buildFromTemplate([
{
label: 'Open',
@@ -296,7 +506,7 @@ function createTray() {
label: 'Quit VRCX',
type: 'normal',
click: function () {
app.isQuitting = true;
appIsQuitting = true;
app.quit();
}
}
@@ -507,18 +717,19 @@ function getHomePath() {
const absoluteHomePath = fs.realpathSync(relativeHomePath);
return absoluteHomePath;
} catch (err) {
console.error('Error resolving absolute home path:', err);
return relativeHomePath;
}
}
function getVersion() {
try {
var versionFile = fs
const versionFile = fs
.readFileSync(path.join(rootDir, 'Version'), 'utf8')
.trim();
// look for trailing git hash "-22bcd96" to indicate nightly build
var version = versionFile.split('-');
const version = versionFile.split('-');
console.log('Version:', versionFile);
if (version.length > 0 && version[version.length - 1].length == 7) {
return `VRCX (Linux) Nightly ${versionFile}`;
@@ -532,19 +743,15 @@ function getVersion() {
}
function isDotNetInstalled() {
if (process.platform === 'darwin') {
// Assume .NET is already installed on macOS
return true;
let dotnetPath = path.join(process.env.DOTNET_ROOT, 'dotnet');
if (!process.env.DOTNET_ROOT || !fs.existsSync(dotnetPath)) {
// fallback to command
dotnetPath = 'dotnet';
}
// Check for bundled .NET runtime
if (fs.existsSync(bundledDotnet)) {
console.log('Using bundled .NET runtime at:', bundledDotNetPath);
return true;
}
console.log('Checking for .NET installation at:', dotnetPath);
// Fallback to system .NET runtime
const result = spawnSync('dotnet', ['--list-runtimes'], {
const result = spawnSync(dotnetPath, ['--list-runtimes'], {
encoding: 'utf-8'
});
return result.stdout?.includes('.NETCore.App 9.0');
@@ -611,20 +818,54 @@ function applyWindowState() {
app.whenReady().then(() => {
createWindow();
createTray();
if (process.platform === 'linux') {
createWristOverlayWindowOffscreen();
createHmdOverlayWindowOffscreen();
}
installVRCX();
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// app.on('before-quit', function () {
// mainWindow.webContents.send('windowClosed');
// });
function disposeOverlay() {
if (!isOverlayActive) {
return;
}
isOverlayActive = false;
if (wristOverlayWindow) {
wristOverlayWindow.close();
wristOverlayWindow = undefined;
}
if (hmdOverlayWindow) {
hmdOverlayWindow.close();
hmdOverlayWindow = undefined;
}
if (fs.existsSync(WRIST_SHM_PATH)) {
fs.unlinkSync(WRIST_SHM_PATH);
}
if (fs.existsSync(HMD_SHM_PATH)) {
fs.unlinkSync(HMD_SHM_PATH);
}
}
app.on('before-quit', function () {
disposeOverlay();
mainWindow.webContents.send('windowClosed');
});
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit();
disposeOverlay();
if (process.platform !== 'darwin') {
app.quit();
}
});

View File

@@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
onUpdateImage: (callback) => ipcRenderer.on('update-image', (event, base64) => callback(base64))
});

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Offscreen Mirror</title>
<style>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: black;
}
#mirror {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
</head>
<body>
<img id="mirror" src="" alt="Main Window Mirror">
<script>
window.electronAPI.onUpdateImage((base64) => {
document.getElementById('mirror').src = `data:image/png;base64,${base64}`;
});
</script>
</body>
</html>

View File

@@ -31,11 +31,17 @@ managedHostPath = managedHostPath.indexOf('app.asar.unpacked') < 0 ?
}
// Paths to patch
let platformName = 'linux';
let platformName = '';
switch (process.platform) {
case 'win32':
platformName = 'win';
break;
case 'darwin':
platformName = 'mac';
break;
case 'linux':
platformName = 'linux';
break;
}
const postBuildPath = path.join(
__dirname,

View File

@@ -30,5 +30,9 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.on('setWindowState', callback),
desktopNotification: (title, body, icon) =>
ipcRenderer.invoke('notification:showNotification', title, body, icon),
restartApp: () => ipcRenderer.invoke('app:restart')
});
restartApp: () => ipcRenderer.invoke('app:restart'),
getWristOverlayWindow: () => ipcRenderer.invoke('app:getWristOverlayWindow'),
getHmdOverlayWindow: () => ipcRenderer.invoke('app:getHmdOverlayWindow'),
updateVr: (active, hmdOverlay, wristOverlay, menuButton, overlayHand) =>
ipcRenderer.invoke('app:updateVr', active, hmdOverlay, wristOverlay, menuButton, overlayHand)
});

View File

@@ -17,17 +17,34 @@ try {
process.exit(1);
}
const oldAppImage = path.join(buildDir, `VRCX_Version.AppImage`);
const newAppImage = path.join(buildDir, `VRCX_${version}.AppImage`);
try {
if (fs.existsSync(oldAppImage)) {
fs.renameSync(oldAppImage, newAppImage);
console.log(`Renamed: ${oldAppImage} -> ${newAppImage}`);
} else {
console.log(`File not found: ${oldAppImage}`);
if (process.platform === 'linux') {
const oldAppImage = path.join(buildDir, `VRCX_Version.AppImage`);
const newAppImage = path.join(buildDir, `VRCX_${version}.AppImage`);
try {
if (fs.existsSync(oldAppImage)) {
fs.renameSync(oldAppImage, newAppImage);
console.log(`Renamed: ${oldAppImage} -> ${newAppImage}`);
} else {
console.log(`File not found: ${oldAppImage}`);
}
} catch (err) {
console.error('Error renaming files:', err);
process.exit(1);
}
} catch (err) {
console.error('Error renaming files:', err);
process.exit(1);
} else if (process.platform === 'darwin') {
const oldDmg = path.join(buildDir, `VRCX_Version.dmg`);
const newDmg = path.join(buildDir, `VRCX_${version}.dmg`);
try {
if (fs.existsSync(oldDmg)) {
fs.renameSync(oldDmg, newDmg);
console.log(`Renamed: ${oldDmg} -> ${newDmg}`);
} else {
console.log(`File not found: ${oldDmg}`);
}
} catch (err) {
console.error('Error renaming files:', err);
process.exit(1);
}
} else {
console.log('No renaming needed for this platform.');
}