Files
VRCX/src/stores/vrcx.js
rs189 a2dc6ba9a4 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>
2025-07-19 12:07:43 +12:00

783 lines
28 KiB
JavaScript

import { defineStore } from 'pinia';
import { computed, reactive, watch } from 'vue';
import { worldRequest } from '../api';
import { $app } from '../app';
import configRepository from '../service/config';
import { database } from '../service/database';
import { AppGlobal } from '../service/appConfig';
import { failedGetRequests } from '../service/request';
import { watchState } from '../service/watchState';
import {
debounce,
parseLocation,
refreshCustomCss,
removeFromArray
} from '../shared/utils';
import { useAvatarStore } from './avatar';
import { useAvatarProviderStore } from './avatarProvider';
import { useFavoriteStore } from './favorite';
import { useFriendStore } from './friend';
import { useGameStore } from './game';
import { useGameLogStore } from './gameLog';
import { useGroupStore } from './group';
import { useInstanceStore } from './instance';
import { useLocationStore } from './location';
import { useNotificationStore } from './notification';
import { usePhotonStore } from './photon';
import { useSearchStore } from './search';
import { useAdvancedSettingsStore } from './settings/advanced';
import { useUpdateLoopStore } from './updateLoop';
import { useUserStore } from './user';
import { useWorldStore } from './world';
import { useI18n } from 'vue-i18n-bridge';
import Noty from 'noty';
export const useVrcxStore = defineStore('Vrcx', () => {
const gameStore = useGameStore();
const locationStore = useLocationStore();
const notificationStore = useNotificationStore();
const avatarStore = useAvatarStore();
const worldStore = useWorldStore();
const instanceStore = useInstanceStore();
const friendStore = useFriendStore();
const favoriteStore = useFavoriteStore();
const groupStore = useGroupStore();
const userStore = useUserStore();
const photonStore = usePhotonStore();
const advancedSettingsStore = useAdvancedSettingsStore();
const searchStore = useSearchStore();
const avatarProviderStore = useAvatarProviderStore();
const gameLogStore = useGameLogStore();
const updateLoopStore = useUpdateLoopStore();
const { t } = useI18n();
const state = reactive({
isRunningUnderWine: false,
databaseVersion: 0,
clearVRCXCacheFrequency: 172800,
proxyServer: '',
locationX: 0,
locationY: 0,
sizeWidth: 800,
sizeHeight: 600,
windowState: '',
maxTableSize: 1000,
ipcEnabled: false,
externalNotifierVersion: 0,
currentlyDroppingFile: null,
isRegistryBackupDialogVisible: false
});
async function init() {
if (LINUX) {
window.electron.onWindowPositionChanged((event, position) => {
state.locationX = position.x;
state.locationY = position.y;
debounce(saveVRCXWindowOption(), 300);
});
window.electron.onWindowSizeChanged((event, size) => {
state.sizeWidth = size.width;
state.sizeHeight = size.height;
debounce(saveVRCXWindowOption(), 300);
});
window.electron.onWindowStateChange((event, state) => {
state.windowState = state;
debounce(saveVRCXWindowOption(), 300);
});
// window.electron.onWindowClosed((event) => {
// window.$app.saveVRCXWindowOption();
// });
}
state.databaseVersion = await configRepository.getInt(
'VRCX_databaseVersion',
0
);
state.clearVRCXCacheFrequency = await configRepository.getInt(
'VRCX_clearVRCXCacheFrequency',
172800
);
if (!(await VRCXStorage.Get('VRCX_DatabaseLocation'))) {
await VRCXStorage.Set('VRCX_DatabaseLocation', '');
}
if (!(await VRCXStorage.Get('VRCX_ProxyServer'))) {
await VRCXStorage.Set('VRCX_ProxyServer', '');
}
if ((await VRCXStorage.Get('VRCX_DisableGpuAcceleration')) === '') {
await VRCXStorage.Set('VRCX_DisableGpuAcceleration', 'false');
}
if (
(await VRCXStorage.Get('VRCX_DisableVrOverlayGpuAcceleration')) ===
''
) {
await VRCXStorage.Set(
'VRCX_DisableVrOverlayGpuAcceleration',
'false'
);
}
state.proxyServer = await VRCXStorage.Get('VRCX_ProxyServer');
state.locationX = await VRCXStorage.Get('VRCX_LocationX');
state.locationY = await VRCXStorage.Get('VRCX_LocationY');
state.sizeWidth = await VRCXStorage.Get('VRCX_SizeWidth');
state.sizeHeight = await VRCXStorage.Get('VRCX_SizeHeight');
state.windowState = await VRCXStorage.Get('VRCX_WindowState');
state.maxTableSize = await configRepository.getInt(
'VRCX_maxTableSize',
1000
);
if (state.maxTableSize > 10000) {
state.maxTableSize = 1000;
}
database.setMaxTableSize(state.maxTableSize);
}
init();
const currentlyDroppingFile = computed({
get: () => state.currentlyDroppingFile,
set: (value) => {
state.currentlyDroppingFile = value;
}
});
const isRegistryBackupDialogVisible = computed({
get: () => state.isRegistryBackupDialogVisible,
set: (value) => {
state.isRegistryBackupDialogVisible = value;
}
});
const ipcEnabled = computed({
get: () => state.ipcEnabled,
set: (value) => {
state.ipcEnabled = value;
}
});
const clearVRCXCacheFrequency = computed({
get: () => state.clearVRCXCacheFrequency,
set: (value) => {
state.clearVRCXCacheFrequency = value;
}
});
const maxTableSize = computed({
get: () => state.maxTableSize,
set: (value) => {
state.maxTableSize = value;
}
});
// Make sure file drops outside of the screenshot manager don't navigate to the file path dropped.
// This issue persists on prompts created with prompt(), unfortunately. Not sure how to fix that.
document.body.addEventListener('drop', function (e) {
e.preventDefault();
});
document.addEventListener('keyup', function (e) {
if (e.ctrlKey) {
if (e.key === 'I') {
showConsole();
} else if (e.key === 'r') {
location.reload();
}
} else if (e.altKey && e.key === 'R') {
refreshCustomCss();
}
});
const isRunningUnderWine = computed({
get: () => state.isRunningUnderWine,
set: (value) => {
state.isRunningUnderWine = value;
}
});
async function applyWineEmojis() {
if (document.contains(document.getElementById('app-emoji-font'))) {
document.getElementById('app-emoji-font').remove();
}
if (gameStore.isRunningUnderWine) {
const $appEmojiFont = document.createElement('link');
$appEmojiFont.setAttribute('id', 'app-emoji-font');
$appEmojiFont.rel = 'stylesheet';
$appEmojiFont.href = 'emoji.font.css';
document.head.appendChild($appEmojiFont);
}
}
function showConsole() {
AppApi.ShowDevTools();
if (
AppGlobal.debug ||
AppGlobal.debugWebRequests ||
AppGlobal.debugWebSocket ||
AppGlobal.debugUserDiff
) {
return;
}
console.log(
'%cCareful! This might not do what you think.',
'background-color: red; color: yellow; font-size: 32px; font-weight: bold'
);
console.log(
'%cIf someone told you to copy-paste something here, it can give them access to your account.',
'font-size: 20px;'
);
}
async function updateDatabaseVersion() {
// requires dbVars.userPrefix to be already set
const databaseVersion = 12;
let msgBox;
if (state.databaseVersion < databaseVersion) {
if (state.databaseVersion) {
msgBox = $app.$message({
message:
'DO NOT CLOSE VRCX, database upgrade in progress...',
type: 'warning',
duration: 0
});
}
console.log(
`Updating database from ${state.databaseVersion} to ${databaseVersion}...`
);
try {
await database.cleanLegendFromFriendLog(); // fix friendLog spammed with crap
await database.fixGameLogTraveling(); // fix bug with gameLog location being set as traveling
await database.fixNegativeGPS(); // fix GPS being a negative value due to VRCX bug with traveling
await database.fixBrokenLeaveEntries(); // fix user instance timer being higher than current user location timer
await database.fixBrokenGroupInvites(); // fix notification v2 in wrong table
await database.fixBrokenNotifications(); // fix notifications being null
await database.fixBrokenGroupChange(); // fix spam group left & name change
await database.fixCancelFriendRequestTypo(); // fix CancelFriendRequst typo
await database.fixBrokenGameLogDisplayNames(); // fix gameLog display names "DisplayName (userId)"
await database.upgradeDatabaseVersion(); // update database version
await database.vacuum(); // succ
await database.optimize();
await configRepository.setInt(
'VRCX_databaseVersion',
databaseVersion
);
console.log('Database update complete.');
msgBox?.close();
if (state.databaseVersion) {
// only display when database exists
$app.$message({
message: 'Database upgrade complete',
type: 'success'
});
}
state.databaseVersion = databaseVersion;
} catch (err) {
console.error(err);
msgBox?.close();
$app.$message({
message:
'Database upgrade failed, check console for details',
type: 'error',
duration: 120000
});
AppApi.ShowDevTools();
}
}
}
function clearVRCXCache() {
failedGetRequests.clear();
userStore.cachedUsers.forEach((ref, id) => {
if (
!friendStore.friends.has(id) &&
!locationStore.lastLocation.playerList.has(ref.id) &&
id !== userStore.currentUser.id
) {
userStore.cachedUsers.delete(id);
}
});
worldStore.cachedWorlds.forEach((ref, id) => {
if (
!favoriteStore.cachedFavoritesByObjectId.has(id) &&
ref.authorId !== userStore.currentUser.id &&
!favoriteStore.localWorldFavoritesList.includes(id)
) {
worldStore.cachedWorlds.delete(id);
}
});
avatarStore.cachedAvatars.forEach((ref, id) => {
if (
!favoriteStore.cachedFavoritesByObjectId.has(id) &&
ref.authorId !== userStore.currentUser.id &&
!favoriteStore.localAvatarFavoritesList.includes(id) &&
!avatarStore.avatarHistory.has(id)
) {
avatarStore.cachedAvatars.delete(id);
}
});
groupStore.cachedGroups.forEach((ref, id) => {
if (!groupStore.currentUserGroups.has(id)) {
groupStore.cachedGroups.delete(id);
}
});
instanceStore.cachedInstances.forEach((ref, id) => {
// delete instances over an hour old
if (Date.parse(ref.$fetchedAt) < Date.now() - 3600000) {
instanceStore.cachedInstances.delete(id);
}
});
avatarStore.cachedAvatarNames = new Map();
userStore.customUserTags = new Map();
}
function eventVrcxMessage(data) {
let entry;
switch (data.MsgType) {
case 'CustomTag':
userStore.addCustomTag(data);
break;
case 'ClearCustomTags':
userStore.customUserTags.forEach((value, key) => {
userStore.customUserTags.delete(key);
const ref = userStore.cachedUsers.get(key);
if (typeof ref !== 'undefined') {
ref.$customTag = '';
ref.$customTagColour = '';
}
});
break;
case 'Noty':
if (
photonStore.photonLoggingEnabled ||
(state.externalNotifierVersion &&
state.externalNotifierVersion > 21)
) {
return;
}
entry = {
created_at: new Date().toJSON(),
type: 'Event',
data: data.Data
};
database.addGamelogEventToDatabase(entry);
notificationStore.queueGameLogNoty(entry);
gameLogStore.addGameLog(entry);
break;
case 'External': {
const displayName = data.DisplayName ?? '';
entry = {
created_at: new Date().toJSON(),
type: 'External',
message: data.Data,
displayName,
userId: data.UserId,
location: locationStore.lastLocation.location
};
database.addGamelogExternalToDatabase(entry);
notificationStore.queueGameLogNoty(entry);
gameLogStore.addGameLog(entry);
break;
}
default:
console.log('VRCXMessage:', data);
break;
}
}
async function saveVRCXWindowOption() {
if (LINUX) {
VRCXStorage.Set('VRCX_LocationX', state.locationX);
VRCXStorage.Set('VRCX_LocationY', state.locationY);
VRCXStorage.Set('VRCX_SizeWidth', state.sizeWidth);
VRCXStorage.Set('VRCX_SizeHeight', state.sizeHeight);
VRCXStorage.Set('VRCX_WindowState', state.windowState);
VRCXStorage.Flush();
}
}
async function processScreenshot(path) {
let newPath = path;
if (advancedSettingsStore.screenshotHelper) {
const location = parseLocation(locationStore.lastLocation.location);
const metadata = {
application: 'VRCX',
version: 1,
author: {
id: userStore.currentUser.id,
displayName: userStore.currentUser.displayName
},
world: {
name: locationStore.lastLocation.name,
id: location.worldId,
instanceId: locationStore.lastLocation.location
},
players: []
};
for (const user of locationStore.lastLocation.playerList.values()) {
metadata.players.push({
id: user.userId,
displayName: user.displayName
});
}
newPath = await AppApi.AddScreenshotMetadata(
path,
JSON.stringify(metadata),
location.worldId,
advancedSettingsStore.screenshotHelperModifyFilename
);
console.log('Screenshot metadata added', newPath);
}
if (advancedSettingsStore.screenshotHelperCopyToClipboard) {
await AppApi.CopyImageToClipboard(newPath);
console.log('Screenshot copied to clipboard', newPath);
}
}
// use in C# side
// eslint-disable-next-line no-unused-vars
function ipcEvent(json) {
if (!watchState.isLoggedIn) {
return;
}
let data;
try {
data = JSON.parse(json);
} catch {
console.log(`IPC invalid JSON, ${json}`);
return;
}
switch (data.type) {
case 'OnEvent':
if (!gameStore.isGameRunning) {
console.log('Game closed, skipped event', data);
return;
}
if (AppGlobal.debugPhotonLogging) {
console.log(
'OnEvent',
data.OnEventData.Code,
data.OnEventData
);
}
photonStore.parsePhotonEvent(data.OnEventData, data.dt);
photonStore.photonEventPulse();
break;
case 'OnOperationResponse':
if (!gameStore.isGameRunning) {
console.log('Game closed, skipped event', data);
return;
}
if (AppGlobal.debugPhotonLogging) {
console.log(
'OnOperationResponse',
data.OnOperationResponseData.OperationCode,
data.OnOperationResponseData
);
}
photonStore.parseOperationResponse(
data.OnOperationResponseData,
data.dt
);
photonStore.photonEventPulse();
break;
case 'OnOperationRequest':
if (!gameStore.isGameRunning) {
console.log('Game closed, skipped event', data);
return;
}
if (AppGlobal.debugPhotonLogging) {
console.log(
'OnOperationRequest',
data.OnOperationRequestData.OperationCode,
data.OnOperationRequestData
);
}
break;
case 'VRCEvent':
if (!gameStore.isGameRunning) {
console.log('Game closed, skipped event', data);
return;
}
photonStore.parseVRCEvent(data);
photonStore.photonEventPulse();
break;
case 'Event7List':
photonStore.photonEvent7List.clear();
for (const [id, dt] of Object.entries(data.Event7List)) {
photonStore.photonEvent7List.set(parseInt(id, 10), dt);
}
photonStore.photonLastEvent7List = Date.parse(data.dt);
break;
case 'VrcxMessage':
if (AppGlobal.debugPhotonLogging) {
console.log('VrcxMessage:', data);
}
eventVrcxMessage(data);
break;
case 'Ping':
if (!photonStore.photonLoggingEnabled) {
photonStore.setPhotonLoggingEnabled();
}
state.ipcEnabled = true;
updateLoopStore.ipcTimeout = 60; // 30secs
break;
case 'MsgPing':
state.externalNotifierVersion = data.version;
break;
case 'LaunchCommand':
eventLaunchCommand(data.command);
break;
case 'VRCXLaunch':
console.log('VRCXLaunch:', data);
break;
default:
console.log('IPC:', data);
}
}
/**
* This function is called by .NET(CefCustomDragHandler#CefCustomDragHandler) when a file is dragged over a drop zone in the app window.
* @param {string} filePath - The full path to the file being dragged into the window
*/
// eslint-disable-next-line no-unused-vars
function dragEnterCef(filePath) {
state.currentlyDroppingFile = filePath;
}
watch(
() => watchState.isLoggedIn,
(isLoggedIn) => {
state.isRegistryBackupDialogVisible = false;
if (isLoggedIn) {
startupLaunchCommand();
}
},
{ flush: 'sync' }
);
async function startupLaunchCommand() {
const command = await AppApi.GetLaunchCommand();
if (command) {
eventLaunchCommand(command);
}
}
function eventLaunchCommand(input) {
if (!watchState.isLoggedIn) {
return;
}
console.log('LaunchCommand:', input);
const args = input.split('/');
const command = args[0];
const commandArg = args[1]?.trim();
let shouldFocusWindow = true;
switch (command) {
case 'world':
searchStore.directAccessWorld(input.replace('world/', ''));
break;
case 'avatar':
avatarStore.showAvatarDialog(commandArg);
break;
case 'user':
userStore.showUserDialog(commandArg);
break;
case 'group':
groupStore.showGroupDialog(commandArg);
break;
case 'local-favorite-world':
console.log('local-favorite-world', commandArg);
const [id, group] = commandArg.split(':');
worldRequest.getCachedWorld({ worldId: id }).then((args1) => {
searchStore.directAccessWorld(id);
favoriteStore.addLocalWorldFavorite(id, group);
return args1;
});
break;
case 'addavatardb':
avatarProviderStore.addAvatarProvider(
input.replace('addavatardb/', '')
);
break;
case 'switchavatar':
const avatarId = commandArg;
const regexAvatarId =
/avtr_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
if (!avatarId.match(regexAvatarId) || avatarId.length !== 41) {
$app.$message({
message: 'Invalid Avatar ID',
type: 'error'
});
break;
}
if (advancedSettingsStore.showConfirmationOnSwitchAvatar) {
avatarStore.selectAvatarWithConfirmation(avatarId);
// Makes sure the window is focused
shouldFocusWindow = true;
} else {
avatarStore
.selectAvatarWithoutConfirmation(avatarId)
.then(() => {
new Noty({
type: 'success',
text: 'Avatar changed via launch command'
}).show();
});
shouldFocusWindow = false;
}
break;
case 'import':
const type = args[1];
if (!type) break;
const data = input.replace(`import/${type}/`, '');
if (type === 'avatar') {
favoriteStore.avatarImportDialogInput = data;
favoriteStore.showAvatarImportDialog();
} else if (type === 'world') {
favoriteStore.worldImportDialogInput = data;
favoriteStore.showWorldImportDialog();
} else if (type === 'friend') {
favoriteStore.friendImportDialogInput = data;
favoriteStore.showFriendImportDialog();
}
break;
}
if (shouldFocusWindow) {
AppApi.FocusWindow();
}
}
async function backupVrcRegistry(name) {
let regJson;
if (WINDOWS) {
regJson = await AppApi.GetVRChatRegistry();
} else {
regJson = await AppApi.GetVRChatRegistryJson();
regJson = JSON.parse(regJson);
}
const newBackup = {
name,
date: new Date().toJSON(),
data: regJson
};
let backupsJson = await configRepository.getString(
'VRCX_VRChatRegistryBackups'
);
if (!backupsJson) {
backupsJson = JSON.stringify([]);
}
const backups = JSON.parse(backupsJson);
backups.push(newBackup);
await configRepository.setString(
'VRCX_VRChatRegistryBackups',
JSON.stringify(backups)
);
// await this.updateRegistryBackupDialog();
}
async function checkAutoBackupRestoreVrcRegistry() {
if (!advancedSettingsStore.vrcRegistryAutoBackup) {
return;
}
// check for auto restore
const hasVRChatRegistryFolder = await AppApi.HasVRChatRegistryFolder();
if (!hasVRChatRegistryFolder) {
const lastBackupDate = await configRepository.getString(
'VRCX_VRChatRegistryLastBackupDate'
);
const lastRestoreCheck = await configRepository.getString(
'VRCX_VRChatRegistryLastRestoreCheck'
);
if (
!lastBackupDate ||
(lastRestoreCheck &&
lastBackupDate &&
lastRestoreCheck === lastBackupDate)
) {
// only ask to restore once and when backup is present
return;
}
// popup message about auto restore
$app.$alert(
t('dialog.registry_backup.restore_prompt'),
t('dialog.registry_backup.header')
);
showRegistryBackupDialog();
await AppApi.FocusWindow();
await configRepository.setString(
'VRCX_VRChatRegistryLastRestoreCheck',
lastBackupDate
);
} else {
await autoBackupVrcRegistry();
}
}
function showRegistryBackupDialog() {
state.isRegistryBackupDialogVisible = true;
}
async function autoBackupVrcRegistry() {
const date = new Date();
const lastBackupDate = await configRepository.getString(
'VRCX_VRChatRegistryLastBackupDate'
);
if (lastBackupDate) {
const lastBackup = new Date(lastBackupDate);
const diff = date.getTime() - lastBackup.getTime();
const diffDays = Math.floor(diff / (1000 * 60 * 60 * 24));
if (diffDays < 7) {
return;
}
}
let backupsJson = await configRepository.getString(
'VRCX_VRChatRegistryBackups'
);
if (!backupsJson) {
backupsJson = JSON.stringify([]);
}
const backups = JSON.parse(backupsJson);
backups.forEach((backup) => {
if (backup.name === 'Auto Backup') {
// remove old auto backup
removeFromArray(backups, backup);
}
});
await configRepository.setString(
'VRCX_VRChatRegistryBackups',
JSON.stringify(backups)
);
backupVrcRegistry('Auto Backup');
await configRepository.setString(
'VRCX_VRChatRegistryLastBackupDate',
date.toJSON()
);
}
return {
state,
isRunningUnderWine,
currentlyDroppingFile,
isRegistryBackupDialogVisible,
ipcEnabled,
clearVRCXCacheFrequency,
maxTableSize,
showConsole,
applyWineEmojis,
clearVRCXCache,
startupLaunchCommand,
eventVrcxMessage,
showRegistryBackupDialog,
checkAutoBackupRestoreVrcRegistry,
processScreenshot,
ipcEvent,
dragEnterCef,
backupVrcRegistry,
updateDatabaseVersion
};
});