diff --git a/gui/electron/main/cli.ts b/gui/electron/main/cli.ts index 3171baee4..4d706753e 100644 --- a/gui/electron/main/cli.ts +++ b/gui/electron/main/cli.ts @@ -1,12 +1,20 @@ -import { program } from "commander"; +import { Option, program } from "commander"; program - .option('-p --path ', 'set launch path') + .option('-p, --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(); diff --git a/gui/electron/main/index.ts b/gui/electron/main/index.ts index 35f29090f..aa801c776 100644 --- a/gui/electron/main/index.ts +++ b/gui/electron/main/index.ts @@ -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, { diff --git a/gui/electron/main/paths.ts b/gui/electron/main/paths.ts index 437901022..fb5a17be1 100644 --- a/gui/electron/main/paths.ts +++ b/gui/electron/main/paths.ts @@ -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'); diff --git a/gui/electron/preload/index.ts b/gui/electron/preload/index.ts index e9682ca0d..fe2d72280 100644 --- a/gui/electron/preload/index.ts +++ b/gui/electron/preload/index.ts @@ -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); diff --git a/gui/electron/preload/interface.d.ts b/gui/electron/preload/interface.d.ts index 03c47a1f7..9554339e3 100644 --- a/gui/electron/preload/interface.d.ts +++ b/gui/electron/preload/interface.d.ts @@ -55,6 +55,7 @@ export interface IElectronAPI { openFile: (path: string) => void; ghGet: (options: T) => Promise; setPresence: (options: DiscordPresence) => void; + getInstallDir: () => Promise; } declare global { diff --git a/gui/electron/shared.ts b/gui/electron/shared.ts index 1364550bd..6949ade5d 100644 --- a/gui/electron/shared.ts +++ b/gui/electron/shared.ts @@ -41,7 +41,7 @@ export interface IpcInvokeMap { value?: unknown; }) => Promise; [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]: ( options: T ) => Promise; diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index 9d7d14766..a16b62035 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -985,6 +985,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! diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 555925671..b120f6279 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -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'; @@ -54,6 +55,7 @@ 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(''); @@ -71,6 +73,7 @@ function Layout() { + }> } /> + } + /> } /> } /> } /> diff --git a/gui/src/AppLayout.tsx b/gui/src/AppLayout.tsx index 6e0aee1c2..ab067c3a9 100644 --- a/gui/src/AppLayout.tsx +++ b/gui/src/AppLayout.tsx @@ -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 ( <> diff --git a/gui/src/components/ErrorConsentModal.tsx b/gui/src/components/ErrorConsentModal.tsx deleted file mode 100644 index 358a80e16..000000000 --- a/gui/src/components/ErrorConsentModal.tsx +++ /dev/null @@ -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 ( - -
- <> -
-
- - {l10n.getString('error_collection_modal-title')} - - , - h1: , - }} - > - - -
-
- - - - -
-
- ); -} diff --git a/gui/src/components/TopBar.tsx b/gui/src/components/TopBar.tsx index e15006485..62cc5d526 100644 --- a/gui/src/components/TopBar.tsx +++ b/gui/src/components/TopBar.tsx @@ -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); }} /> - setConfig({ errorTracking: true })} - cancel={() => setConfig({ errorTracking: false })} - /> ); } diff --git a/gui/src/components/onboarding/UdevRulesModal.tsx b/gui/src/components/onboarding/UdevRulesModal.tsx new file mode 100644 index 000000000..b89af1e73 --- /dev/null +++ b/gui/src/components/onboarding/UdevRulesModal.tsx @@ -0,0 +1,113 @@ +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 { useWebsocketAPI } from '@/hooks/websocket-api'; +import { RpcMessage, InstalledInfoResponseT } from 'solarxr-protocol'; +import { useConfig } from '@/hooks/config'; +import { useLocalization } from '@fluent/react'; + +export function UdevRulesModal() { + const { config, setConfig } = useConfig(); + const { useRPCPacket, sendRPCPacket } = useWebsocketAPI(); + const electron = useElectron(); + const [udevContent, setUdevContent] = useState(''); + const [isUdevInstalledResponse, setIsUdevInstalledResponse] = useState(true); + const [showUdevWarning, setShowUdevWarning] = useState(false); + const [dontShowThisSession, setDontShowThisSession] = useState(false); + const [dontShowAgain, setDontShowAgain] = useState(false); + const { l10n } = useLocalization(); + + 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 = !isUdevInstalledResponse; + const notHiddenGlobally = !config.dontShowUdevModal; + const notHiddenThisSession = !dontShowThisSession; + const shouldShow = + isLinux && udevMissing && notHiddenGlobally && notHiddenThisSession; + setShowUdevWarning(shouldShow); + } + }, [config, isUdevInstalledResponse, dontShowThisSession]); + + useEffect(() => { + sendRPCPacket( + RpcMessage.InstalledInfoRequest, + new InstalledInfoResponseT() + ); + }, []); + + useRPCPacket( + RpcMessage.InstalledInfoResponse, + ({ isUdevInstalled }: InstalledInfoResponseT) => { + setIsUdevInstalledResponse(isUdevInstalled); + } + ); + + const handleModalClose = () => { + if (!config) throw 'Invalid State!'; + setConfig({ dontShowUdevModal: dontShowAgain }); + setDontShowThisSession(true); + }; + + const copyToClipboard = () => { + navigator.clipboard.writeText(udevContent); + }; + + return ( + +
+
+
+ + +
+
+
+ +
+
+
{udevContent}
+
+
+
+
+ setDontShowAgain(e.currentTarget.checked)} + /> +
+
+
+ ); +} diff --git a/gui/src/components/onboarding/pages/ErrorCollectingConsent.tsx b/gui/src/components/onboarding/pages/ErrorCollectingConsent.tsx new file mode 100644 index 000000000..5be6404e5 --- /dev/null +++ b/gui/src/components/onboarding/pages/ErrorCollectingConsent.tsx @@ -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 ( +
+
+
+ + , + h1: , + }} + > + + +
+
+
+
+
+ ); +} diff --git a/gui/src/components/onboarding/pages/Home.tsx b/gui/src/components/onboarding/pages/Home.tsx index 019943a0e..bff43dce0 100644 --- a/gui/src/components/onboarding/pages/Home.tsx +++ b/gui/src/components/onboarding/pages/Home.tsx @@ -19,9 +19,11 @@ export function HomePage() { {l10n.getString('onboarding-home')} - +