Compare commits

...

133 Commits

Author SHA1 Message Date
HannahPadd
bb0423457f Update keybinds form to reject duplicate submissions. 2026-04-01 11:20:12 +02:00
HannahPadd
13f2abb7ab lint 2026-04-01 08:07:35 +02:00
HannahPadd
f938d7e49c Should be done 2026-04-01 07:55:15 +02:00
HannahPadd
cbf59e0ec7 Styling update 2026-03-31 13:17:19 +02:00
HannahPadd
04eb4ce33d Added translations, fixed some modal logic 2026-03-31 10:51:21 +02:00
HannahPadd
9d26032460 It wokie 2026-03-30 17:48:50 +02:00
HannahPadd
cde42aa071 UI is functional now 2026-03-27 17:17:05 +01:00
HannahPadd
13e1265a51 Very big keybinds server refactor 2026-03-27 15:49:19 +01:00
HannahPadd
2ef6a3172d Rename .java to .kt 2026-03-27 15:49:19 +01:00
HannahPadd
934dd3dee2 Large update to keybindrecorder 2026-03-26 17:45:56 +01:00
HannahPadd
9c8cd8517e Update to form and ui. 2026-03-25 23:18:37 +01:00
HannahPadd
ef7e4b1550 Merge remote-tracking branch 'origin/hannah/steam' into hannah/keybinds 2026-03-20 14:10:46 +01:00
HannahPadd
4f84ccb399 Update keybinding logic 2026-03-19 16:42:09 +01:00
Hannah Lynn Lindrob
6c5c358805 Added DbusGlobalShortcutsWayland as a jitpack module 2026-03-18 21:33:44 +01:00
HannahPadd
810c7e5327 Started implementing proof of concept for keybind support on Wayland 2026-03-18 21:17:21 +01:00
HannahPadd
faa3da362e Merge remote-tracking branch 'origin/main' into hannah/keybinds 2026-03-16 18:54:30 +01:00
HannahPadd
22319a5c7e lint 2026-03-16 18:20:37 +01:00
HannahPadd
a83c7ff31e PR feedback 2026-03-16 18:02:38 +01:00
HannahPadd
be722624f1 PR feedback and small logging update. 2026-03-16 15:20:10 +01:00
HannahPadd
3fbe64b0c3 PR feedback 2026-03-16 14:24:56 +01:00
HannahPadd
1b299c499d Update to Linux socket locations 2026-03-16 12:47:25 +01:00
HannahPadd
3c945946e7 Update unix socket directory to be /tmp/ instead of user home. 2026-03-13 13:51:00 +01:00
HannahPadd
6c9341f333 Update udevadm to account for running in pressure vessel 2026-03-12 18:00:22 +01:00
HannahPadd
4409050359 Moved the installation of the usb drivers to steam the install script. 2026-03-12 16:11:26 +01:00
Hannah Lynn Lindrob
8580e9a18b PR feedback and fix broken merge 2026-03-11 20:34:30 +01:00
Hannah Lynn Lindrob
77e0dab795 Remove no-udev command flag when on Windows 2026-03-11 13:09:59 +01:00
Hannah Lynn Lindrob
6bbf325b47 Set skipCheckUdev to true if not on linux. 2026-03-11 12:57:02 +01:00
HannahPadd
db0b3d72b2 revert 2026-03-11 00:06:40 +01:00
HannahPadd
b55448c421 revert 2026-03-11 00:05:05 +01:00
HannahPadd
9bf559ab97 PR feedback 2026-03-11 00:02:03 +01:00
HannahPadd
e79dfd514c Pr feedback
lint
2026-03-10 23:42:34 +01:00
HannahPadd
8e98a2bab6 Merge remote-tracking branch 'origin/main' into hannah/steam 2026-03-10 23:40:22 +01:00
HannahPadd
b2aa3ab394 PR feedback 2026-03-10 22:53:55 +01:00
HannahPadd
6da2691f2b revert 2026-03-10 15:29:53 +01:00
HannahPadd
6227c682ee Merge branch 'llelievr/electron' into hannah/steam 2026-03-10 15:22:48 +01:00
HannahPadd
c1a864176f lint 2026-03-10 15:20:02 +01:00
HannahPadd
f5911f01b7 Update FeatureFlags 2026-03-10 15:07:29 +01:00
HannahPadd
daeeac2baa Added cli flags for running installer and udev warning 2026-03-10 13:15:47 +01:00
loucass003
13930a402e Udev rules files for deb | rpm 2026-03-10 09:51:22 +00:00
loucass003
dbc7f57274 fix matrix 2026-03-09 22:28:50 +01:00
loucass003
d4002114ef fix matrix 2026-03-09 22:27:46 +01:00
loucass003
f9653e941a fix matrix 2026-03-09 22:27:03 +01:00
loucass003
d2f4721b8a Reduce amount of builds on prs 2026-03-09 22:25:22 +01:00
loucass003
764b034abe try macos one more time 2026-03-09 21:44:40 +01:00
loucass003
a13e5e9650 try macos one more time 2026-03-09 18:32:53 +01:00
loucass003
a57c7fc1c3 try macos one more time 2026-03-09 18:25:46 +01:00
loucass003
d4c53086dc try macos one more time 2026-03-09 18:19:03 +01:00
loucass003
25a8bd144d better zips 2026-03-09 17:31:24 +01:00
HannahPadd
43544f0c86 Merge remote-tracking branch 'origin/llelievr/electron' into hannah/steam 2026-03-09 17:12:43 +01:00
HannahPadd
bfd5f02ff2 PR feedback, added launch arguments for steam and driver installer 2026-03-09 15:39:52 +01:00
HannahPadd
3172271ddd Updated registry logic in RegEdit.kt
Moved installer registry checks to RegEdit.kt
2026-03-09 14:38:08 +01:00
HannahPadd
a554b46263 Updated registry logic in RegEdit.kt
Moved installer registry checks to RegEdit.kt
2026-03-09 14:26:37 +01:00
loucass003
d52ea42d01 fix archives names 2026-03-09 13:30:56 +01:00
loucass003
903b59201d fix appimage 2026-03-09 13:17:32 +01:00
loucass003
c181d83245 temp enable draft release 2026-03-09 12:57:04 +01:00
loucass003
8ebaf51750 export dir 2026-03-09 12:44:10 +01:00
loucass003
76814e041c mkdir on win 2026-03-09 12:34:04 +01:00
loucass003
4e09bd26fb folders 2026-03-09 12:28:16 +01:00
loucass003
5084f27611 more tests 2026-03-09 12:23:58 +01:00
loucass003
b3932e3b0f Test without macos 2026-03-09 12:07:45 +01:00
loucass003
ffb4ca6f61 ???? 2026-03-09 12:00:59 +01:00
loucass003
28c241d6c5 release test 2026-03-09 11:53:55 +01:00
loucass003
d7735db8bf release test 2026-03-09 11:43:28 +01:00
loucass003
2d3f168b88 release test 2026-03-09 11:43:01 +01:00
loucass003
ca79d16420 Better? 2026-03-09 11:04:09 +01:00
loucass003
3fe4f8f94d forgot one recursive 2026-03-09 09:58:37 +01:00
loucass003
0b9b2394c0 unified workflow 2026-03-09 09:56:52 +01:00
loucass003
d84cb7f0bf Fix folders 2026-03-09 09:42:51 +01:00
loucass003
2c32fde7d1 Fix typos 2026-03-09 09:39:30 +01:00
loucass003
838fadf523 More archive files 2026-03-09 09:33:13 +01:00
loucass003
01a63a2e28 Pipeline optimization 2026-03-09 09:27:17 +01:00
loucass003
8f73937a52 Small fixes + maybe better ci? 2026-03-09 09:20:00 +01:00
loucass003
c46def9e31 Merge branch 'main' of github.com:SlimeVR/SlimeVR-Server into llelievr/electron 2026-03-09 09:00:19 +01:00
loucass003
3e0297a355 Merge branches 'llelievr/electron' and 'llelievr/electron' of github.com:SlimeVR/SlimeVR-Server into llelievr/electron 2026-03-09 08:59:51 +01:00
Hannah
e087d76781 Apply suggestion from @ImSapphire
Co-authored-by: Sapphire <imsapphire0@gmail.com>
2026-03-05 19:33:56 +01:00
HannahPadd
7b5f526fe6 PR Feedback 2026-03-05 19:33:29 +01:00
HannahPadd
51247f23bf Remove udev installation since it's now shown to the user in the gui.
Updated the logic to detect if udev rules are installed skip the check when running on SteamOS or NixOS.
Remove the unused ErrorConsentModal.
PR Feedback
2026-03-05 18:19:53 +01:00
HannahPadd
c87e3eaccb lint 2026-03-05 17:01:52 +01:00
HannahPadd
e477258d67 Revert paths.ts 2026-03-05 15:26:45 +01:00
HannahPadd
7132268d58 Moved usb driver install from bat file to powershell oneliner 2026-03-05 15:23:23 +01:00
Hannah
1ee4e79b4e Apply suggestions from code review
Co-authored-by: Sapphire <imsapphire0@gmail.com>
2026-03-05 10:50:05 +01:00
HannahPadd
bbfa45a5c9 Update submodule 2026-03-04 18:16:20 +01:00
HannahPadd
81f50f39c5 I forgor to stage files 2026-03-04 18:15:59 +01:00
HannahPadd
3b40c9ec06 Fixed some variable typing 2026-03-04 17:07:29 +01:00
HannahPadd
bbdf16ee42 moved keybinds branch from personal fork to slime repo 2026-03-04 14:54:21 +01:00
HannahPadd
2a4a72eaf7 PR feedback
Fixed mistake with disabling installer.
Update Windows usb driver install to read log from pnputil.
2026-03-04 10:39:09 +01:00
HannahPadd
6b227f8324 lint 2026-03-03 16:28:23 +01:00
HannahPadd
2247725ba5 revert package.json in solarXR 2026-03-03 16:22:58 +01:00
HannahPadd
be3494cdf8 Applied PR feedback
Added system env variable to disable installer
2026-03-03 16:09:15 +01:00
HannahPadd
9cf0a637d3 Merge remote-tracking branch 'origin/hannah/steam' into hannah/steam 2026-03-03 13:53:24 +01:00
HannahPadd
fd29f41276 update steamvr driver install
Update logging messages
2026-03-03 13:51:56 +01:00
HannahPadd
72d193e5e9 update Logging
Skip Installation of drivers if user is not using the steam version.
2026-03-03 10:54:11 +01:00
HannahPadd
c2f2fc219b Revert ProtobufBridge.kt ProtobufMessages.java SteamVRBridge.kt.
Remove wrong return string from executeShellCommand in RPCInstallInfoHandler.kt
2026-03-02 11:12:18 +01:00
HannahPadd
7e7e67631e Remove duplicate Updater.kt
Added specific Nix and SteamOS configs.
Updated Feeder install function on Linux.
Update Feeder install function on Windows.
Lint.
2026-03-02 10:53:24 +01:00
HannahPadd
a0e8e356bb PR Suggestions 2026-02-27 18:12:47 +01:00
HannahPadd
35955f77df Added modal to show user if udev rules aren't installed. Added function to get path to exe dir inside react. Moved the Error collecting consent modal to a page. 2026-02-27 17:50:58 +01:00
HannahPadd
cd7a58501c Added function to the server to install it's own drivers. 2026-02-27 17:48:11 +01:00
lucas lelievre
9353504b39 Apply suggestions from code review
Co-authored-by: Sapphire <imsapphire0@gmail.com>
2026-02-26 20:15:33 +01:00
loucass003
daf818b2cd Merge branch 'llelievr/electron' of github.com:SlimeVR/SlimeVR-Server into llelievr/electron 2026-02-25 17:24:49 +01:00
Hannah Lindrob
34d35fa6fd Merge remote-tracking branch 'origin/main' into llelievr/electron 2026-02-23 16:56:02 +01:00
loucass003
ca1c453451 Merge branch 'main' of github.com:SlimeVR/SlimeVR-Server into llelievr/electron 2026-02-23 16:55:13 +01:00
loucass003
84806c44db Small fixes 2026-02-20 09:11:22 +01:00
loucass003
24b2c8722b Remove unused assets + formating 2026-02-19 19:01:36 +01:00
loucass003
215228551d remove console logs 2026-02-18 13:23:49 +01:00
loucass003
f765ef0e4a Remove android icons from electron 2026-02-18 13:07:50 +01:00
loucass003
c956e5d1cb Revert pnpm dev to pnpm gui 2026-02-18 13:00:49 +01:00
loucass003
865ffc5543 Put back if in gradle.yml ci 2026-02-18 12:57:32 +01:00
loucass003
25e771f4f5 Moar lint 2026-02-18 12:55:43 +01:00
loucass003
74628d215a Lint 2026-02-18 12:50:54 +01:00
loucass003
f19f5cbe51 Re enable hardware accel 2026-02-18 12:50:12 +01:00
loucass003
711d0cae63 Fix resources 2026-02-18 12:45:58 +01:00
loucass003
60321891b8 Merge branch 'llelievr/electron' of github.com:SlimeVR/SlimeVR-Server into llelievr/electron 2026-02-18 12:45:39 +01:00
loucass003
b89a290ac9 Fix resources folder 2026-02-18 12:45:19 +01:00
lucas lelievre
c4d0fe59fd Update gui/electron/main/paths.ts
Co-authored-by: Sapphire <imsapphire0@gmail.com>
2026-02-18 12:45:07 +01:00
lucas lelievre
58a9d3b6eb Update gui/src/components/commons/SystemFileInput.tsx
Co-authored-by: Sapphire <imsapphire0@gmail.com>
2026-02-18 12:44:54 +01:00
loucass003
a3314fa4f9 icons and extraFiles 2026-02-18 12:23:27 +01:00
loucass003
778e0bde56 New flake 2026-02-18 11:07:48 +01:00
Hannah Lindrob
5a6ef6dee5 Fix issue with jar launching on steam 2026-02-17 13:05:57 +01:00
Hannah Lindrob
ba5adca358 Fix working dir 2026-02-17 11:55:57 +01:00
loucass003
67f1ac7684 Progress 2026-02-17 10:14:07 +01:00
loucass003
636514361d Server launched from electron + discord presence 2026-02-17 10:13:35 +01:00
loucass003
63d36db15e Final CI? 2026-02-16 18:49:07 +01:00
loucass003
9b8073153a Maybe progress 2026-02-16 17:34:13 +01:00
loucass003
7b40b8d315 Progress on workflow 2026-02-16 14:18:00 +01:00
loucass003
0f1a19e04b Progress on workflow 2026-02-16 14:16:49 +01:00
loucass003
d1387508e6 Progress 2026-02-16 14:11:19 +01:00
loucass003
50a2be81f7 Progress 2026-02-14 03:04:40 +01:00
loucass003
29598583a9 Lockfile 2026-02-14 01:38:18 +01:00
loucass003
e83adadf1f There is no way this works. RIGHT?! 2026-02-14 01:37:09 +01:00
loucass003
87699438b4 Progress? 2026-02-13 23:49:16 +01:00
loucass003
d1cf5dee24 MOAR 2026-02-12 20:52:51 +01:00
loucass003
b90f52ad7b WIP 2026-02-12 20:37:50 +01:00
loucass003
af917d7158 WIP 2026-02-12 18:46:54 +01:00
63 changed files with 1691 additions and 584 deletions

View File

@@ -114,9 +114,3 @@ licensed under `GPL-v3`.
## Discord
We use discord *a lot* to coordinate and discuss development. Come join us at
https://discord.gg/SlimeVR!
## Use of AI
We DO NOT accept contributions that are generated with AI (for example, "vibe-coding").
If you do use AI, and you believe your usage of AI is reasonable, you must clearly disclose
how you used AI in your submission.

View File

@@ -1,12 +1,20 @@
import { program } from "commander";
import { Option, program } from "commander";
program
.option('-p --path <path>', 'set launch path')
.option('-p, --path <path>', 'set launch path')
.option('-s, --steam', 'steam mode')
.option('-i, --install', 'run the driver installer')
.option(
'--skip-server-if-running',
'gui will not launch the server if it is already running'
)
.allowUnknownOption();
if (process.platform === "linux") {
const noUdevOption = new Option('--no-udev', 'disable udev warning');
noUdevOption.negate = false;
program.addOption(noUdevOption)
}
program.parse(process.argv);
export const options = program.opts();

View File

@@ -20,6 +20,7 @@ import { getPlatform, handleIpc, isPortAvailable } from './utils';
import {
findServerJar,
findSystemJRE,
getExeFolder,
getGuiDataFolder,
getLogsFolder,
getServerDataFolder,
@@ -177,6 +178,8 @@ handleIpc(IPC_CHANNELS.GET_FOLDER, (e, folder) => {
return getGuiDataFolder();
case 'logs':
return getLogsFolder();
case 'exe':
return getExeFolder();
}
});
@@ -385,8 +388,17 @@ const spawnServer = async () => {
}
logger.info({ javaBin, serverJar }, 'Found Java and server jar');
const serverArgs = ['-Xmx128M', '-jar', serverJar]
const process = spawn(javaBin, ['-Xmx128M', '-jar', serverJar, 'run']);
if (options.steam) serverArgs.push(`--steam`)
if (options.install) serverArgs.push(`--install`)
if (options.noUdev) serverArgs.push(`--no-udev`)
serverArgs.push('run')
const process = spawn(javaBin, serverArgs)
logger.info(`Java start command: ${serverArgs.join(' ')})`)
process.stdout?.on('data', (message) => {
mainWindow?.webContents.send(IPC_CHANNELS.SERVER_STATUS, {

View File

@@ -48,6 +48,10 @@ export const getLogsFolder = () => {
return join(getGuiDataFolder(), 'logs');
};
export const getExeFolder = () => {
return path.dirname(app.getPath('exe'));
}
export const getWindowStateFile = () =>
join(getServerDataFolder(), '.window-state.json');

View File

@@ -35,5 +35,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
openLogsFolder: async () => ipcRenderer.invoke(IPC_CHANNELS.OPEN_FILE, await ipcRenderer.invoke(IPC_CHANNELS.GET_FOLDER, 'logs')),
openFile: (path) => ipcRenderer.invoke(IPC_CHANNELS.OPEN_FILE, path),
ghGet: (req) => ipcRenderer.invoke(IPC_CHANNELS.GH_FETCH, req),
setPresence: (options) => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_PRESENCE, options)
setPresence: (options) => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_PRESENCE, options),
getInstallDir: () => ipcRenderer.invoke(IPC_CHANNELS.GET_FOLDER, 'exe')
} satisfies IElectronAPI);

View File

@@ -55,6 +55,7 @@ export interface IElectronAPI {
openFile: (path: string) => void;
ghGet: <T extends GHGet>(options: T) => Promise<GHReturn[T['type']]>;
setPresence: (options: DiscordPresence) => void;
getInstallDir: () => Promise<string>;
}
declare global {

View File

@@ -41,7 +41,7 @@ export interface IpcInvokeMap {
value?: unknown;
}) => Promise<unknown>;
[IPC_CHANNELS.OPEN_FILE]: (path: string) => void;
[IPC_CHANNELS.GET_FOLDER]: (folder: 'config' | 'logs') => string;
[IPC_CHANNELS.GET_FOLDER]: (folder: 'config' | 'logs' | 'exe') => string;
[IPC_CHANNELS.GH_FETCH]: <T extends GHGet>(
options: T
) => Promise<GHReturn[T['type']]>;

View File

@@ -17,6 +17,7 @@
},
"scripts": {
"start": "vite --force",
"dev:clean": "rm -rf node_modules/.vite && pnpm run gui",
"gui": "electron-vite dev --config electron.vite.config.ts --watch",
"build": "electron-vite build --config electron.vite.config.ts",
"package": "electron-builder",

View File

@@ -603,6 +603,29 @@ settings-stay_aligned-debug-label = Debugging
settings-stay_aligned-debug-description = Please include your settings when reporting problems about Stay Aligned.
settings-stay_aligned-debug-copy-label = Copy settings to clipboard
settings-keybinds = Keybind settings
settings-keybinds_ = ''
settings-keybinds-description = Change keybinds for various shortcuts
keybind_config-keybind_name = Keybind
keybind_config-keybind_value = Combination
keybind_config-keybind_delay = Delay before trigger (s)
settings-keybinds_full-reset = Full Reset
settings-keybinds_yaw-reset = Yaw Reset
settings-keybinds_mounting-reset = Mounting Reset
settings-keybinds_feet-mounting-reset = Feet Mounting Reset
settings-keybinds_pause-tracking = Pause Tracking
settings-keybinds_record-keybind = Click to record
settings-keybinds_now-recording = Recording…
settings-keybinds_reset-button = Reset
settings-keybinds_reset-all-button = Reset all
settings-keybinds-wayland-description = You appear to be using wayland, Please change your shortcuts in your system settings.
settings-keybinds-wayland-open-system-settings-button = Open system settings
settings-sidebar-keybinds = Keybinds
settings-keybinds-recorder-modal-title = Assign keybind for
settings-keybinds-recorder-modal-reset-button = Reset
settings-keybinds-recorder-modal-unbind-button = Unbind
settings-keybinds-recorder-modal-done-button = Done
## FK/Tracking settings
settings-general-fk_settings = Tracking settings
@@ -980,6 +1003,11 @@ onboarding-reset_tutorial-2 = Tap the highlighted tracker { $taps } times to tri
You need to be in a pose like you are skiing as shown in the Automatic Mounting wizard, and you have a 3 second delay (configurable) before it gets triggered.
## Install info
install-info_udev-rules_modal_title = Hardware udev access rules not found
install-info_udev-rules_warning = Access rules via udev are required for serial console access & dongle connection. Paste the following command into your terminal to add the udev rules.
install-info_udev-rules_modal_button = Close
install-info_udev-rules_modal-dont-show-again_checkbox = Don't show again
## Setup start
onboarding-home = Welcome to SlimeVR
onboarding-home-start = Let's get set up!

View File

@@ -17,6 +17,7 @@ import { AutomaticProportionsPage } from './components/onboarding/pages/body-pro
import { ManualProportionsPage } from './components/onboarding/pages/body-proportions/ManualProportions';
import { ConnectTrackersPage } from './components/onboarding/pages/ConnectTracker';
import { HomePage } from './components/onboarding/pages/Home';
import { ErrorCollectingConsentPage } from './components/onboarding/pages/ErrorCollectingConsent';
import { AutomaticMountingPage } from './components/onboarding/pages/mounting/AutomaticMounting';
import { ManualMountingPage } from './components/onboarding/pages/mounting/ManualMounting';
import { TrackersAssignPage } from './components/onboarding/pages/trackers-assign/TrackerAssignment';
@@ -50,9 +51,11 @@ import { StayAlignedSetup } from './components/onboarding/pages/stay-aligned/Sta
import { TrackingChecklistProvider } from './components/tracking-checklist/TrackingChecklistProvider';
import { HomeScreenSettings } from './components/settings/pages/HomeScreenSettings';
import { ChecklistPage } from './components/tracking-checklist/TrackingChecklist';
import { KeybindSettings } from './components/settings/pages/KeybindSettings';
import { ElectronContextC, provideElectron } from './hooks/electron';
import { AppLocalizationProvider } from './i18n/config';
import { openUrl } from './hooks/crossplatform';
import { UdevRulesModal } from './components/onboarding/UdevRulesModal';
export const GH_REPO = 'SlimeVR/SlimeVR-Server';
export const VersionContext = createContext('');
@@ -70,6 +73,7 @@ function Layout() {
<SerialDetectionModal />
<VersionUpdateModal />
<UnknownDeviceModal />
<UdevRulesModal />
<SentryRoutes>
<Route element={<AppLayout />}>
<Route
@@ -137,6 +141,7 @@ function Layout() {
<Route path="interface" element={<InterfaceSettings />} />
<Route path="interface/home" element={<HomeScreenSettings />} />
<Route path="advanced" element={<AdvancedSettings />} />
<Route path="keybinds" element={<KeybindSettings />} />
</Route>
<Route
path="/onboarding"
@@ -147,6 +152,10 @@ function Layout() {
}
>
<Route path="home" element={<HomePage />} />
<Route
path="error-collecting-consent"
element={<ErrorCollectingConsentPage />}
/>
<Route path="wifi-creds" element={<WifiCredsPage />} />
<Route path="connect-trackers" element={<ConnectTrackersPage />} />
<Route path="trackers-assign" element={<TrackersAssignPage />} />

View File

@@ -1,9 +1,10 @@
import { useLayoutEffect } from 'react';
import { useConfig } from './hooks/config';
import { Outlet, useNavigate } from 'react-router-dom';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
export function AppLayout() {
const { config } = useConfig();
const { pathname } = useLocation();
const navigate = useNavigate();
useLayoutEffect(() => {
@@ -28,10 +29,14 @@ export function AppLayout() {
}, [config]);
useLayoutEffect(() => {
if (config && !config.doneOnboarding) {
if (
config &&
!config.doneOnboarding &&
!pathname.startsWith('/onboarding/')
) {
navigate('/onboarding/home');
}
}, [config?.doneOnboarding]);
}, [config]);
return (
<>

View File

@@ -1,60 +0,0 @@
import { Localized, useLocalization } from '@fluent/react';
import { BaseModal } from './commons/BaseModal';
import { Button } from './commons/Button';
import { Typography } from './commons/Typography';
export function ErrorConsentModal({
isOpen = true,
cancel,
accept,
}: {
/**
* Is the parent/sibling component opened?
*/
isOpen: boolean;
/**
* Function to trigger when you still want to close the app
*/
accept: () => void;
/**
* Function to trigger when cancelling app close
*/
cancel?: () => void;
}) {
const { l10n } = useLocalization();
return (
<BaseModal isOpen={isOpen} onRequestClose={cancel} closeable={false}>
<div className="flex flex-col gap-3">
<>
<div className="flex flex-col items-center gap-3 fill-accent-background-20">
<div className="flex flex-col items-center gap-2 max-w-[512px]">
<Typography variant="main-title">
{l10n.getString('error_collection_modal-title')}
</Typography>
<Localized
id={'error_collection_modal-description_v2'}
elems={{
b: <b />,
h1: <span className="text-lg font-bold" />,
}}
>
<Typography
variant="standard"
whitespace="whitespace-pre-line"
/>
</Localized>
</div>
</div>
<Button variant="primary" onClick={accept}>
{l10n.getString('error_collection_modal-confirm')}
</Button>
<Button variant="tertiary" onClick={cancel}>
{l10n.getString('error_collection_modal-cancel')}
</Button>
</>
</div>
</BaseModal>
);
}

View File

@@ -22,7 +22,6 @@ import { GearIcon } from './commons/icon/GearIcon';
import { TrackersStillOnModal } from './TrackersStillOnModal';
import { useConfig } from '@/hooks/config';
import { TrayOrExitModal } from './TrayOrExitModal';
import { ErrorConsentModal } from './ErrorConsentModal';
import { useAtomValue } from 'jotai';
import { connectedIMUTrackersAtom } from '@/store/app-store';
import { useElectron } from '@/hooks/electron';
@@ -72,6 +71,7 @@ export function TopBar({
await saveConfig();
electron.api.close();
};
const tryCloseApp = async (dontTray = false) => {
if (!electron.isElectron) throw 'no electron';
@@ -286,11 +286,6 @@ export function TopBar({
setConnectedTrackerWarning(false);
}}
/>
<ErrorConsentModal
isOpen={config?.errorTracking === null}
accept={() => setConfig({ errorTracking: true })}
cancel={() => setConfig({ errorTracking: false })}
/>
</>
);
}

View File

@@ -0,0 +1,119 @@
import { useState, forwardRef, useRef } from 'react';
import { Typography } from './Typography';
import classNames from 'classnames';
import { useFormContext } from 'react-hook-form';
const excludedKeys = [' ', 'SPACE', 'META'];
const maxKeybindLength = 4;
export const KeybindRecorder = forwardRef<
HTMLInputElement,
{
keys: string[];
onKeysChange: (v: string[]) => void;
error?: string;
}
>(function KeybindRecorder({ keys, onKeysChange, error }) {
const [localKeys, setLocalKeys] = useState<string[]>(keys);
const [isRecording, setIsRecording] = useState(false);
const [oldKeys, setOldKeys] = useState<string[]>([]);
const [invalidSlot, setInvalidSlot] = useState<number | null>(null);
const [errorText, setErrorText] = useState<string>('');
const inputRef = useRef<HTMLInputElement>(null);
const displayKeys = isRecording ? localKeys : keys;
const activeIndex = isRecording ? displayKeys.length : -1;
const displayError = errorText || error;
const { clearErrors } = useFormContext();
const handleKeyDown = (e: React.KeyboardEvent) => {
e.preventDefault();
const key = e.key.toUpperCase();
const errorMsg = excludedKeys.includes(key)
? `Cannot use ${key}!`
: displayKeys.includes(key)
? `${key} is a Duplicate Key!`
: null;
if (errorMsg) {
setErrorText(errorMsg);
setInvalidSlot(activeIndex);
setTimeout(() => {
setInvalidSlot(null);
}, 350);
return;
}
if (displayKeys.length < maxKeybindLength) {
const updatedKeys = [...displayKeys, key];
setLocalKeys(updatedKeys);
onKeysChange(updatedKeys);
if (updatedKeys.length == maxKeybindLength) {
inputRef.current?.blur();
}
}
};
const handleOnBlur = () => {
setIsRecording(false);
if (displayKeys.length < maxKeybindLength - 2 || error) {
onKeysChange(oldKeys);
setLocalKeys(oldKeys);
}
};
const handleOnFocus = () => {
clearErrors('keybinds');
const initialKeys: string[] = [];
setOldKeys(keys);
setLocalKeys(initialKeys);
onKeysChange(initialKeys);
setIsRecording(true);
};
return (
<div className="w-full justify-center items-center flex flex-col gap-2">
<div className="flex gap-2 p-2 items-center rounded-lg relative">
<input
autoFocus
ref={inputRef}
className="opacity-0 absolute cursor-pointer w-full"
onFocus={handleOnFocus}
onBlur={handleOnBlur}
onKeyDown={handleKeyDown}
/>
<div className="flex flex-grow gap-2 justify-center h-full">
{Array.from({ length: maxKeybindLength }).map((_, i) => {
const key = displayKeys[i];
const isActive = isRecording && i === activeIndex;
const isInvalid = invalidSlot === i;
return (
<div key={i} className="flex flex-row">
<div
className={classNames(
'flex p-2 rounded-lg min-w-[50px] min-h-[50px] text-main-title justify-center items-center bg-background-80 mobile:text-sm',
{
'keyslot-invalid ring-2 ring-status-critical': isInvalid,
'keyslot-animate ring-2 ring-accent':
isActive && !isInvalid,
'ring-accent': !isInvalid && !isInvalid,
}
)}
>
{key ?? ''}
</div>
<div className="flex pl-2 text-main-title justify-center items-center mobile:text-sm">
{i < maxKeybindLength - 1 ? '+' : ''}
</div>
</div>
);
})}
</div>
</div>
{displayError && (
<div className="isInvalid keyslot-invalid">
<Typography color="text-status-critical">{`${errorText} ${error}`}</Typography>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,87 @@
import { BaseModal } from './BaseModal';
import { Controller, Control, useFormContext } from 'react-hook-form';
import { KeybindRecorder } from './KeybindRecorder';
import { Typography } from './Typography';
import { Button } from './Button';
import './KeybindRow.scss';
import { useLocalization } from '@fluent/react';
export function KeybindRecorderModal({
id,
control,
name,
isVisisble,
onClose,
onUnbind,
onSubmit,
}: {
id?: string;
control: Control<any>;
name: string;
isVisisble: boolean;
onClose: () => void;
onUnbind: () => void;
onSubmit: () => void;
}) {
const { l10n } = useLocalization();
const keybindlocalization = 'settings-keybinds_' + id;
const {
formState: { errors },
resetField,
handleSubmit,
} = useFormContext();
return (
<BaseModal
isOpen={isVisisble}
onRequestClose={onClose}
appendClasses="w-full max-w-xl"
>
<div className="flex flex-col gap-3 w-full justify-between h-full">
<Typography variant="section-title">
{l10n.getString('settings-keybinds-recorder-modal-title')}{' '}
{l10n.getString(keybindlocalization)}
</Typography>
<Controller
control={control}
name={name}
render={({ field }) => (
<KeybindRecorder
keys={field.value ?? []}
onKeysChange={field.onChange}
ref={field.ref}
error={errors.keybinds?.message as string}
/>
)}
/>
<div className="flex flex-row justify-between w-full">
<div className="flex flex-row justify-start gap-4">
<Button
id="settings-keybinds-recorder-modal-reset-button"
variant="tertiary"
onClick={() => {
resetField(name);
handleSubmit(onSubmit)();
}}
/>
<Button
id="settings-keybinds-recorder-modal-unbind-button"
variant="tertiary"
onClick={() => {
onUnbind();
handleSubmit(onSubmit)();
}}
/>
</div>
<div className="flex flex-row justify-end">
<Button
id="settings-keybinds-recorder-modal-done-button"
variant="primary"
onClick={handleSubmit(onSubmit)}
/>
</div>
</div>
</div>
</BaseModal>
);
}

View File

@@ -0,0 +1,65 @@
.keybind-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
height: auto;
align-items: center;
gap: 10px;
}
@keyframes keyslot {
0%,
100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
@keyframes shake {
0% {
transform: translate(1px, 1px) rotate(0deg);
}
10% {
transform: translate(-1px, -2px) rotate(-1deg);
}
20% {
transform: translate(-3px, 0px) rotate(1deg);
}
30% {
transform: translate(3px, 2px) rotate(0deg);
}
40% {
transform: translate(1px, -1px) rotate(1deg);
}
50% {
transform: translate(-1px, 2px) rotate(-1deg);
}
60% {
transform: translate(-3px, 1px) rotate(0deg);
}
70% {
transform: translate(3px, 1px) rotate(-1deg);
}
80% {
transform: translate(-1px, -1px) rotate(1deg);
}
90% {
transform: translate(1px, 2px) rotate(0deg);
}
100% {
transform: translate(1px, -2px) rotate(-1deg);
}
}
.keyslot-animate {
animation: keyslot 1s ease-in-out infinite;
}
.keyslot-invalid {
animation: shake 0.35s ease;
}

View File

@@ -0,0 +1,83 @@
import { Typography } from './Typography';
import './KeybindRow.scss';
import { useEffect, useState } from 'react';
import { Control, UseFormGetValues } from 'react-hook-form';
import { NumberSelector } from './NumberSelector';
import { useLocaleConfig } from '@/i18n/config';
function KeyBindKeyList({ keybind }: { keybind: string[] }) {
if (keybind.length <= 1) {
return (
<div className="flex h-full text-section-title items-center justifiy-center">
Click to edit keybind
</div>
);
}
return keybind.map((key, i) => {
return (
<div key={i} className="flex flex-row">
<div className="flex flex-wrap p-2 rounded-lg min-w-[50px] text-standard-bold justify-center items-center bg-background-80 mobile:text-sm">
{key ?? ''}
</div>
<div className="flex justify-center items-center text-section-title mobile:text-sm gap-2 pl-3">
{i < keybind.length - 1 ? '+' : ''}
</div>
</div>
);
});
}
export function KeybindsRow({
id,
control,
index,
getValue,
openKeybindRecorderModal,
}: {
id?: string;
control: Control<any>;
index: number;
getValue: UseFormGetValues<any>;
openKeybindRecorderModal: (index: number) => void;
}) {
const [binding, setBinding] = useState<string[]>();
const { currentLocales } = useLocaleConfig();
const secondsFormat = new Intl.NumberFormat(currentLocales, {
style: 'unit',
unit: 'second',
unitDisplay: 'narrow',
maximumFractionDigits: 2,
});
const handleOpenModal = () => {
openKeybindRecorderModal(index);
};
useEffect(() => {
setBinding(getValue(`keybinds.${index}.binding`));
});
return (
<div className="keybind-row bg-background-60 rounded-xl h-full keybinds-small:flex keybinds-small:flex-col keybinds-small:justify-center keybinds-small:items-center p-2">
<label className="text-sm font-medium text-background-10 keybinds-small:flex keybinds-small:py-2 keybinds-small:justify-center keybinds-small:align-middle">
<Typography id={`settings-keybinds_${id}`} />
</label>
<div
className="flex gap-2 h-full items-center rounded-lg bg-background-70 hover:bg-background-50 w-full"
onClick={handleOpenModal}
>
<div className="flex flex-grow gap-2 justify-center p-2 h-[50px]">
{binding != null && <KeyBindKeyList keybind={binding} />}
</div>
</div>
<NumberSelector
control={control}
name={`keybinds.${index}.delay`}
valueLabelFormat={(value) => secondsFormat.format(value)}
min={0}
max={10}
step={0.2}
/>
</div>
);
}

View File

@@ -57,60 +57,62 @@ export function NumberSelector({
<Controller
control={control}
name={name}
render={({ field: { onChange, value } }) => (
<div className="flex flex-col gap-1 w-full">
<Typography bold>{label}</Typography>
<div className="flex gap-5 bg-background-60 p-2 rounded-lg">
<div className="flex gap-1">
{doubleStep !== undefined && (
render={({ field: { onChange, value } }) => {
return (
<div className="flex flex-col gap-1 w-full">
{label?.length != 0 ? <Typography bold>{label}</Typography> : <></>}
<div className="flex gap-5 bg-background-60 p-2 rounded-lg">
<div className="flex gap-1">
{doubleStep !== undefined && (
<Button
variant="tertiary"
rounded
onClick={() => onChange(doubleStepFn(value, false))}
disabled={doubleStepFn(value, false) < min || disabled}
>
{showButtonWithNumber
? decimalFormat.format(-doubleStep)
: '--'}
</Button>
)}
<Button
variant="tertiary"
rounded
onClick={() => onChange(doubleStepFn(value, false))}
disabled={doubleStepFn(value, false) < min || disabled}
onClick={() => onChange(stepFn(value, false))}
disabled={stepFn(value, false) < min || disabled}
>
{showButtonWithNumber
? decimalFormat.format(-doubleStep)
: '--'}
-
</Button>
)}
<Button
variant="tertiary"
rounded
onClick={() => onChange(stepFn(value, false))}
disabled={stepFn(value, false) < min || disabled}
>
-
</Button>
</div>
<div className="flex flex-grow justify-center text-center items-center w-10 text-standard">
{valueLabelFormat ? valueLabelFormat(value) : value}
</div>
<div className="flex gap-1">
<Button
variant="tertiary"
rounded
onClick={() => onChange(stepFn(value, true))}
disabled={stepFn(value, true) > max || disabled}
>
+
</Button>
{doubleStep !== undefined && (
</div>
<div className="flex flex-grow justify-center text-center items-center w-10 text-standard">
{valueLabelFormat ? valueLabelFormat(value) : value}
</div>
<div className="flex gap-1">
<Button
variant="tertiary"
rounded
onClick={() => onChange(doubleStepFn(value, true))}
disabled={doubleStepFn(value, true) > max || disabled}
onClick={() => onChange(stepFn(value, true))}
disabled={stepFn(value, true) > max || disabled}
>
{showButtonWithNumber
? decimalFormat.format(doubleStep)
: '++'}
+
</Button>
)}
{doubleStep !== undefined && (
<Button
variant="tertiary"
rounded
onClick={() => onChange(doubleStepFn(value, true))}
disabled={doubleStepFn(value, true) > max || disabled}
>
{showButtonWithNumber
? decimalFormat.format(doubleStep)
: '++'}
</Button>
)}
</div>
</div>
</div>
</div>
)}
);
}}
/>
);
}

View File

@@ -0,0 +1,13 @@
export function ResetSettingIcon({ size = 24 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
viewBox="0 -960 960 960"
width={size}
fill="#00000"
>
<path d="M520-330v-60h160v60H520Zm60 210v-50h-60v-60h60v-50h60v160h-60Zm100-50v-60h160v60H680Zm40-110v-160h60v50h60v60h-60v50h-60Zm111-280h-83q-26-88-99-144t-169-56q-117 0-198.5 81.5T200-480q0 72 32.5 132t87.5 98v-110h80v240H160v-80h94q-62-50-98-122.5T120-480q0-75 28.5-140.5t77-114q48.5-48.5 114-77T480-840q129 0 226.5 79.5T831-560Z" />
</svg>
);
}

View File

@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/commons/Button';
import { BaseModal } from '@/components/commons/BaseModal';
import { CheckboxInternal } from '@/components/commons/Checkbox';
import { Typography } from '@/components/commons/Typography';
import { useElectron } from '@/hooks/electron';
import { useConfig } from '@/hooks/config';
import { useLocalization } from '@fluent/react';
import { useAppContext } from '@/hooks/app';
export function UdevRulesModal() {
const { config, setConfig } = useConfig();
const electron = useElectron();
const [udevContent, setUdevContent] = useState('');
const [showUdevWarning, setShowUdevWarning] = useState(false);
const [dontShowThisSession, setDontShowThisSession] = useState(false);
const [dontShowAgain, setDontShowAgain] = useState(false);
const { l10n } = useLocalization();
const { installInfo } = useAppContext();
const handleUdevContent = async () => {
if (electron.isElectron) {
const dir = await electron.api.getInstallDir();
const rulesPath = `${dir}/69-slimevr-devices.rules`;
setUdevContent(
`cat ${rulesPath} | sudo sh -c 'tee /etc/udev/rules.d/69-slimevr-devices.rules >/dev/null && udevadm control --reload-rules && udevadm trigger'`
);
}
};
useEffect(() => {
handleUdevContent();
}, []);
useEffect(() => {
if (!config) throw 'Invalid state!';
if (electron.isElectron) {
const isLinux = electron.data().os.type === 'linux';
const udevMissing = !installInfo?.isUdevInstalled;
const notHiddenGlobally = !config.dontShowUdevModal;
const notHiddenThisSession = !dontShowThisSession;
const shouldShow =
isLinux && udevMissing && notHiddenGlobally && notHiddenThisSession;
setShowUdevWarning(shouldShow);
}
}, [config, dontShowThisSession]);
const handleModalClose = () => {
if (!config) throw 'Invalid State!';
setConfig({ dontShowUdevModal: dontShowAgain });
setDontShowThisSession(true);
};
const copyToClipboard = () => {
navigator.clipboard.writeText(udevContent);
};
return (
<BaseModal isOpen={showUdevWarning} appendClasses="w-full max-w-2xl">
<div className="flex w-full h-full flex-col gap-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Typography
variant="main-title"
id="install-info_udev-rules_modal_title"
/>
<Typography id="install-info_udev-rules_warning" />
</div>
<div className="relative w-full max-w-2xl">
<div className="absolute right-2 top-2">
<Button variant="secondary" onClick={copyToClipboard}>
Copy
</Button>
</div>
<div className="bg-background-80 rounded-lg overflow-auto p-2 w-full h-[300px]">
<pre className="text-wrap">{udevContent}</pre>
</div>
</div>
</div>
<div className="flex justify-between gap-2">
<CheckboxInternal
label={l10n.getString(
'install-info_udev-rules_modal-dont-show-again_checkbox'
)}
name="dismiss-udev-rules-checkbox"
onChange={(e) => setDontShowAgain(e.currentTarget.checked)}
/>
<Button
variant="primary"
onClick={handleModalClose}
id="install-info_udev-rules_modal_button"
/>
</div>
</div>
</BaseModal>
);
}

View File

@@ -0,0 +1,49 @@
import { Localized } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { Button } from '@/components/commons/Button';
import { useConfig } from '@/hooks/config';
export function ErrorCollectingConsentPage() {
const { setConfig } = useConfig();
const accept = () => {
setConfig({ errorTracking: true });
};
const cancel = () => {
setConfig({ errorTracking: false });
};
return (
<div className="flex items-center justify-center h-full flex-col gap-3 p-4">
<div className="max-w-2xl flex flex-col gap-4">
<div className="flex flex-col w-full gap-4">
<Typography variant="main-title" id="error_collection_modal-title" />
<Localized
id="error_collection_modal-description_v2"
elems={{
b: <b />,
h1: <span className="text-md font-bold" />,
}}
>
<Typography variant="standard" whitespace="whitespace-pre-line" />
</Localized>
</div>
<div className="flex flex-row gap-2 justify-between">
<Button
variant="tertiary"
to="/onboarding/wifi-creds"
onClick={cancel}
id="error_collection_modal-cancel"
/>
<Button
variant="primary"
to="/onboarding/wifi-creds"
onClick={accept}
id="error_collection_modal-confirm"
/>
</div>
</div>
</div>
);
}

View File

@@ -19,9 +19,11 @@ export function HomePage() {
<Typography variant="mobile-title">
{l10n.getString('onboarding-home')}
</Typography>
<Button variant="primary" to="/onboarding/wifi-creds">
{l10n.getString('onboarding-home-start')}
</Button>
<Button
variant="primary"
id="onboarding-home-start"
to="/onboarding/error-collecting-consent"
/>
</div>
<div className="absolute right-4 bottom-4 z-50">
<LangSelector />

View File

@@ -58,6 +58,10 @@ export function SettingSelectorMobile() {
label: l10n.getString('settings-sidebar-advanced'),
value: { url: '/settings/advanced' },
},
{
label: l10n.getString('settings-sidebar-keybinds'),
value: { url: '/settings/keybinds' },
},
{
label: l10n.getString('navbar-onboarding'),
value: { url: '/onboarding/home' },

View File

@@ -73,6 +73,13 @@ export function SettingsSidebar() {
scrollTo="gestureControl"
id="settings-sidebar-gesture_control"
/>
{
<SettingsLink
to="/settings/keybinds"
scrollTo="keybinds"
id="settings-sidebar-keybinds"
/>
}
</div>
</div>
<div className="flex flex-col gap-3">

View File

@@ -0,0 +1,15 @@
.keybind-settings {
display: grid;
grid-template:
'n v d ' auto
'k k k ' auto
'b . . ' auto
/ 1fr 1fr 90px;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
align-items: left;
gap: 10px;
}

View File

@@ -0,0 +1,264 @@
import { KeybindRecorderModal } from '@/components/commons/KeybindRecorderModal';
import {
SettingsPageLayout,
SettingsPagePaneLayout,
} from '@/components/settings/SettingsPageLayout';
import { WrenchIcon } from '@/components/commons/icon/WrenchIcons';
import { Typography } from '@/components/commons/Typography';
import { useLocalization } from '@fluent/react';
import './KeybindSettings.scss';
import { Button } from '@/components/commons/Button';
import { KeybindsRow } from '@/components/commons/KeybindsRow';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { ReactNode, useEffect, useRef, useState } from 'react';
import {
ChangeKeybindRequestT,
KeybindRequestT,
KeybindResponseT,
KeybindT,
OpenUriRequestT,
RpcMessage,
} from 'solarxr-protocol';
import { FormProvider, useFieldArray, useForm } from 'react-hook-form';
import { useAppContext } from '@/hooks/app';
import { useElectron } from '@/hooks/electron';
export type KeybindForm = {
keybinds: {
id: number;
name: string;
binding: string[];
delay: number;
}[];
};
export function KeybindSettings() {
const electron = useElectron();
const { l10n } = useLocalization();
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [defaultKeybindsState, setDefaultKeybindsState] = useState<KeybindForm>(
{
keybinds: [],
}
);
const currentIndex = useRef<number | null>(null);
const { installInfo } = useAppContext();
const methods = useForm<KeybindForm>({
defaultValues: defaultKeybindsState,
});
const {
control,
handleSubmit,
reset,
setValue,
getValues,
setError,
clearErrors,
resetField,
} = methods;
const { fields } = useFieldArray({
control,
name: 'keybinds',
});
const onSubmit = () => {
const value = getValues();
if (checkDuplicates(value)) {
return;
}
clearErrors('keybinds');
value.keybinds.forEach((kb) => {
const changeKeybindRequest = new ChangeKeybindRequestT();
const keybind = new KeybindT();
keybind.keybindId = kb.id;
keybind.keybindNameId = kb.name;
keybind.keybindValue = kb.binding.join('+');
keybind.keybindDelay = kb.delay;
changeKeybindRequest.keybind = keybind;
sendRPCPacket(RpcMessage.ChangeKeybindRequest, changeKeybindRequest);
setIsOpen(false);
});
};
const checkDuplicates = (value: KeybindForm) => {
const normalized = value.keybinds
.filter((kb) => kb.binding.length > 0)
.map((kb) => JSON.stringify([...kb.binding].sort()));
const unique = new Set(normalized);
if (unique.size !== normalized.length) {
setError('keybinds', {
type: 'manual',
message: 'Duplicate keybind combinations are not allowed',
});
return true;
}
return false;
};
const handleOpenSystemSettingsButton = () => {
sendRPCPacket(RpcMessage.OpenUriRequest, new OpenUriRequestT());
};
useRPCPacket(
RpcMessage.KeybindResponse,
({ keybind, defaultKeybinds }: KeybindResponseT) => {
if (!keybind) return;
const mappedDefaults = defaultKeybinds.map((kb) => ({
id: kb.keybindId,
name: typeof kb.keybindNameId === 'string' ? kb.keybindNameId : '',
binding:
typeof kb.keybindValue === 'string' ? kb.keybindValue.split('+') : [],
delay: kb.keybindDelay,
}));
setDefaultKeybindsState({ keybinds: mappedDefaults });
reset({ keybinds: mappedDefaults });
const mapped = keybind.map((kb) => ({
id: kb.keybindId,
name: typeof kb.keybindNameId === 'string' ? kb.keybindNameId : '',
binding:
typeof kb.keybindValue === 'string' ? kb.keybindValue.split('+') : [],
delay: kb.keybindDelay,
}));
mapped.forEach((keybind, index) => {
setValue(`keybinds.${index}.binding`, keybind.binding);
setValue(`keybinds.${index}.delay`, keybind.delay);
});
}
);
const handleOpenRecorderModal = (index: number) => {
currentIndex.current = index;
if (currentIndex !== null) {
setIsOpen(true);
}
};
const onClose = () => {
if (currentIndex.current != null) {
resetField(`keybinds.${currentIndex.current}.binding`);
}
setIsOpen(false);
};
const createKeybindRows = (): ReactNode => {
return fields.map((field, index) => {
return (
<div className="keybind-row" key={index}>
<KeybindsRow
id={typeof field.name === 'string' ? field.name : ''}
control={control}
index={index}
getValue={getValues}
openKeybindRecorderModal={handleOpenRecorderModal}
/>
</div>
);
});
};
useEffect(() => {
sendRPCPacket(RpcMessage.KeybindRequest, new KeybindRequestT());
}, []);
return (
<SettingsPageLayout>
<SettingsPagePaneLayout icon={<WrenchIcon />} id="keybinds">
<div className="flex flex-col gap-2">
<Typography variant="main-title" id="settings-keybinds" />
<div className="flex flex-col pt-2 pb-4">
{l10n
.getString('settings-keybinds-description')
.split('\n')
.map((line, i) => (
<Typography key={i}>{line}</Typography>
))}
</div>
{!installInfo?.isWayland ? (
<div className="flex flex-col gap-4">
<Typography id="settings-keybinds-wayland-description" />
<div>
<Button
id="settings-keybinds-wayland-open-system-settings-button"
className="flex flex-col"
onClick={handleOpenSystemSettingsButton}
variant="primary"
/>
</div>
</div>
) : (
electron.isElectron &&
electron.data().os.type !== 'windows' && (
<>
<FormProvider {...methods}>
<div className="keybind-settings">
<Typography
id="keybind_config-keybind_name"
variant="section-title"
/>
<Typography
id="keybind_config-keybind_value"
variant="section-title"
/>
<Typography
id="keybind_config-keybind_delay"
variant="section-title"
/>
{createKeybindRows()}
</div>
<div className="flex justify-end">
<Button
id="settings-keybinds_reset-all-button"
onClick={() => {
reset(defaultKeybindsState);
handleSubmit(onSubmit)();
}}
variant="primary"
/>
</div>
<KeybindRecorderModal
id={
currentIndex.current != null
? fields[currentIndex.current].name
: ''
}
control={control}
name={
currentIndex.current != null
? `keybinds.${currentIndex.current}.binding`
: ''
}
isVisisble={isOpen}
onClose={onClose}
onUnbind={() => {
if (currentIndex.current != null)
setValue(
`keybinds.${currentIndex.current}.binding`,
[]
);
}}
onSubmit={onSubmit}
/>
</FormProvider>
</>
)
)}
</div>
</SettingsPagePaneLayout>
</SettingsPageLayout>
);
}

View File

@@ -39,8 +39,8 @@ function StaAlignedPoseModal({
<Typography variant="main-title">{l10n.getString(title)}</Typography>
</div>
<div className="flex flex-col gap-1">
{descriptionKeys.map((descriptionKey) => (
<Typography>{l10n.getString(descriptionKey)}</Typography>
{descriptionKeys.map((descriptionKey, i) => (
<Typography key={i}>{l10n.getString(descriptionKey)}</Typography>
))}
</div>
<div className="flex pt-1 items-center fill-background-50 justify-center px-12">

View File

@@ -38,7 +38,7 @@ export function TrackerBattery({
return (
<Tooltip
disabled={charging || !runtime || debug}
disabled={!charging && (!runtime || debug)}
preferedDirection="left"
content=<Typography>{percentFormatter.format(value)}</Typography>
>

View File

@@ -199,7 +199,7 @@ export function TrackerSettingsPage() {
shakeHighlight={false}
/>
)}
{tracker?.device?.hardwareInfo?.hardwareIdentifier != 'Unknown' && (
{
<div className="flex flex-col bg-background-70 p-3 rounded-lg gap-2">
<Typography
variant="section-title"
@@ -223,38 +223,34 @@ export function TrackerSettingsPage() {
whitespace="whitespace-pre-wrap"
textAlign="text-end"
>
{tracker?.device?.hardwareInfo?.firmwareVersion
? `v${tracker?.device?.hardwareInfo?.firmwareVersion}`
: '--'}
v{tracker?.device?.hardwareInfo?.firmwareVersion}
</Typography>
</div>
{!!tracker?.device?.hardwareInfo?.officialBoardType && (
<div className="flex justify-between gap-2">
<Typography id="tracker-settings-latest-version" />
{!updateUnavailable && (
<>
{currentFirmwareRelease && (
<Typography
color={
needUpdate === 'updated'
? undefined
: 'text-accent-background-10'
}
textAlign="text-end"
whitespace="whitespace-pre-wrap"
>
{currentFirmwareRelease.name}
</Typography>
)}
</>
)}
{updateUnavailable && (
<Typography id="tracker-settings-update-unavailable-v2">
No releases found
</Typography>
)}
</div>
)}
<div className="flex justify-between gap-2">
<Typography id="tracker-settings-latest-version" />
{!updateUnavailable && (
<>
{currentFirmwareRelease && (
<Typography
color={
needUpdate === 'updated'
? undefined
: 'text-accent-background-10'
}
textAlign="text-end"
whitespace="whitespace-pre-wrap"
>
{currentFirmwareRelease.name}
</Typography>
)}
</>
)}
{updateUnavailable && (
<Typography id="tracker-settings-update-unavailable-v2">
No releases found
</Typography>
)}
</div>
</div>
{!updateUnavailable && (
<Tooltip
@@ -293,7 +289,7 @@ export function TrackerSettingsPage() {
</Tooltip>
)}
</div>
)}
}
<div className="flex flex-col bg-background-70 p-3 rounded-lg gap-2 overflow-x-auto">
<div className="flex justify-between">
@@ -321,11 +317,10 @@ export function TrackerSettingsPage() {
<div className="flex justify-between">
<Typography>{l10n.getString('tracker-infos-url')}</Typography>
<Typography>
{tracker?.device?.hardwareInfo?.ipAddress?.addr
? `udp://${IPv4.fromNumber(
tracker?.device?.hardwareInfo?.ipAddress?.addr || 0
).toString()}`
: '--'}
udp://
{IPv4.fromNumber(
tracker?.device?.hardwareInfo?.ipAddress?.addr || 0
).toString()}
</Typography>
</div>
<div className="flex justify-between">

View File

@@ -5,6 +5,7 @@ import {
ResetResponseT,
RpcMessage,
StartDataFeedT,
InstalledInfoResponseT,
} from 'solarxr-protocol';
import { handleResetSounds } from '@/sounds/sounds';
import { useConfig } from './config';
@@ -18,11 +19,17 @@ import { DEFAULT_LOCALE, LangContext } from '@/i18n/config';
export interface AppContext {
currentFirmwareRelease: FirmwareRelease | null;
installInfo: InstalledInfoResponseT | null;
}
export function useProvideAppContext(): AppContext {
const { useRPCPacket, sendDataFeedPacket, useDataFeedPacket, isConnected } =
useWebsocketAPI();
const {
useRPCPacket,
sendRPCPacket,
sendDataFeedPacket,
useDataFeedPacket,
isConnected,
} = useWebsocketAPI();
const { changeLocales } = useContext(LangContext);
const { config } = useConfig();
const { dataFeedConfig } = useDataFeedConfig();
@@ -34,6 +41,8 @@ export function useProvideAppContext(): AppContext {
const [currentFirmwareRelease, setCurrentFirmwareRelease] =
useState<FirmwareRelease | null>(null);
const [installInfo, setInstallInfo] = useState<InstalledInfoResponseT | null>(null);
useEffect(() => {
if (isConnected) {
const startDataFeed = new StartDataFeedT();
@@ -70,6 +79,17 @@ export function useProvideAppContext(): AppContext {
};
}, [config?.uuid]);
useEffect(() => {
sendRPCPacket(RpcMessage.InstalledInfoRequest, new InstalledInfoResponseT());
}, []);
useRPCPacket(
RpcMessage.InstalledInfoResponse,
({ isUdevInstalled, isWayland }: InstalledInfoResponseT) => {
setInstallInfo(new InstalledInfoResponseT(isUdevInstalled, isWayland));
}
);
useLayoutEffect(() => {
changeLocales([config?.lang || DEFAULT_LOCALE]);
}, []);
@@ -85,6 +105,7 @@ export function useProvideAppContext(): AppContext {
return {
currentFirmwareRelease,
installInfo,
};
}

View File

@@ -48,6 +48,7 @@ export interface Config {
homeLayout: 'default' | 'table';
skeletonPreview: boolean;
lastUsedProportions: 'manual' | 'autobone' | 'scaled' | null;
dontShowUdevModal: boolean;
}
export interface ConfigContext {
@@ -79,6 +80,7 @@ export const defaultConfig: Config = {
homeLayout: 'default',
skeletonPreview: true,
lastUsedProportions: null,
dontShowUdevModal: false,
};
const localStore: CrossStorage = {

View File

@@ -130,6 +130,9 @@ export function checkForUpdate(
if (
!device.hardwareInfo?.officialBoardType ||
![BoardType.SLIMEVR, BoardType.SLIMEVR_V1_2].includes(
device.hardwareInfo.officialBoardType
) ||
!semver.valid(currentFirmwareRelease.version) ||
!semver.valid(device.hardwareInfo.firmwareVersion?.toString() ?? 'none')
) {
@@ -141,14 +144,6 @@ export function checkForUpdate(
currentFirmwareRelease.version
);
if (
![BoardType.SLIMEVR, BoardType.SLIMEVR_V1_2].includes(
device.hardwareInfo.officialBoardType
)
) {
return canUpdate ? 'unavailable' : 'updated';
}
if (
canUpdate &&
device.hardwareStatus?.batteryPctEstimate != null &&

View File

@@ -11,8 +11,12 @@ import { DeviceDataT } from 'solarxr-protocol';
export function getSentryOrCompute(enabled = false, uuid: string) {
Sentry.setUser({ id: uuid });
// if sentry is already initialized - SKIP
if (enabled && Sentry.isInitialized()) return;
if (enabled && Sentry.isInitialized()) {
log('Sentry already enabled, skipping initialization');
return;
}
const client = Sentry.getClient();
if (client) {

View File

@@ -183,6 +183,7 @@ const config = {
lg: '1300px',
xl: '1600px',
tall: { raw: '(min-height: 860px)' },
'keybinds-small': { raw: 'not (min-width: 1230px)' },
},
extend: {
colors: {

View File

@@ -8,7 +8,7 @@
"gui"
],
"scripts": {
"gui": "pnpm run update-solarxr && cd gui && pnpm run gui",
"gui": "pnpm run update-solarxr && cd gui && pnpm run dev:clean",
"lint:fix": "cd gui && pnpm lint:fix",
"skipbundler": "cd gui && pnpm run skipbundler",
"build": "cd gui && pnpm build",

View File

@@ -79,6 +79,8 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("com.mayakapps.kache:kache:2.1.1")
implementation("com.github.HannahPadd:DbusGlobalShortcutsWayland:v0.1.0")
api("com.github.loucass003:EspflashKotlin:v0.11.0")
// Allow the use of reflection

View File

@@ -0,0 +1,6 @@
package dev.slimevr
data class FeatureFlags(
var steam: Boolean = false,
var skipCheckUdev: Boolean = false,
)

View File

@@ -2,51 +2,30 @@ package dev.slimevr
import com.melloware.jintellitype.HotkeyListener
import com.melloware.jintellitype.JIntellitype
import dev.hannah.portals.PortalManager
import dev.hannah.portals.globalShortcuts.Shortcut
import dev.hannah.portals.globalShortcuts.ShortcutTuple
import dev.slimevr.config.KeybindingsConfig
import dev.slimevr.tracking.trackers.TrackerUtils
import io.eiren.util.OperatingSystem
import io.eiren.util.OperatingSystem.Companion.currentPlatform
import io.eiren.util.ann.AWTThread
import io.eiren.util.logging.LogManager
import solarxr_protocol.rpc.KeybindId
class Keybinding @AWTThread constructor(val server: VRServer) : HotkeyListener {
val config: KeybindingsConfig = server.configManager.vrConfig.keybindings
init {
if (currentPlatform != OperatingSystem.WINDOWS) {
LogManager
.info(
"[Keybinding] Currently only supported on Windows. Keybindings will be disabled.",
)
} else {
if (currentPlatform == OperatingSystem.WINDOWS) {
try {
if (JIntellitype.getInstance() != null) {
JIntellitype.getInstance().addHotKeyListener(this)
val fullResetBinding = config.fullResetBinding
JIntellitype.getInstance()
.registerHotKey(FULL_RESET, fullResetBinding)
LogManager.info("[Keybinding] Bound full reset to $fullResetBinding")
val yawResetBinding = config.yawResetBinding
JIntellitype.getInstance()
.registerHotKey(YAW_RESET, yawResetBinding)
LogManager.info("[Keybinding] Bound yaw reset to $yawResetBinding")
val mountingResetBinding = config.mountingResetBinding
JIntellitype.getInstance()
.registerHotKey(MOUNTING_RESET, mountingResetBinding)
LogManager.info("[Keybinding] Bound reset mounting to $mountingResetBinding")
val feetMountingResetBinding = config.feetMountingResetBinding
JIntellitype.getInstance()
.registerHotKey(FEET_MOUNTING_RESET, feetMountingResetBinding)
LogManager.info("[Keybinding] Bound feet reset mounting to $feetMountingResetBinding")
val pauseTrackingBinding = config.pauseTrackingBinding
JIntellitype.getInstance()
.registerHotKey(PAUSE_TRACKING, pauseTrackingBinding)
LogManager.info("[Keybinding] Bound pause tracking to $pauseTrackingBinding")
config.keybinds.forEach { (i, keybind) ->
JIntellitype.getInstance()
.registerHotKey(keybind.id, keybind.binding)
}
}
} catch (e: Throwable) {
LogManager
@@ -55,32 +34,107 @@ class Keybinding @AWTThread constructor(val server: VRServer) : HotkeyListener {
)
}
}
if (currentPlatform == OperatingSystem.LINUX) {
val portalManager = PortalManager(SLIMEVR_IDENTIFIER)
val fullReset = Shortcut("Full Reset", "CTRL+ALT+SHIFT+Y")
val yawReset = Shortcut("Yaw Reset", "CTRL+ALT+SHIFT+U")
val mountingReset = Shortcut("Mounting Reset", "CTRL+ALT+SHIFT+I")
val feetMountingReset = Shortcut("Feet Mounting Reset", "CTRL+ALT+SHIFT+P")
val pauseTracking = Shortcut("Pause Tracking", "CTRL+ALT+SHIFT+O")
val shortcutsList = mutableListOf(
ShortcutTuple("FULL_RESET", fullReset.shortcut),
ShortcutTuple("YAW_RESET", yawReset.shortcut),
ShortcutTuple("MOUNTING_RESET", mountingReset.shortcut),
ShortcutTuple("FEET_MOUNTING_RESET", feetMountingReset.shortcut),
ShortcutTuple("PAUSE_TRACKING", pauseTracking.shortcut),
)
val globalShortcutsHandler = portalManager.globalShortcutsRequest(shortcutsList)
Runtime.getRuntime().addShutdownHook(
Thread {
println("Closing connection")
globalShortcutsHandler.close()
},
)
globalShortcutsHandler.onShortcutActivated = { shortcutId ->
when (shortcutId) {
"FULL_RESET" -> {
val delay = config.keybinds[KeybindId.FULL_RESET]?.delay?.toLong() ?: 0L
server.scheduleResetTrackersFull(RESET_SOURCE_NAME, delay)
}
"YAW_RESET" -> {
val delay = config.keybinds[KeybindId.YAW_RESET]?.delay?.toLong() ?: 0L
server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, delay)
}
"MOUNTING_RESET" -> {
val delay = config.keybinds[KeybindId.MOUNTING_RESET]?.delay?.toLong() ?: 0L
server.scheduleResetTrackersMounting(
RESET_SOURCE_NAME,
delay,
)
}
"FEET_MOUNTING_RESET" -> {
val delay = config.keybinds[KeybindId.FEET_MOUNTING_RESET]?.delay?.toLong() ?: 0L
server.scheduleResetTrackersMounting(
RESET_SOURCE_NAME,
delay,
TrackerUtils.feetsBodyParts,
)
}
"PAUSE_TRACKING" -> {
val delay = config.keybinds[KeybindId.PAUSE_TRACKING]?.delay?.toLong() ?: 0L
server.scheduleTogglePauseTracking(
RESET_SOURCE_NAME,
delay,
)
}
}
}
}
}
@AWTThread
override fun onHotKey(identifier: Int) {
when (identifier) {
FULL_RESET -> server.scheduleResetTrackersFull(RESET_SOURCE_NAME, config.fullResetDelay)
FULL_RESET -> {
val delay = config.keybinds[KeybindId.FULL_RESET]?.delay?.toLong() ?: 0L
server.scheduleResetTrackersFull(RESET_SOURCE_NAME, delay)
}
YAW_RESET -> server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, config.yawResetDelay)
YAW_RESET -> {
val delay = config.keybinds[KeybindId.YAW_RESET]?.delay?.toLong() ?: 0L
server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, delay)
}
MOUNTING_RESET -> server.scheduleResetTrackersMounting(
RESET_SOURCE_NAME,
config.mountingResetDelay,
)
MOUNTING_RESET -> {
val delay = config.keybinds[KeybindId.FEET_MOUNTING_RESET]?.delay?.toLong() ?: 0L
server.scheduleResetTrackersMounting(
RESET_SOURCE_NAME,
delay,
TrackerUtils.feetsBodyParts,
)
}
FEET_MOUNTING_RESET -> server.scheduleResetTrackersMounting(
RESET_SOURCE_NAME,
config.feetMountingResetDelay,
TrackerUtils.feetsBodyParts,
)
FEET_MOUNTING_RESET -> {
val delay = config.keybinds[KeybindId.FEET_MOUNTING_RESET]?.delay?.toLong() ?: 0L
server.scheduleResetTrackersMounting(
RESET_SOURCE_NAME,
delay,
TrackerUtils.feetsBodyParts,
)
}
PAUSE_TRACKING ->
server
.scheduleTogglePauseTracking(
RESET_SOURCE_NAME,
config.pauseTrackingDelay,
)
PAUSE_TRACKING -> {
val delay = config.keybinds[KeybindId.PAUSE_TRACKING]?.delay?.toLong() ?: 0L
server.scheduleTogglePauseTracking(
RESET_SOURCE_NAME,
delay,
)
}
}
}

View File

@@ -11,6 +11,7 @@ import dev.slimevr.games.vrchat.VRCConfigHandler
import dev.slimevr.games.vrchat.VRCConfigHandlerStub
import dev.slimevr.games.vrchat.VRChatConfigManager
import dev.slimevr.guards.ServerGuards
import dev.slimevr.keybind.KeybindHandler
import dev.slimevr.osc.OSCHandler
import dev.slimevr.osc.OSCRouter
import dev.slimevr.osc.VMCHandler
@@ -56,6 +57,7 @@ const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR"
class VRServer @JvmOverloads constructor(
bridgeProvider: BridgeProvider = { _, _ -> sequence {} },
featureFlagsProvider: (VRServer) -> FeatureFlags = { _ -> FeatureFlags() },
serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() },
flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null },
vrcConfigHandlerProvider: (VRServer) -> VRCConfigHandler = { _ -> VRCConfigHandlerStub() },
@@ -83,6 +85,9 @@ class VRServer @JvmOverloads constructor(
@JvmField
val deviceManager: DeviceManager
// UwU
val featureFlags: FeatureFlags = featureFlagsProvider(this)
@JvmField
val bvhRecorder: BVHRecorder
@@ -123,10 +128,11 @@ class VRServer @JvmOverloads constructor(
val networkProfileChecker: NetworkProfileChecker
val keybindHandler: KeybindHandler
val serverGuards = ServerGuards()
init {
// UwU
deviceManager = DeviceManager(this)
serialHandler = serialHandlerProvider(this)
serialFlashingHandler = flashingHandlerProvider(this)
@@ -140,6 +146,7 @@ class VRServer @JvmOverloads constructor(
vrcConfigManager = VRChatConfigManager(this, vrcConfigHandlerProvider(this))
networkProfileChecker = networkProfileProvider(this)
trackingChecklistManager = TrackingChecklistManager(this)
keybindHandler = KeybindHandler(this)
protocolAPI = ProtocolAPI(this)
val computedTrackers = humanPoseManager.computedTrackers

View File

@@ -1,88 +0,0 @@
package dev.slimevr.config;
public class KeybindingsConfig {
private String fullResetBinding = "CTRL+ALT+SHIFT+Y";
private String yawResetBinding = "CTRL+ALT+SHIFT+U";
private String mountingResetBinding = "CTRL+ALT+SHIFT+I";
private String feetMountingResetBinding = "CTRL+ALT+SHIFT+P";
private String pauseTrackingBinding = "CTRL+ALT+SHIFT+O";
private long fullResetDelay = 0L;
private long yawResetDelay = 0L;
private long mountingResetDelay = 0L;
private long feetMountingResetDelay = 0L;
private long pauseTrackingDelay = 0L;
public KeybindingsConfig() {
}
public String getFullResetBinding() {
return fullResetBinding;
}
public String getYawResetBinding() {
return yawResetBinding;
}
public String getMountingResetBinding() {
return mountingResetBinding;
}
public String getFeetMountingResetBinding() {
return feetMountingResetBinding;
}
public String getPauseTrackingBinding() {
return pauseTrackingBinding;
}
public long getFullResetDelay() {
return fullResetDelay;
}
public void setFullResetDelay(long delay) {
fullResetDelay = delay;
}
public long getYawResetDelay() {
return yawResetDelay;
}
public void setYawResetDelay(long delay) {
yawResetDelay = delay;
}
public long getMountingResetDelay() {
return mountingResetDelay;
}
public void setMountingResetDelay(long delay) {
mountingResetDelay = delay;
}
public long getFeetMountingResetDelay() {
return feetMountingResetDelay;
}
public void setFeetMountingResetDelay(long delay) {
feetMountingResetDelay = delay;
}
public long getPauseTrackingDelay() {
return pauseTrackingDelay;
}
public void setPauseTrackingDelay(long delay) {
pauseTrackingDelay = delay;
}
}

View File

@@ -0,0 +1,52 @@
package dev.slimevr.config
import solarxr_protocol.rpc.KeybindId
class KeybindingsConfig {
val keybinds: MutableMap<Int, KeybindData> = mutableMapOf()
init {
keybinds[KeybindId.FULL_RESET] =
KeybindData(
KeybindId.FULL_RESET,
"full-reset",
"CTRL+ALT+SHIFT+Y",
0f,
)
keybinds[KeybindId.YAW_RESET] =
KeybindData(
KeybindId.YAW_RESET,
"yaw-reset",
"CTRL+ALT+SHIFT+U",
0f,
)
keybinds[KeybindId.MOUNTING_RESET] =
KeybindData(
KeybindId.MOUNTING_RESET,
"mounting-reset",
"CTRL+ALT+SHIFT+I",
0f,
)
keybinds[KeybindId.FEET_MOUNTING_RESET] =
KeybindData(
KeybindId.FEET_MOUNTING_RESET,
"feet-mounting-reset",
"CTRL+ALT+SHIFT+P",
0f,
)
keybinds[KeybindId.PAUSE_TRACKING] =
KeybindData(
KeybindId.PAUSE_TRACKING,
"pause-tracking",
"CTRL+ALT+SHIFT+O",
0f,
)
}
}
data class KeybindData(
var id: Int,
var name: String,
var binding: String,
var delay: Float,
)

View File

@@ -0,0 +1,55 @@
package dev.slimevr.keybind
import dev.slimevr.VRServer
import dev.slimevr.config.KeybindingsConfig
import solarxr_protocol.rpc.KeybindT
import java.util.concurrent.CopyOnWriteArrayList
class KeybindHandler(val vrServer: VRServer) {
private val listeners: MutableList<KeybindListener> = CopyOnWriteArrayList()
var keybinds: MutableList<KeybindT> = mutableListOf()
var defaultKeybinds: MutableList<KeybindT> = mutableListOf()
init {
createKeybinds()
}
fun addListener(listener: KeybindListener) {
this.listeners.add(listener)
}
fun removeListener(listener: KeybindListener) {
listeners.removeIf { listener == it }
}
private fun createKeybinds() {
keybinds.clear()
defaultKeybinds.clear()
vrServer.configManager.vrConfig.keybindings.keybinds.forEach { (i, keybind) ->
keybinds.add(
KeybindT().apply {
keybindId = keybind.id
keybindNameId = keybind.name
keybindValue = keybind.binding
keybindDelay = keybind.delay
},
)
}
val binds = KeybindingsConfig().keybinds
binds.forEach { (i, keybind) ->
defaultKeybinds.add(
KeybindT().apply {
keybindId = keybind.id
keybindNameId = keybind.name
keybindValue = keybind.binding
keybindDelay = keybind.delay
},
)
}
}
fun updateKeybinds() {
createKeybinds()
}
}

View File

@@ -0,0 +1,6 @@
package dev.slimevr.keybind
interface KeybindListener {
fun sendKeybind()
fun onKeybindUpdate()
}

View File

@@ -10,6 +10,9 @@ import dev.slimevr.protocol.datafeed.createTrackerId
import dev.slimevr.protocol.rpc.autobone.RPCAutoBoneHandler
import dev.slimevr.protocol.rpc.firmware.RPCFirmwareUpdateHandler
import dev.slimevr.protocol.rpc.games.vrchat.RPCVRChatHandler
import dev.slimevr.protocol.rpc.installinfo.RPCInstallInfoHandler
import dev.slimevr.protocol.rpc.keybinds.RPCKeybindHandler
import dev.slimevr.protocol.rpc.openuri.RPCOpenUriHandler
import dev.slimevr.protocol.rpc.reset.RPCResetHandler
import dev.slimevr.protocol.rpc.serial.RPCProvisioningHandler
import dev.slimevr.protocol.rpc.serial.RPCSerialHandler
@@ -52,6 +55,15 @@ class RPCHandler(private val api: ProtocolAPI) : ProtocolHandler<RpcMessageHeade
RPCVRChatHandler(this, api)
RPCTrackingChecklistHandler(this, api)
RPCUserHeightCalibration(this, api)
RPCInstallInfoHandler(this, api)
RPCOpenUriHandler(this, api)
try {
RPCKeybindHandler(this, api)
} catch (e: Exception) {
e.printStackTrace()
} catch (t: Throwable) {
t.printStackTrace()
}
registerPacketListener(
RpcMessage.AssignTrackerRequest,

View File

@@ -0,0 +1,58 @@
package dev.slimevr.protocol.rpc.installinfo
import com.google.flatbuffers.FlatBufferBuilder
import dev.slimevr.protocol.GenericConnection
import dev.slimevr.protocol.ProtocolAPI
import dev.slimevr.protocol.rpc.RPCHandler
import io.eiren.util.logging.LogManager
import solarxr_protocol.rpc.InstalledInfoResponse.createInstalledInfoResponse
import solarxr_protocol.rpc.RpcMessage
import solarxr_protocol.rpc.RpcMessageHeader
import java.io.IOException
class RPCInstallInfoHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) {
init {
rpcHandler.registerPacketListener(RpcMessage.InstalledInfoRequest, ::onInstalledInfoRequest)
}
fun onInstalledInfoRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) {
if (api.server.featureFlags.skipCheckUdev) {
return
}
val command = if (api.server.featureFlags.steam) {
arrayOf("steam-runtime-launch-client", "--alongside-steam", "--", "udevadm", "cat")
} else {
arrayOf("udevadm", "cat")
}
val udevResponse = executeShellCommand(*command)
if (udevResponse == null) {
LogManager.warning("Server couldn't verify if udev is installed")
return
}
val isUdevInstalled = udevResponse.contains("slime")
val isWayland = System.getenv("XDG_SESSION_TYPE").lowercase().contains("wayland")
val fbb = FlatBufferBuilder(1024)
val outbound = this.rpcHandler.createRPCMessage(
fbb,
RpcMessage.InstalledInfoResponse,
createInstalledInfoResponse(fbb, isUdevInstalled, isWayland),
)
fbb.finish(outbound)
conn.send(fbb.dataBuffer())
}
}
private fun executeShellCommand(vararg command: String): String? = try {
val process = ProcessBuilder(*command)
.redirectErrorStream(true)
.start()
process.inputStream.bufferedReader().readText().also {
process.waitFor()
}
} catch (e: IOException) {
LogManager.warning("Error executing shell command", e)
null
}

View File

@@ -0,0 +1,68 @@
package dev.slimevr.protocol.rpc.keybinds
import com.google.flatbuffers.FlatBufferBuilder
import dev.slimevr.config.KeybindData
import dev.slimevr.keybind.KeybindListener
import dev.slimevr.protocol.GenericConnection
import dev.slimevr.protocol.ProtocolAPI
import dev.slimevr.protocol.rpc.RPCHandler
import jdk.internal.joptsimple.internal.Messages.message
import solarxr_protocol.rpc.ChangeKeybindRequest
import solarxr_protocol.rpc.KeybindId
import solarxr_protocol.rpc.KeybindRequest
import solarxr_protocol.rpc.KeybindResponse
import solarxr_protocol.rpc.KeybindResponseT
import solarxr_protocol.rpc.KeybindT
import solarxr_protocol.rpc.RpcMessage
import solarxr_protocol.rpc.RpcMessageHeader
class RPCKeybindHandler(
var rpcHandler: RPCHandler,
var api: ProtocolAPI,
) : KeybindListener {
val keybindingConfig = api.server.configManager.vrConfig.keybindings
init {
this.api.server.keybindHandler.addListener(this)
rpcHandler.registerPacketListener(RpcMessage.KeybindRequest, ::onKeybindRequest)
rpcHandler.registerPacketListener(RpcMessage.ChangeKeybindRequest, ::onChangeKeybindRequest)
}
// TODO: Figure out a way to "refresh" the keybind array here.
private fun buildKeybindResponse(fbb: FlatBufferBuilder): Int = KeybindResponse.pack(
fbb,
KeybindResponseT().apply {
keybind = api.server.keybindHandler.keybinds.toTypedArray()
defaultKeybinds = api.server.keybindHandler.defaultKeybinds.toTypedArray()
},
)
private fun onKeybindRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) {
val fbb = FlatBufferBuilder(32)
val response = buildKeybindResponse(fbb)
val outbound = rpcHandler.createRPCMessage(
fbb,
RpcMessage.KeybindResponse,
response,
)
fbb.finish(outbound)
conn.send(fbb.dataBuffer())
}
private fun onChangeKeybindRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) {
val req = (messageHeader.message(ChangeKeybindRequest()) as ChangeKeybindRequest).unpack()
keybindingConfig.keybinds[req.keybind.keybindId] = KeybindData(req.keybind.keybindId, req.keybind.keybindNameId, req.keybind.keybindValue, req.keybind.keybindDelay)
api.server.configManager.saveConfig()
api.server.keybindHandler.updateKeybinds()
}
override fun onKeybindUpdate() {
}
override fun sendKeybind() {
}
}

View File

@@ -0,0 +1,21 @@
package dev.slimevr.protocol.rpc.openuri
import dev.hannah.portals.PortalManager
import dev.slimevr.SLIMEVR_IDENTIFIER
import dev.slimevr.protocol.GenericConnection
import dev.slimevr.protocol.ProtocolAPI
import dev.slimevr.protocol.rpc.RPCHandler
import solarxr_protocol.rpc.RpcMessage
import solarxr_protocol.rpc.RpcMessageHeader
class RPCOpenUriHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) {
init {
rpcHandler.registerPacketListener(RpcMessage.OpenUriRequest, ::onOpenUriRequest)
}
fun onOpenUriRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) {
val portalManager = PortalManager(SLIMEVR_IDENTIFIER)
portalManager.openGlobalShortcutsSettings()
}
}

View File

@@ -36,7 +36,7 @@ public class ProvisioningHandler implements SerialListener {
this.provisioningTickTimer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
if (!isRunning || provisioningStatus == ProvisioningStatus.DONE)
if (!isRunning)
return;
provisioningTick();
}

View File

@@ -24,6 +24,8 @@ class TrackerResetsHandler(val tracker: Tracker) {
Math.PI.toFloat(),
0f,
).toQuaternion()
private val QuarterPitch = Quaternion.rotationAroundXAxis(FastMath.HALF_PI)
private var driftAmount = 0f
private var averagedDriftQuat = Quaternion.IDENTITY
private var rotationSinceReset = Quaternion.IDENTITY
@@ -52,16 +54,6 @@ class TrackerResetsHandler(val tracker: Tracker) {
// Reference adjustment quats
/**
* Gyro fix is set by full reset. This sets the current y rotation to 0, correcting
* for initial yaw rotation and the rotation incurred by mounting orientation. This
* is a local offset in rotation and does not affect the axes of rotation.
*
* This rotation is only used to compute [attachmentFix], otherwise [yawFix] would
* correct for the same rotation.
*/
private var gyroFix = Quaternion.IDENTITY
/**
* Attachment fix is set by full reset. This sets the current x and z rotations to
* 0, correcting for initial pitch and roll rotation. This is a global offset in
@@ -188,12 +180,9 @@ class TrackerResetsHandler(val tracker: Tracker) {
/**
* Get the reference adjusted accel.
*/
// TODO: Make this actually adjusted to the corrected IMU heading. The current
// implementation for heading correction doesn't appear to be correct and may simply
// make acceleration worse, so I'm just leaving this until we work that out. The
// output of this will be world space, but with an unknown offset to heading (yaw).
// - Butterscotch
fun getReferenceAdjustedAccel(rawRot: Quaternion, accel: Vector3): Vector3 = rawRot.sandwich(accel)
// All IMU axis corrections are inverse to undo `adjustToReference` after local yaw offsets are added
// Order is VERY important here! Please be extremely careful! >~>
fun getReferenceAdjustedAccel(rawRot: Quaternion, accel: Vector3): Vector3 = (adjustToReference(rawRot) * (attachmentFix * mountingOrientation * mountRotFix * tposeDownFix).inv()).sandwich(accel)
/**
* Converts raw or filtered rotation into reference- and
@@ -202,22 +191,17 @@ class TrackerResetsHandler(val tracker: Tracker) {
*/
private fun adjustToReference(rotation: Quaternion): Quaternion {
var rot = rotation
// Align heading axis with bone space
if (!tracker.isHmd || tracker.trackerPosition != TrackerPosition.HEAD) {
rot *= mountingOrientation
}
// Heading correction assuming manual orientation is correct
rot = gyroFix * rot
// Align attitude axes with bone space
// Correct for global pitch/roll offset
rot *= attachmentFix
// Secondary heading axis alignment with bone space for automatic mounting
// Note: Applying an inverse amount of heading correction corresponding to the
// axis alignment quaternion will leave the correction to another variable
rot = mountRotFix.inv() * (rot * mountRotFix)
// More attitude axes alignment specifically for the t-pose configuration, this
// probably shouldn't be a separate variable from attachmentFix?
// Correct for global yaw offset without affecting local yaw so we can change this
// later without invalidating local yaw offset corrections
if (!tracker.isHmd || tracker.trackerPosition != TrackerPosition.HEAD) {
rot = mountingOrientation.inv() * rot * mountingOrientation
}
rot = mountRotFix.inv() * rot * mountRotFix
// T-pose global correction
rot *= tposeDownFix
// More heading correction
// Align local yaw with reference
rot = yawFix * rot
rot = constraintFix * rot
return rot
@@ -227,8 +211,6 @@ class TrackerResetsHandler(val tracker: Tracker) {
* Converts raw or filtered rotation into zero-reference-adjusted by
* applying quaternions produced after full reset and yaw reset only
*/
// This is essentially just adjustToReference but aligning to quaternion identity
// rather than to the bone.
private fun adjustToIdentity(rotation: Quaternion): Quaternion {
var rot = rotation
rot = gyroFixNoMounting * rot
@@ -283,15 +265,23 @@ class TrackerResetsHandler(val tracker: Tracker) {
lastResetQuaternion = oldRot
// Adjust raw rotation to mountingOrientation
val mountingAdjustedRotation = tracker.getRawRotation() * mountingOrientation
val rotation = tracker.getRawRotation()
// Gyrofix
if (tracker.allowMounting || (tracker.trackerPosition == TrackerPosition.HEAD && !tracker.isHmd)) {
gyroFix = if (tracker.isComputed) {
fixGyroscope(tracker.getRawRotation())
val gyroFix = if (tracker.allowMounting || (tracker.trackerPosition == TrackerPosition.HEAD && !tracker.isHmd)) {
if (tracker.isComputed) {
fixGyroscope(rotation)
} else {
fixGyroscope(mountingAdjustedRotation * tposeDownFix)
if (tracker.trackerPosition.isFoot()) {
// Feet are rotated by 90 deg pitch, this means we're relying on IMU rotation
// to be set correctly here.
fixGyroscope(rotation * tposeDownFix * QuarterPitch)
} else {
fixGyroscope(rotation * tposeDownFix)
}
}
} else {
Quaternion.IDENTITY
}
// Mounting for computed trackers
@@ -306,7 +296,7 @@ class TrackerResetsHandler(val tracker: Tracker) {
if (resetHmdPitch) {
// Reset the HMD's pitch if it's assigned to head and resetHmdPitch is true
// Get rotation without yaw (make sure to use the raw rotation directly!)
val rotBuf = getYawQuaternion(tracker.getRawRotation()).inv() * tracker.getRawRotation()
val rotBuf = getYawQuaternion(rotation).inv() * rotation
// Isolate pitch
Quaternion(rotBuf.w, -rotBuf.x, 0f, 0f).unit()
} else {
@@ -314,7 +304,7 @@ class TrackerResetsHandler(val tracker: Tracker) {
Quaternion.IDENTITY
}
} else {
fixAttachment(mountingAdjustedRotation)
(gyroFix * rotation).inv()
}
// Rotate attachmentFix by 180 degrees as a workaround for t-pose (down)
@@ -326,7 +316,7 @@ class TrackerResetsHandler(val tracker: Tracker) {
// Don't adjust yaw if head and computed
if (tracker.trackerPosition != TrackerPosition.HEAD || !tracker.isComputed) {
yawFix = fixYaw(mountingAdjustedRotation, reference)
yawFix = gyroFix * reference.project(Vector3.POS_Y).unit()
tracker.yawResetSmoothing.reset()
}
@@ -370,7 +360,7 @@ class TrackerResetsHandler(val tracker: Tracker) {
lastResetQuaternion = oldRot
val yawFixOld = yawFix
yawFix = fixYaw(tracker.getRawRotation() * mountingOrientation, reference)
yawFix = fixYaw(tracker.getRawRotation(), reference)
tracker.yawResetSmoothing.reset()
makeIdentityAdjustmentQuatsYaw()
@@ -409,9 +399,9 @@ class TrackerResetsHandler(val tracker: Tracker) {
constraintFix = Quaternion.IDENTITY
// Get the current calibrated rotation
var rotBuf = adjustToDrift(tracker.getRawRotation() * mountingOrientation)
rotBuf = gyroFix * rotBuf
var rotBuf = adjustToDrift(tracker.getRawRotation())
rotBuf *= attachmentFix
rotBuf = mountingOrientation.inv() * rotBuf * mountingOrientation
rotBuf = yawFix * rotBuf
// Adjust buffer to reference
@@ -467,14 +457,22 @@ class TrackerResetsHandler(val tracker: Tracker) {
mountRotFix = Quaternion.IDENTITY
}
private fun fixGyroscope(sensorRotation: Quaternion): Quaternion = getYawQuaternion(sensorRotation).inv()
private fun fixAttachment(sensorRotation: Quaternion): Quaternion = (gyroFix * sensorRotation).inv()
// EulerOrder.YXZ is actually better for gyroscope fix, as it can get yaw at any roll.
// Consequentially, instead of the roll being limited, the pitch is limited to
// 90 degrees from the yaw plane. This means trackers may be mounted upside down
// or with incorrectly configured IMU rotation, but we will need to compensate for
// the pitch.
private fun fixGyroscope(sensorRotation: Quaternion): Quaternion = getYawQuaternion(sensorRotation, EulerOrder.YXZ).inv()
private fun fixYaw(sensorRotation: Quaternion, reference: Quaternion): Quaternion {
var rot = gyroFix * sensorRotation
rot *= attachmentFix
rot = mountRotFix.inv() * (rot * mountRotFix)
var rot = sensorRotation * attachmentFix
// We need to fix the global yaw offset for the euler yaw calculation
if (!tracker.isHmd || tracker.trackerPosition != TrackerPosition.HEAD) {
rot = mountingOrientation.inv() * rot * mountingOrientation
}
rot = mountRotFix.inv() * rot * mountRotFix
// TODO: Get diff from ref to rot, use euler angle (YZX) yaw as output.
// This prevents pitch and roll from affecting the alignment.
rot = getYawQuaternion(rot)
return rot.inv() * reference.project(Vector3.POS_Y).unit()
}
@@ -485,7 +483,7 @@ class TrackerResetsHandler(val tracker: Tracker) {
// In both cases, the isolated yaw value changes
// with the tracker's roll when pointing forward.
// calling twinNearest() makes sure this rotation has the wanted polarity (+-).
private fun getYawQuaternion(rot: Quaternion): Quaternion = EulerAngles(EulerOrder.YZX, 0f, rot.toEulerAngles(EulerOrder.YZX).y, 0f).toQuaternion().twinNearest(rot)
private fun getYawQuaternion(rot: Quaternion, order: EulerOrder = EulerOrder.YZX): Quaternion = EulerAngles(order, 0f, rot.toEulerAngles(order).y, 0f).toQuaternion().twinNearest(rot)
private fun makeIdentityAdjustmentQuatsFull() {
val sensorRotation = tracker.getRawRotation()

View File

@@ -11,7 +11,6 @@ import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerStatus
import dev.slimevr.tracking.trackers.TrackerUtils
import dev.slimevr.tracking.trackers.udp.TrackerDataType
import io.github.axisangles.ktmath.Quaternion
import solarxr_protocol.datatypes.DeviceIdT
import solarxr_protocol.datatypes.TrackerIdT
import solarxr_protocol.rpc.*
@@ -200,8 +199,7 @@ class TrackingChecklistManager(private val vrServer: VRServer) : VRCConfigListen
}
// We ask for a full reset if you need to do mounting calibration but cant because you haven't done full reset in a while
// or if you have trackers that need reset after re-assigning
val usingSavedCalibration = vrServer.configManager.vrConfig.resetsConfig.saveMountingReset && imuTrackers.all { it.resetsHandler.mountRotFix != Quaternion.IDENTITY }
val needFullReset = (vrServer.configManager.vrConfig.resetsConfig.lastMountingMethod == MountingMethods.AUTOMATIC && !usingSavedCalibration && !resetMountingCompleted && !vrServer.serverGuards.canDoMounting) || trackerRequireReset.isNotEmpty()
val needFullReset = (!resetMountingCompleted && !vrServer.serverGuards.canDoMounting) || trackerRequireReset.isNotEmpty()
updateValidity(TrackingChecklistStepId.FULL_RESET, !needFullReset) {
it.enabled = imuTrackers.isNotEmpty()
if (trackerRequireReset.isNotEmpty()) {

View File

@@ -1,5 +1,6 @@
package io.eiren.util
import dev.slimevr.SLIMEVR_IDENTIFIER
import java.io.File
import java.nio.file.Path
import java.util.*
@@ -38,6 +39,12 @@ enum class OperatingSystem(
var dir = System.getenv("SLIMEVR_SOCKET_DIR")
if (dir != null) return dir
if (currentPlatform == LINUX) {
val isPressureVessel = System.getenv("PRESSURE_VESSEL_RUNTIME")?.isNotEmpty() == true
if (isPressureVessel) {
dir = System.getenv("XDG_CONFIG_HOME")?.let { Path(it, SLIMEVR_IDENTIFIER).toString() }
?: System.getenv("HOME")?.let { Path(it, ".local", "share", SLIMEVR_IDENTIFIER).toString() }
if (dir != null) return dir
}
dir = System.getenv("XDG_RUNTIME_DIR")
if (dir != null) return dir
}

View File

@@ -93,13 +93,13 @@ class SkeletonResetTests {
TrackerPosition.HIP,
TrackerPosition.LEFT_LOWER_LEG,
TrackerPosition.RIGHT_LOWER_LEG,
-> mountRot * Quaternion.SLIMEVR.FRONT
-> mountRot
TrackerPosition.LEFT_UPPER_LEG,
TrackerPosition.RIGHT_UPPER_LEG,
-> mountRot
-> mountRot * Quaternion.SLIMEVR.FRONT
else -> mountRot
else -> mountRot * Quaternion.SLIMEVR.FRONT
}
val actualMounting = tracker.resetsHandler.mountRotFix

View File

@@ -47,7 +47,6 @@ tasks.withType<Javadoc> {
allprojects {
repositories {
// Use jcenter for resolving dependencies.
// You can declare any Maven/Ivy/file repository here.
mavenCentral()
maven(url = "https://jitpack.io")
maven(url = "https://oss.sonatype.org/content/repositories/snapshots")
@@ -74,7 +73,7 @@ tasks.shadowJar {
exclude(dependency("com.fazecast:jSerialComm:.*"))
exclude(dependency("net.java.dev.jna:.*:.*"))
exclude(dependency("com.google.flatbuffers:flatbuffers-java:.*"))
exclude(dependency("com.github.HannahPadd:DbusGlobalShortcutsWayland:v0.1.0"))
exclude(project(":solarxr-protocol"))
}
archiveBaseName.set("slimevr")

View File

@@ -2,6 +2,7 @@
package dev.slimevr.desktop
import dev.slimevr.FeatureFlags
import dev.slimevr.Keybinding
import dev.slimevr.SLIMEVR_IDENTIFIER
import dev.slimevr.VRServer
@@ -9,6 +10,7 @@ import dev.slimevr.bridge.Bridge
import dev.slimevr.config.ConfigManager
import dev.slimevr.desktop.firmware.DesktopSerialFlashingHandler
import dev.slimevr.desktop.games.vrchat.DesktopVRCConfigHandler
import dev.slimevr.desktop.install.drivers.InstallDrivers
import dev.slimevr.desktop.platform.SteamVRBridge
import dev.slimevr.desktop.platform.linux.UnixSocketBridge
import dev.slimevr.desktop.platform.linux.UnixSocketRpcBridge
@@ -43,6 +45,8 @@ val VERSION =
(GIT_VERSION_TAG.ifEmpty { GIT_COMMIT_HASH }) +
if (GIT_CLEAN) "" else "-dirty"
val featureFlags = FeatureFlags()
fun main(args: Array<String>) {
System.setProperty("awt.useSystemAAFontSettings", "on")
System.setProperty("swing.aatext", "true")
@@ -50,23 +54,38 @@ fun main(args: Array<String>) {
val parser: CommandLineParser = DefaultParser()
val formatter = HelpFormatter()
val options = Options()
val isLinux = OperatingSystem.currentPlatform == OperatingSystem.LINUX
options.addOption("h", "help", false, "Show help")
options.addOption("V", "version", false, "Show version")
options.addOption("i", "install", true, "Run the driver install")
options.addOption("s", "steam", true, "Run the server in steam mode")
if (isLinux) {
options.addOption("u", "no-udev", false, "Skip checking if udev rules are installed")
}
val cmd: CommandLine = try {
parser.parse(options, args, true)
} catch (e: org.apache.commons.cli.ParseException) {
formatter.printHelp("slimevr.jar", options)
exitProcess(1)
}
if (cmd.hasOption("help")) {
formatter.printHelp("slimevr.jar", options)
exitProcess(0)
}
if (cmd.hasOption("version")) {
println("SlimeVR Server $VERSION")
LogManager.info("SlimeVR Server $VERSION")
exitProcess(0)
}
if (cmd.hasOption("install")) {
val installDrivers = InstallDrivers()
installDrivers.runInstaller()
exitProcess(0)
}
if (cmd.hasOption("steam")) {
featureFlags.steam = true
}
featureFlags.skipCheckUdev = !isLinux || cmd.hasOption("no-udev")
if (cmd.args.isEmpty()) {
System.err.println("No command specified, expected 'run'")
@@ -99,6 +118,12 @@ fun main(args: Array<String>) {
return
}
val isInstallDisabled = System.getenv("SLIME_SERVER_DISABLE_INSTALLER")?.toInt()
if (featureFlags.steam && isInstallDisabled != 1) {
val installDrivers = InstallDrivers()
installDrivers.runInstaller()
}
val configDir = resolveConfig()
LogManager.info("Using config dir: $configDir")
@@ -126,6 +151,7 @@ fun main(args: Array<String>) {
try {
val vrServer = VRServer(
::provideBridges,
{ _ -> featureFlags },
{ _ -> DesktopSerialHandler() },
{ _ -> DesktopSerialFlashingHandler() },
{ _ -> DesktopVRCConfigHandler() },
@@ -133,7 +159,6 @@ fun main(args: Array<String>) {
configManager = configManager,
)
vrServer.start()
// Start service for USB HID trackers
DesktopHIDManager(
"Sensors HID service",
@@ -218,7 +243,6 @@ fun provideBridges(
)
yield(linuxBridge)
}
yield(
UnixSocketBridge(
server,

View File

@@ -19,6 +19,7 @@ abstract class AbstractRegEdit {
abstract fun getQwordValue(path: String, key: String): Double?
abstract fun getDwordValue(path: String, key: String): Int?
abstract fun getVRChatKeys(path: String): Map<String, String>
abstract fun getKeyByPath(hkey: WinReg.HKEY, path: String): Map<String, String>
}
class RegEditWindows : AbstractRegEdit() {
@@ -74,6 +75,19 @@ class RegEditWindows : AbstractRegEdit() {
}
return keysMap
}
override fun getKeyByPath(hkey: WinReg.HKEY, path: String): Map<String, String> {
val keysMap = mutableMapOf<String, String>()
try {
Advapi32Util.registryGetValues(hkey, path).forEach {
keysMap[it.key.replace("""_h\d+$""".toRegex(), "")] = it.value.toString()
}
} catch (e: Exception) {
LogManager.severe("[RegEdit] Error reading values from registry", e)
}
return keysMap
}
}
class RegEditLinux : AbstractRegEdit() {
@@ -141,6 +155,9 @@ class RegEditLinux : AbstractRegEdit() {
return keysMap
}
// This function should never run on Linux.
override fun getKeyByPath(hkey: WinReg.HKEY, path: String): Map<String, String> = mutableMapOf<String, String>()
companion object {
const val USER_REG_SUBPATH = "steamapps/compatdata/438100/pfx/user.reg"
val USER_REG_PATH =

View File

@@ -0,0 +1,20 @@
package dev.slimevr.desktop.install.drivers
import io.eiren.util.logging.LogManager
class InstallDrivers {
val os = System.getProperty("os.name").lowercase()
fun runInstaller() {
if (os.contains("linux")) {
val linuxUpdater = Linux()
linuxUpdater.updateLinux()
} else if (os.contains("windows")) {
val windowsUpdater = Windows()
windowsUpdater.updateWindows()
} else {
LogManager.warning("Updater doesn't support operating system '$os'")
}
}
}

View File

@@ -0,0 +1,16 @@
package dev.slimevr.desktop.install.drivers
import io.eiren.util.logging.LogManager
import java.io.IOException
fun executeShellCommand(vararg command: String): String? = try {
val process = ProcessBuilder(*command)
.redirectErrorStream(true)
.start()
process.inputStream.bufferedReader().readText().also {
process.waitFor()
}
} catch (e: IOException) {
LogManager.warning("Error executing shell command", e)
null
}

View File

@@ -0,0 +1,60 @@
package dev.slimevr.desktop.install.drivers
import dev.slimevr.desktop.featureFlags
import io.eiren.util.logging.LogManager
class Linux {
val path: String = System.getProperty("user.dir")
fun updateLinux() {
updateLinuxSteamVRDriver()
feeder()
}
fun updateLinuxSteamVRDriver() {
val pathRegPath = "${System.getProperty("user.home")}/.steam/steam/steamapps/common/SteamVR/bin/vrpathreg.sh"
val vrPathRegContents = executeShellCommand(pathRegPath)
if (vrPathRegContents == null) {
LogManager.warning("SteamVR driver installation failed")
return
}
if (vrPathRegContents.contains("slimevr")) {
LogManager.info("SteamVR driver is already installed")
return
}
executeShellCommand(pathRegPath, "adddriver", "$path/$LINUX_STEAM_DRIVER_DIRECTORY")
if (executeShellCommand(pathRegPath)?.contains("slimevr") != true) {
LogManager.warning("Failed to install SteamVR driver")
return
}
LogManager.info("SteamVR driver successfully installed")
}
fun feeder() {
executeShellCommand("chmod", "+x", "$path/$LINUX_FEEDER_DIRECTORY/SlimeVR-Feeder-App")
val command = if (featureFlags.steam) {
arrayOf("steam-runtime-launch-client", "--alongside-steam", "--", "$path/$LINUX_FEEDER_DIRECTORY/SlimeVR-Feeder-App", "--install")
} else {
arrayOf("$path/$LINUX_FEEDER_DIRECTORY/SlimeVR-Feeder-App", "--install")
}
val feederOutput = executeShellCommand(*command)
if (feederOutput == null) {
LogManager.warning("Error installing feeder")
return
}
if (feederOutput.lowercase().contains("manifest is not installed")) {
LogManager.warning("Could not install feeder application")
} else {
LogManager.info("Successfully installed feeder application")
}
}
companion object {
private const val LINUX_STEAM_DRIVER_DIRECTORY = "slimevr-openvr-driver-x64-linux"
private const val LINUX_FEEDER_DIRECTORY = "SlimeVR-Feeder-App-Linux"
}
}

View File

@@ -0,0 +1,62 @@
package dev.slimevr.desktop.install.drivers
import com.sun.jna.platform.win32.WinReg
import dev.slimevr.desktop.games.vrchat.RegEditWindows
import io.eiren.util.logging.LogManager
class Windows {
val path: String = System.getProperty("user.dir")
fun updateWindows() {
steamVRDriver()
feeder()
}
fun feeder() {
val feederOutput = executeShellCommand("${path}\\${WINDOWS_FEEDER_DIRECTORY}\\SlimeVR-Feeder-App.exe", "--install")
if (feederOutput == null) {
LogManager.warning("Error installing feeder")
return
}
if (feederOutput.lowercase().contains("manifest is not installed")) {
LogManager.warning("Could not install feeder application")
} else {
LogManager.info("Successfully installed feeder application")
}
}
fun steamVRDriver() {
val regEdit = RegEditWindows()
val regQuery = regEdit.getKeyByPath(WinReg.HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Steam App 250820")
val steamVRLocation = regQuery["InstallLocation"]
if (steamVRLocation == null || !steamVRLocation.contains("SteamVR")) {
LogManager.warning("Can't find SteamVR, unable to install SteamVR driver")
return
}
val pathRegPath = "${steamVRLocation}\\bin\\win64\\vrpathreg.exe"
val vrPathRegContents = executeShellCommand(pathRegPath, "finddriver", "slimevr")
if (vrPathRegContents == null) {
LogManager.warning("Error installing SteamVR driver")
return
}
if (vrPathRegContents.contains("slimevr")) {
LogManager.info("SteamVR driver is already installed")
return
}
executeShellCommand(pathRegPath, "adddriver", "${path}\\${WINDOWS_STEAMVR_DRIVER_DIRECTORY}")
if (executeShellCommand(pathRegPath, "finddriver", "slimevr")?.contains("slimevr") != true) {
LogManager.warning("Failed to install SlimeVR driver")
return
}
LogManager.info("SteamVR driver successfully installed")
}
companion object {
private const val WINDOWS_STEAMVR_DRIVER_DIRECTORY = "slimevr-openvr-driver-win64"
private const val WINDOWS_FEEDER_DIRECTORY = "SlimeVR-Feeder-App-win64"
}
}

View File

@@ -7,7 +7,6 @@ import dev.slimevr.desktop.platform.ProtobufMessages.*
import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerStatus
import dev.slimevr.tracking.trackers.TrackerStatus.Companion.getById
import dev.slimevr.tracking.trackers.TrackerUtils
import dev.slimevr.util.ann.VRServerThread
import io.eiren.util.ann.Synchronize
import io.eiren.util.ann.ThreadSafe
@@ -219,11 +218,6 @@ abstract class ProtobufBridge(@JvmField protected val bridgeName: String) : ISte
"mounting_reset" -> instance.resetTrackersMounting(resetSourceName)
"feet_mounting_reset" -> instance.resetTrackersMounting(
resetSourceName,
TrackerUtils.feetsBodyParts,
)
"pause_tracking" ->
instance
.togglePauseTracking(resetSourceName)

View File

@@ -3558,20 +3558,6 @@ public final class ProtobufMessages {
* @return The trackerRole.
*/
int getTrackerRole();
/**
* <code>string manufacturer = 5;</code>
*
* @return The manufacturer.
*/
java.lang.String getManufacturer();
/**
* <code>string manufacturer = 5;</code>
*
* @return The bytes for manufacturer.
*/
com.google.protobuf.ByteString getManufacturerBytes();
}
/**
@@ -3602,7 +3588,6 @@ public final class ProtobufMessages {
private TrackerAdded() {
trackerSerial_ = "";
trackerName_ = "";
manufacturer_ = "";
}
public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() {
@@ -3728,48 +3713,6 @@ public final class ProtobufMessages {
return trackerRole_;
}
public static final int MANUFACTURER_FIELD_NUMBER = 5;
@SuppressWarnings("serial")
private volatile java.lang.Object manufacturer_ = "";
/**
* <code>string manufacturer = 5;</code>
*
* @return The manufacturer.
*/
@java.lang.Override
public java.lang.String getManufacturer() {
java.lang.Object ref = manufacturer_;
if (ref instanceof java.lang.String) {
return (java.lang.String) ref;
} else {
com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref;
java.lang.String s = bs.toStringUtf8();
manufacturer_ = s;
return s;
}
}
/**
* <code>string manufacturer = 5;</code>
*
* @return The bytes for manufacturer.
*/
@java.lang.Override
public com.google.protobuf.ByteString getManufacturerBytes() {
java.lang.Object ref = manufacturer_;
if (ref instanceof java.lang.String) {
com.google.protobuf.ByteString b = com.google.protobuf.ByteString
.copyFromUtf8(
(java.lang.String) ref
);
manufacturer_ = b;
return b;
} else {
return (com.google.protobuf.ByteString) ref;
}
}
private byte memoizedIsInitialized = -1;
@java.lang.Override
@@ -3799,9 +3742,6 @@ public final class ProtobufMessages {
if (trackerRole_ != 0) {
output.writeInt32(4, trackerRole_);
}
if (!com.google.protobuf.GeneratedMessage.isStringEmpty(manufacturer_)) {
com.google.protobuf.GeneratedMessage.writeString(output, 5, manufacturer_);
}
getUnknownFields().writeTo(output);
}
@@ -3826,9 +3766,6 @@ public final class ProtobufMessages {
size += com.google.protobuf.CodedOutputStream
.computeInt32Size(4, trackerRole_);
}
if (!com.google.protobuf.GeneratedMessage.isStringEmpty(manufacturer_)) {
size += com.google.protobuf.GeneratedMessage.computeStringSize(5, manufacturer_);
}
size += getUnknownFields().getSerializedSize();
memoizedSize = size;
return size;
@@ -3864,11 +3801,6 @@ public final class ProtobufMessages {
!= other.getTrackerRole()
)
return false;
if (
!getManufacturer()
.equals(other.getManufacturer())
)
return false;
if (!getUnknownFields().equals(other.getUnknownFields()))
return false;
return true;
@@ -3889,8 +3821,6 @@ public final class ProtobufMessages {
hash = (53 * hash) + getTrackerName().hashCode();
hash = (37 * hash) + TRACKER_ROLE_FIELD_NUMBER;
hash = (53 * hash) + getTrackerRole();
hash = (37 * hash) + MANUFACTURER_FIELD_NUMBER;
hash = (53 * hash) + getManufacturer().hashCode();
hash = (29 * hash) + getUnknownFields().hashCode();
memoizedHashCode = hash;
return hash;
@@ -4063,7 +3993,6 @@ public final class ProtobufMessages {
trackerSerial_ = "";
trackerName_ = "";
trackerRole_ = 0;
manufacturer_ = "";
return this;
}
@@ -4115,9 +4044,6 @@ public final class ProtobufMessages {
if (((from_bitField0_ & 0x00000008) != 0)) {
result.trackerRole_ = trackerRole_;
}
if (((from_bitField0_ & 0x00000010) != 0)) {
result.manufacturer_ = manufacturer_;
}
}
@java.lang.Override
@@ -4157,11 +4083,6 @@ public final class ProtobufMessages {
if (other.getTrackerRole() != 0) {
setTrackerRole(other.getTrackerRole());
}
if (!other.getManufacturer().isEmpty()) {
manufacturer_ = other.manufacturer_;
bitField0_ |= 0x00000010;
onChanged();
}
this.mergeUnknownFields(other.getUnknownFields());
onChanged();
return this;
@@ -4209,11 +4130,6 @@ public final class ProtobufMessages {
bitField0_ |= 0x00000008;
break;
} // case 32
case 42: {
manufacturer_ = input.readStringRequireUtf8();
bitField0_ |= 0x00000010;
break;
} // case 42
default: {
if (!super.parseUnknownField(input, extensionRegistry, tag)) {
done = true; // was an endgroup tag
@@ -4482,93 +4398,6 @@ public final class ProtobufMessages {
return this;
}
private java.lang.Object manufacturer_ = "";
/**
* <code>string manufacturer = 5;</code>
*
* @return The manufacturer.
*/
public java.lang.String getManufacturer() {
java.lang.Object ref = manufacturer_;
if (!(ref instanceof java.lang.String)) {
com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref;
java.lang.String s = bs.toStringUtf8();
manufacturer_ = s;
return s;
} else {
return (java.lang.String) ref;
}
}
/**
* <code>string manufacturer = 5;</code>
*
* @return The bytes for manufacturer.
*/
public com.google.protobuf.ByteString getManufacturerBytes() {
java.lang.Object ref = manufacturer_;
if (ref instanceof String) {
com.google.protobuf.ByteString b = com.google.protobuf.ByteString
.copyFromUtf8(
(java.lang.String) ref
);
manufacturer_ = b;
return b;
} else {
return (com.google.protobuf.ByteString) ref;
}
}
/**
* <code>string manufacturer = 5;</code>
*
* @param value The manufacturer to set.
* @return This builder for chaining.
*/
public Builder setManufacturer(
java.lang.String value
) {
if (value == null) {
throw new NullPointerException();
}
manufacturer_ = value;
bitField0_ |= 0x00000010;
onChanged();
return this;
}
/**
* <code>string manufacturer = 5;</code>
*
* @return This builder for chaining.
*/
public Builder clearManufacturer() {
manufacturer_ = getDefaultInstance().getManufacturer();
bitField0_ = (bitField0_ & ~0x00000010);
onChanged();
return this;
}
/**
* <code>string manufacturer = 5;</code>
*
* @param value The bytes for manufacturer to set.
* @return This builder for chaining.
*/
public Builder setManufacturerBytes(
com.google.protobuf.ByteString value
) {
if (value == null) {
throw new NullPointerException();
}
checkByteStringIsUtf8(value);
manufacturer_ = value;
bitField0_ |= 0x00000010;
onChanged();
return this;
}
// @@protoc_insertion_point(builder_scope:messages.TrackerAdded)
}
@@ -9043,55 +8872,53 @@ public final class ProtobufMessages {
+
"ctionArgumentsEntry\0326\n\024ActionArgumentsEn"
+
"try\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"|\n\014T"
"try\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"f\n\014T"
+
"rackerAdded\022\022\n\ntracker_id\030\001 \001(\005\022\026\n\016track"
+
"er_serial\030\002 \001(\t\022\024\n\014tracker_name\030\003 \001(\t\022\024\n"
+
"\014tracker_role\030\004 \001(\005\022\024\n\014manufacturer\030\005 \001("
"\014tracker_role\030\004 \001(\005\"\374\002\n\rTrackerStatus\022\022\n"
+
"\t\"\374\002\n\rTrackerStatus\022\022\n\ntracker_id\030\001 \001(\005\022"
"\ntracker_id\030\001 \001(\005\022.\n\006status\030\002 \001(\0162\036.mess"
+
".\n\006status\030\002 \001(\0162\036.messages.TrackerStatus"
"ages.TrackerStatus.Status\0221\n\005extra\030\003 \003(\013"
+
".Status\0221\n\005extra\030\003 \003(\0132\".messages.Tracke"
"2\".messages.TrackerStatus.ExtraEntry\022;\n\n"
+
"rStatus.ExtraEntry\022;\n\nconfidence\030\004 \001(\0162\""
"confidence\030\004 \001(\0162\".messages.TrackerStatu"
+
".messages.TrackerStatus.ConfidenceH\000\210\001\001\032"
"s.ConfidenceH\000\210\001\001\032,\n\nExtraEntry\022\013\n\003key\030\001"
+
",\n\nExtraEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001("
" \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"E\n\006Status\022\020\n\014DIS"
+
"\t:\0028\001\"E\n\006Status\022\020\n\014DISCONNECTED\020\000\022\006\n\002OK\020"
"CONNECTED\020\000\022\006\n\002OK\020\001\022\010\n\004BUSY\020\002\022\t\n\005ERROR\020\003"
+
"\001\022\010\n\004BUSY\020\002\022\t\n\005ERROR\020\003\022\014\n\010OCCLUDED\020\004\"3\n\n"
"\022\014\n\010OCCLUDED\020\004\"3\n\nConfidence\022\006\n\002NO\020\000\022\007\n\003"
+
"Confidence\022\006\n\002NO\020\000\022\007\n\003LOW\020\001\022\n\n\006MEDIUM\020\005\022"
"LOW\020\001\022\n\n\006MEDIUM\020\005\022\010\n\004HIGH\020\nB\r\n\013_confiden"
+
"\010\n\004HIGH\020\nB\r\n\013_confidence\"I\n\007Battery\022\022\n\nt"
"ce\"I\n\007Battery\022\022\n\ntracker_id\030\001 \001(\005\022\025\n\rbat"
+
"racker_id\030\001 \001(\005\022\025\n\rbattery_level\030\002 \001(\002\022\023"
"tery_level\030\002 \001(\002\022\023\n\013is_charging\030\003 \001(\010\"\241\002"
+
"\n\013is_charging\030\003 \001(\010\"\241\002\n\017ProtobufMessage\022"
"\n\017ProtobufMessage\022&\n\010position\030\001 \001(\0132\022.me"
+
"&\n\010position\030\001 \001(\0132\022.messages.PositionH\000\022"
"ssages.PositionH\000\022+\n\013user_action\030\002 \001(\0132\024"
+
"+\n\013user_action\030\002 \001(\0132\024.messages.UserActi"
".messages.UserActionH\000\022/\n\rtracker_added\030"
+
"onH\000\022/\n\rtracker_added\030\003 \001(\0132\026.messages.T"
"\003 \001(\0132\026.messages.TrackerAddedH\000\0221\n\016track"
+
"rackerAddedH\000\0221\n\016tracker_status\030\004 \001(\0132\027."
"er_status\030\004 \001(\0132\027.messages.TrackerStatus"
+
"messages.TrackerStatusH\000\022$\n\007battery\030\005 \001("
"H\000\022$\n\007battery\030\005 \001(\0132\021.messages.BatteryH\000"
+
"\0132\021.messages.BatteryH\000\022$\n\007version\030\006 \001(\0132"
"\022$\n\007version\030\006 \001(\0132\021.messages.VersionH\000B\t"
+
"\021.messages.VersionH\000B\t\n\007messageB2\n\034dev.s"
"\n\007messageB2\n\034dev.slimevr.desktop.platfor"
+
"limevr.desktop.platformB\020ProtobufMessage"
+
"sH\003b\006proto3"
"mB\020ProtobufMessagesH\003b\006proto3"
};
descriptor = com.google.protobuf.Descriptors.FileDescriptor
.internalBuildGeneratedFileFrom(
@@ -9130,8 +8957,7 @@ public final class ProtobufMessages {
internal_static_messages_TrackerAdded_descriptor = getDescriptor().getMessageTypes().get(4);
internal_static_messages_TrackerAdded_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable(
internal_static_messages_TrackerAdded_descriptor,
new java.lang.String[] { "TrackerId", "TrackerSerial", "TrackerName", "TrackerRole",
"Manufacturer", }
new java.lang.String[] { "TrackerId", "TrackerSerial", "TrackerName", "TrackerRole", }
);
internal_static_messages_TrackerStatus_descriptor = getDescriptor()
.getMessageTypes()

View File

@@ -147,13 +147,23 @@ abstract class SteamVRBridge(
val device = instance.deviceManager
.createDevice(
trackerAdded.trackerName,
null,
trackerAdded.manufacturer.ifEmpty { "OpenVR" },
trackerAdded.trackerSerial,
"OpenVR", // TODO : We need the manufacturer
)
// Display name, needsReset and isHmd
val displayName: String = trackerAdded.trackerName
val isHmd = trackerAdded.trackerId == 0
val displayName: String
val isHmd = if (trackerAdded.trackerId == 0) {
displayName = if (trackerAdded.trackerName == "HMD") {
"SteamVR Driver HMD"
} else {
"Feeder App HMD"
}
true
} else {
displayName = trackerAdded.trackerName
false
}
// trackerPosition
val role = getById(trackerAdded.trackerRole)