+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+export function ErrorBox({
+ children,
+ whitespace = true,
+ hideIcon = false,
+ className,
+}: {
+ children: ReactNode;
+ whitespace?: boolean;
+ hideIcon?: boolean;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
export function WarningBox({
children,
whitespace = true,
hideIcon = false,
+ className,
}: {
children: ReactNode;
whitespace?: boolean;
hideIcon?: boolean;
+ className?: string;
}) {
return (
-
+
-
+
void;
+ /**
+ * Function for primary action
+ */
+ primary: () => void;
+ /**
+ * Function for secondary action
+ */
+ secondary: () => void;
+} & ReactModal.Props) {
+ const { l10n } = useLocalization();
+
+ return (
+
+
+
+
+
+ Should the default settings or your current settings be used for
+ the new profile?
+
+
+
+
+ primary()}>
+ {l10n.getString('settings-utils-profiles-modal-default')}
+
+ secondary()}>
+ {l10n.getString('settings-utils-profiles-modal-copy')}
+
+
+
+ {l10n.getString('settings-utils-profiles-modal-cancel')}
+
+
+
+
+ );
+}
diff --git a/gui/src/components/settings/DeleteProfileModal.tsx b/gui/src/components/settings/DeleteProfileModal.tsx
new file mode 100644
index 0000000000..a76f7278f3
--- /dev/null
+++ b/gui/src/components/settings/DeleteProfileModal.tsx
@@ -0,0 +1,68 @@
+import { Button } from '@/components/commons/Button';
+import { WarningBox } from '@/components/commons/TipBox';
+import { Localized, useLocalization } from '@fluent/react';
+import { BaseModal } from '@/components/commons/BaseModal';
+import ReactModal from 'react-modal';
+
+export function DeleteProfileModal({
+ isOpen = true,
+ onClose,
+ accept,
+ profile,
+ ...props
+}: {
+ /**
+ * Is the parent/sibling component opened?
+ */
+ isOpen: boolean;
+ /**
+ * Function to trigger when the warning hasn't been accepted
+ */
+ onClose: () => void;
+ /**
+ * Function when accepting the modal
+ */
+ accept: () => void;
+ profile: string;
+} & ReactModal.Props) {
+ const { l10n } = useLocalization();
+
+ return (
+
+
+
+
}}
+ vars={{ name: profile }}
+ >
+
+ Warning: This will delete the selected profile ({profile}).
+ Are you sure you want to do this?
+
+
+
+
+
+ {l10n.getString('settings-utils-profiles-delete-warning-cancel')}
+
+ {
+ accept();
+ }}
+ >
+ {l10n.getString('settings-utils-profiles-delete-warning-done')}
+
+
+
+
+
+ );
+}
diff --git a/gui/src/components/settings/ProfileCreateErrorModal.tsx b/gui/src/components/settings/ProfileCreateErrorModal.tsx
new file mode 100644
index 0000000000..dbda489ba4
--- /dev/null
+++ b/gui/src/components/settings/ProfileCreateErrorModal.tsx
@@ -0,0 +1,49 @@
+import { Button } from '@/components/commons/Button';
+import { ErrorBox } from '@/components/commons/TipBox';
+import { Localized, useLocalization } from '@fluent/react';
+import { BaseModal } from '@/components/commons/BaseModal';
+import ReactModal from 'react-modal';
+
+export function ProfileCreateErrorModal({
+ isOpen = true,
+ onClose,
+ ...props
+}: {
+ /**
+ * Is the parent/sibling component opened?
+ */
+ isOpen: boolean;
+ /**
+ * Function to run to dismiss the modal
+ */
+ onClose: () => void;
+} & ReactModal.Props) {
+ const { l10n } = useLocalization();
+
+ return (
+
+
+
+
+
+ This profile name contains invalid characters or already exists.
+ Please choose another name.
+
+
+
+
+
+ {l10n.getString('settings-utils-profiles-new-cant-ok')}
+
+
+
+
+
+ );
+}
diff --git a/gui/src/components/settings/ProfileDeleteErrorModal.tsx b/gui/src/components/settings/ProfileDeleteErrorModal.tsx
new file mode 100644
index 0000000000..4c88d8dc1e
--- /dev/null
+++ b/gui/src/components/settings/ProfileDeleteErrorModal.tsx
@@ -0,0 +1,55 @@
+import { Button } from '@/components/commons/Button';
+import { ErrorBox } from '@/components/commons/TipBox';
+import { Localized, useLocalization } from '@fluent/react';
+import { BaseModal } from '@/components/commons/BaseModal';
+import ReactModal from 'react-modal';
+
+export function ProfileDeleteErrorModal({
+ isOpen = true,
+ onClose,
+ profile,
+ ...props
+}: {
+ /**
+ * Is the parent/sibling component opened?
+ */
+ isOpen: boolean;
+ /**
+ * Function to run to dismiss the modal
+ */
+ onClose: () => void;
+ profile: string;
+} & ReactModal.Props) {
+ const { l10n } = useLocalization();
+
+ return (
+
+
+
+
}}
+ vars={{ name: profile }}
+ >
+
+ You cannot delete this profile ({profile}). Please select another
+ profile to delete.
+
+
+
+
+
+ {l10n.getString('settings-utils-profiles-delete-cant-ok')}
+
+
+
+
+
+ );
+}
diff --git a/gui/src/components/settings/SettingsLayout.tsx b/gui/src/components/settings/SettingsLayout.tsx
index 491d53a8fc..752d7d0ba1 100644
--- a/gui/src/components/settings/SettingsLayout.tsx
+++ b/gui/src/components/settings/SettingsLayout.tsx
@@ -40,6 +40,10 @@ export function SettingSelectorMobile() {
label: l10n.getString('settings-sidebar-serial'),
value: { url: '/settings/serial' },
},
+ {
+ label: l10n.getString('settings-sidebar-profiles'),
+ value: { url: '/settings/profiles' },
+ },
{
label: l10n.getString('settings-sidebar-advanced'),
value: { url: '/settings/advanced' },
@@ -53,7 +57,7 @@ export function SettingSelectorMobile() {
});
useEffect(() => {
- // This works because the component gets mounted/unmounted when switching beween desktop or mobile layout
+ // This works because the component gets mounted/unmounted when switching between desktop or mobile layout
setValue('link', pathname, { shouldDirty: false, shouldTouch: false });
}, []);
@@ -80,7 +84,7 @@ export function SettingSelectorMobile() {
}))}
variant="tertiary"
direction="down"
- // There is always an option selected placholder is not used
+ // There is always an option selected placeholder is not used
placeholder=""
name="link"
>
diff --git a/gui/src/components/settings/SettingsSidebar.tsx b/gui/src/components/settings/SettingsSidebar.tsx
index e22fae70ed..43fef740d0 100644
--- a/gui/src/components/settings/SettingsSidebar.tsx
+++ b/gui/src/components/settings/SettingsSidebar.tsx
@@ -101,6 +101,11 @@ export function SettingsSidebar() {
{l10n.getString('settings-sidebar-serial')}
+
+
+ {l10n.getString('settings-sidebar-profiles')}
+
+
{l10n.getString('settings-sidebar-advanced')}
diff --git a/gui/src/components/settings/pages/AdvancedSettings.tsx b/gui/src/components/settings/pages/AdvancedSettings.tsx
index 1df13fa03d..3f5fc2a482 100644
--- a/gui/src/components/settings/pages/AdvancedSettings.tsx
+++ b/gui/src/components/settings/pages/AdvancedSettings.tsx
@@ -42,8 +42,8 @@ export function AdvancedSettings() {
try {
const configPath = await appConfigDir();
await open('file://' + configPath);
- } catch (err) {
- error('Failed to open config folder:', err);
+ } catch (e) {
+ error('Failed to open config folder:', e);
}
};
diff --git a/gui/src/components/settings/pages/Profiles.tsx b/gui/src/components/settings/pages/Profiles.tsx
new file mode 100644
index 0000000000..3050382032
--- /dev/null
+++ b/gui/src/components/settings/pages/Profiles.tsx
@@ -0,0 +1,323 @@
+import { useLocalization } from '@fluent/react';
+import { useEffect, useMemo, useState } from 'react';
+import { Typography } from '@/components/commons/Typography';
+import {
+ SettingsPageLayout,
+ SettingsPagePaneLayout,
+} from '@/components/settings/SettingsPageLayout';
+import { WrenchIcon } from '@/components/commons/icon/WrenchIcons';
+import { Button } from '@/components/commons/Button';
+
+import { error, log } from '@/utils/logging';
+import { useConfig } from '@/hooks/config';
+import { CreateProfileModal } from '@/components/settings/CreateProfileModal';
+import { ProfileCreateErrorModal } from '@/components/settings/ProfileCreateErrorModal';
+import { DeleteProfileModal } from '@/components/settings/DeleteProfileModal';
+import { ProfileDeleteErrorModal } from '@/components/settings/ProfileDeleteErrorModal';
+import { Input } from '@/components/commons/Input';
+import { useForm } from 'react-hook-form';
+import { Dropdown } from '@/components/commons/Dropdown';
+import { useWebsocketAPI } from '@/hooks/websocket-api';
+import { RpcMessage, ChangeProfileRequestT } from 'solarxr-protocol';
+
+export function ProfileSettings() {
+ const { l10n } = useLocalization();
+ const { config, getCurrentProfile, getProfiles, setProfile, deleteProfile } =
+ useConfig();
+ const [profiles, setProfiles] = useState([]);
+ const [showCreatePrompt, setShowCreatePrompt] = useState(false);
+ const [showCreateError, setShowCreateError] = useState(false);
+ const [showDeleteWarning, setShowDeleteWarning] = useState(false);
+ const [showDeleteError, setShowDeleteError] = useState(false);
+
+ const { sendRPCPacket } = useWebsocketAPI();
+
+ const profileItems = useMemo(() => {
+ // Add default profile to dropdown
+ const defaultProfile = { label: 'Default profile', value: 'default' };
+ const mappedProfiles = profiles.map((profile) => ({
+ label: profile,
+ value: profile,
+ }));
+
+ return [defaultProfile, ...mappedProfiles];
+ }, [profiles]);
+
+ // Fetch profiles on load
+ useEffect(() => {
+ const fetchProfiles = async () => {
+ const profiles = await getProfiles();
+ setProfiles(profiles);
+ };
+
+ fetchProfiles();
+ }, []);
+
+ // Set profile value on load, watch if profile switches
+ useEffect(() => {
+ const getProfile = async () => {
+ const currentProfile = await getCurrentProfile();
+ setProfileValue('profile', currentProfile);
+ };
+ getProfile();
+
+ const subscription = watchProfileSubmit(() => {
+ handleProfileSubmit(onSelectSubmit)();
+ });
+ return () => subscription.unsubscribe();
+ }, [profiles]);
+
+ // is there a better way to do this, theres a bunch of stuff here lol
+ const {
+ control: nameControl,
+ watch: watchNameSubmit,
+ handleSubmit: handleNameSubmit,
+ } = useForm<{
+ newName: string;
+ }>({
+ defaultValues: { newName: '' },
+ });
+
+ const {
+ control: profileControl,
+ watch: watchProfileSubmit,
+ handleSubmit: handleProfileSubmit,
+ setValue: setProfileValue,
+ } = useForm<{
+ profile: string;
+ }>({
+ defaultValues: { profile: config?.profile },
+ });
+
+ const {
+ control: deleteControl,
+ handleSubmit: handleDeleteControl,
+ watch: watchDeleteControl,
+ } = useForm<{
+ profile: string;
+ }>({
+ defaultValues: { profile: config?.profile || 'default' },
+ });
+
+ const profileToCreate = watchNameSubmit('newName');
+ const onNameSubmit = async (data: { newName: string }) => {
+ if (!data.newName) return;
+
+ const invalidCharsRegex = /[<>:"/\\|?*]/;
+ if (data.newName === 'default' || invalidCharsRegex.test(data.newName)) {
+ error('Invalid profile name');
+ setShowCreateError(true);
+ return;
+ }
+
+ setShowCreatePrompt(true);
+ };
+
+ const onSelectSubmit = (data: { profile: string }) => {
+ setProfile(data.profile);
+ sendRPCPacket(
+ RpcMessage.ChangeProfileRequest,
+ new ChangeProfileRequestT(data.profile)
+ );
+ };
+
+ const profileToDelete = watchDeleteControl('profile');
+ const onDeleteSelectSubmit = async (data: { profile: string }) => {
+ if (data.profile === 'default') {
+ error('Cannot delete default profile');
+ setShowDeleteError(true);
+ return;
+ }
+
+ log(`Deleting profile ${data.profile}`);
+
+ try {
+ await deleteProfile(data.profile);
+ } catch (e) {
+ error(e);
+ setShowDeleteError(true);
+ return;
+ }
+
+ // Update profiles list
+ const profiles = await getProfiles();
+ setProfiles(profiles);
+ };
+
+ const createProfile = async (name: string, useDefault: boolean) => {
+ const profiles = await getProfiles();
+ if (profiles.includes(name)) {
+ error(`Profile with name ${name} already exists`);
+ setShowCreateError(true);
+ return;
+ }
+
+ log(`Creating new profile with name ${name} with defaults: ${useDefault}`);
+
+ try {
+ if (!useDefault) {
+ const currentConfig = config;
+ if (!currentConfig)
+ throw new Error(
+ 'cannot copy current settings because.. current config does not exist?'
+ );
+ await setProfile(name, currentConfig);
+ } else {
+ // config.ts automatically uses default config if no config is passed
+ await setProfile(name);
+ }
+ } catch (e) {
+ error(e);
+ setShowCreateError(true);
+ return;
+ }
+
+ // Update profiles list
+ setProfiles(profiles.concat(name));
+ };
+
+ return (
+
+ } id="profiles">
+ <>
+
+ {l10n.getString('settings-utils-profiles')}
+
+
+ <>
+ {l10n
+ .getString('settings-utils-profiles-description')
+ .split('\n')
+ .map((line, i) => (
+
+ {line}
+
+ ))}
+ >
+
+
+
+ {l10n.getString('settings-utils-profiles-profile')}
+
+
+
+ {l10n.getString('settings-utils-profiles-profile-description')}
+
+
+
+
+
+
+
+
+
+
+ {l10n.getString('settings-utils-profiles-new')}
+
+
+
+ {l10n.getString('settings-utils-profiles-new-description')}
+
+
+
+
{
+ setShowCreatePrompt(false);
+ createProfile(profileToCreate, true);
+ }}
+ secondary={() => {
+ setShowCreatePrompt(false);
+ createProfile(profileToCreate, false);
+ }}
+ onClose={() => setShowCreatePrompt(false)}
+ isOpen={showCreatePrompt}
+ >
+
+
+
+
+ {l10n.getString('settings-utils-profiles-delete')}
+
+
+
+ {l10n.getString('settings-utils-profiles-delete-description')}
+
+
+
+
+
+
+
{
+ setShowDeleteWarning(true);
+ }}
+ style={{ flexBasis: '35%' }}
+ >
+ {l10n.getString('settings-utils-profiles-delete-label')}
+
+
{
+ handleDeleteControl(onDeleteSelectSubmit)();
+ setShowDeleteWarning(false);
+ }}
+ onClose={() => setShowDeleteWarning(false)}
+ isOpen={showDeleteWarning}
+ profile={profileToDelete}
+ >
+
setShowCreateError(false)}
+ >
+
setShowDeleteError(false)}
+ profile={profileToDelete}
+ >
+
+
+
+ >
+
+
+ );
+}
diff --git a/gui/src/hooks/config.ts b/gui/src/hooks/config.ts
index 6cb228d7a6..e1946f230e 100644
--- a/gui/src/hooks/config.ts
+++ b/gui/src/hooks/config.ts
@@ -1,10 +1,12 @@
-import { createContext, useContext, useState } from 'react';
+import { createContext, useContext, useEffect, useState } from 'react';
import { DeveloperModeWidgetForm } from '@/components/widgets/DeveloperModeWidget';
-import { error } from '@/utils/logging';
+import { error, log } from '@/utils/logging';
import { useDebouncedEffect } from './timeout';
import { createStore, Store } from '@tauri-apps/plugin-store';
import { useIsTauri } from './breakpoint';
import { waitUntil } from '@/utils/a11y';
+import { appConfigDir, resolve } from '@tauri-apps/api/path';
+import { exists, mkdir, readDir, remove, writeTextFile } from '@tauri-apps/plugin-fs';
import { isTauri } from '@tauri-apps/api/core';
export interface WindowConfig {
@@ -40,6 +42,7 @@ export interface Config {
discordPresence: boolean;
decorations: boolean;
showNavbarOnboarding: boolean;
+ profile: string;
}
export interface ConfigContext {
@@ -48,6 +51,10 @@ export interface ConfigContext {
setConfig: (config: Partial) => Promise;
loadConfig: () => Promise;
saveConfig: () => Promise;
+ getCurrentProfile: () => Promise;
+ getProfiles: () => Promise;
+ setProfile: (profile: string, config?: Config) => Promise;
+ deleteProfile: (profile: string) => Promise;
}
export const defaultConfig: Omit = {
@@ -67,6 +74,7 @@ export const defaultConfig: Omit = {
discordPresence: false,
decorations: false,
showNavbarOnboarding: true,
+ profile: 'default',
};
interface CrossStorage {
@@ -74,12 +82,14 @@ interface CrossStorage {
get(key: string): Promise;
}
+let configToSet: Config | null | undefined = null;
+
const localStore: CrossStorage = {
get: async (key) => localStorage.getItem(key),
set: async (key, value) => localStorage.setItem(key, value),
};
-const store: CrossStorage = isTauri()
+let store: CrossStorage = isTauri()
? await createStore('gui-settings.dat')
: localStore;
@@ -90,6 +100,7 @@ function fallbackToDefaults(loadedConfig: any): Config {
export function useConfigProvider(): ConfigContext {
const [currConfig, set] = useState(null);
const [loading, setLoading] = useState(false);
+ const [currentProfile, setCurrentProfile] = useState('default');
const tauri = useIsTauri();
useDebouncedEffect(
@@ -102,6 +113,53 @@ export function useConfigProvider(): ConfigContext {
100
);
+ // Load profile when changed
+ useEffect(() => {
+ loadProfileConfig(currentProfile);
+ }, [currentProfile]);
+
+ const getStore = async (profile: string) => {
+ if (profile === 'default') return await createStore('gui-settings.dat');
+
+ const appDirectory = await appConfigDir();
+ const profileDir = await resolve(`${appDirectory}/profiles/${profile}`);
+ const profileFile = await resolve(`${profileDir}/gui-settings.dat`);
+
+ // Check if profile directory exists before creating it
+ const profileDirExists = await exists(profileDir);
+ if (!profileDirExists) {
+ await mkdir(profileDir, { recursive: true });
+ }
+
+ // Check if profile file exists before writing defaults
+ const profileFileExists = await exists(profileFile);
+ if (!profileFileExists) {
+ await writeTextFile(
+ profileFile,
+ JSON.stringify({ 'config.json': JSON.stringify(defaultConfig) })
+ );
+ }
+
+ return createStore(profileFile);
+ };
+
+ const loadProfileConfig = async (profile: string) => {
+ const profileStore = await getStore(profile);
+ store = profileStore;
+ const json = await store.get('config.json');
+ let loadedConfig = fallbackToDefaults(JSON.parse(json ?? '{}'));
+ if (configToSet) {
+ // Used when user creates a profile in GUI and chose to copy their settings
+ loadedConfig = { ...configToSet };
+ configToSet = null;
+ }
+ set(loadedConfig);
+ // FIXME for some reason, this function is apparently ran multiple times and so this log is printed multiple times
+ // small, but kinda annoys me lol
+ log(`Loaded profile: ${profile}`);
+ return loadedConfig;
+ };
+
const setConfig = async (config: Partial) => {
set((curr) =>
config
@@ -117,6 +175,7 @@ export function useConfigProvider(): ConfigContext {
const newConfig: Partial = JSON.parse(
(await store.get('config.json')) ?? '{}'
);
+
return Object.entries(config).every(([key, value]) =>
typeof value === 'object'
? JSON.stringify(newConfig[key as keyof Config]) === JSON.stringify(value)
@@ -144,6 +203,75 @@ export function useConfigProvider(): ConfigContext {
}
};
+ const getCurrentProfile = async () => {
+ const defaultStore = await getStore('default');
+ const defaultConfig = fallbackToDefaults(
+ JSON.parse((await defaultStore.get('config.json')) ?? '{}')
+ );
+ return defaultConfig.profile ?? 'default';
+ };
+
+ const getProfiles = async () => {
+ // artificial delay to let profile write to disk (when created) before we actually read it
+ // fixes "default" being selected when new profile is created (because folder hasn't been made)
+ // idk why this is necessary but it is
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ const appDirectory = await appConfigDir();
+ const profilesDir = await resolve(`${appDirectory}/profiles`);
+ await mkdir(profilesDir, { recursive: true });
+ const profiles = await readDir(profilesDir);
+
+ return profiles.map((profile) => profile.name);
+ };
+
+ const setProfile = async (profile: string, config?: Config) => {
+ // load default config, set profile to new profile, save config, then load new profile config
+ // idk if this is the best way to do this lol
+ const defaultStore = await getStore('default');
+ const defaultConfig = fallbackToDefaults(
+ JSON.parse((await defaultStore.get('config.json')) ?? '{}')
+ );
+ await defaultStore.set(
+ 'config.json',
+ JSON.stringify({
+ ...defaultConfig,
+ profile,
+ })
+ );
+ await defaultStore.save();
+
+ // Used when user creates a profile in GUI and chose to copy their settings
+ if (config) configToSet = config;
+ setCurrentProfile(profile);
+ };
+
+ const deleteProfile = async (profile: string) => {
+ if (profile === 'default') return;
+
+ // Remove profile directory
+ const appDirectory = await appConfigDir();
+ const profileDir = await resolve(`${appDirectory}/profiles/${profile}`);
+ await remove(profileDir, { recursive: true });
+
+ // Set profile in default config
+ const defaultStore = await getStore('default');
+ const defaultConfig = fallbackToDefaults(
+ JSON.parse((await defaultStore.get('config.json')) ?? '{}')
+ );
+ await defaultStore.set(
+ 'config.json',
+ JSON.stringify({
+ ...defaultConfig,
+ profile: 'default',
+ })
+ );
+
+ // Set to default if deleting current profile
+ if (profile === currentProfile) {
+ setCurrentProfile('default');
+ }
+ };
+
return {
config: currConfig,
loading,
@@ -157,16 +285,19 @@ export function useConfigProvider(): ConfigContext {
if (oldConfig) await store.set('config.json', oldConfig);
- store.set('configMigratedToTauri', 'true');
+ await store.set('configMigratedToTauri', 'true');
}
const json = await store.get('config.json');
-
if (!json) throw new Error('Config has ceased existing for some reason');
- const loadedConfig = fallbackToDefaults(JSON.parse(json));
- set(loadedConfig);
+ let loadedConfig = fallbackToDefaults(JSON.parse(json));
+ const profile = loadedConfig.profile;
+ if (profile && profile !== 'default') {
+ loadedConfig = await loadProfileConfig(profile);
+ }
+ set(loadedConfig);
setLoading(false);
return loadedConfig;
} catch (e) {
@@ -180,6 +311,10 @@ export function useConfigProvider(): ConfigContext {
if (!tauri) return;
await (store as Store).save();
},
+ getCurrentProfile,
+ getProfiles,
+ setProfile,
+ deleteProfile,
};
}
diff --git a/server/android/src/main/java/dev/slimevr/android/Main.kt b/server/android/src/main/java/dev/slimevr/android/Main.kt
index 63361fdd4b..d40aba0e13 100644
--- a/server/android/src/main/java/dev/slimevr/android/Main.kt
+++ b/server/android/src/main/java/dev/slimevr/android/Main.kt
@@ -7,6 +7,7 @@ import android.net.wifi.WifiManager
import androidx.appcompat.app.AppCompatActivity
import dev.slimevr.Keybinding
import dev.slimevr.VRServer
+import dev.slimevr.CONFIG_FILENAME
import dev.slimevr.android.serial.AndroidSerialHandler
import io.eiren.util.logging.LogManager
import io.ktor.http.CacheControl
@@ -50,7 +51,7 @@ fun main(activity: AppCompatActivity) {
try {
vrServer = VRServer(
- configPath = File(activity.filesDir, "vrconfig.yml").absolutePath,
+ configPath = File(activity.filesDir, CONFIG_FILENAME).absolutePath,
serialHandlerProvider = { _ -> AndroidSerialHandler(activity) },
acquireMulticastLock = {
val wifi = activity.getSystemService(Context.WIFI_SERVICE) as WifiManager
diff --git a/server/core/src/main/java/dev/slimevr/VRServer.kt b/server/core/src/main/java/dev/slimevr/VRServer.kt
index 4bd35390e3..2d74dda77d 100644
--- a/server/core/src/main/java/dev/slimevr/VRServer.kt
+++ b/server/core/src/main/java/dev/slimevr/VRServer.kt
@@ -42,6 +42,7 @@ typealias SteamBridgeProvider = (
) -> ISteamVRBridge?
const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR"
+const val CONFIG_FILENAME = "vrconfig.yml"
class VRServer @JvmOverloads constructor(
driverBridgeProvider: SteamBridgeProvider = { _, _ -> null },
diff --git a/server/core/src/main/java/dev/slimevr/config/ConfigManager.java b/server/core/src/main/java/dev/slimevr/config/ConfigManager.java
index 33638cc973..fedceb864d 100644
--- a/server/core/src/main/java/dev/slimevr/config/ConfigManager.java
+++ b/server/core/src/main/java/dev/slimevr/config/ConfigManager.java
@@ -20,7 +20,7 @@
public class ConfigManager {
- private final String configPath;
+ private String configPath;
private final ObjectMapper om;
@@ -53,7 +53,11 @@ public void loadConfig() {
if (this.vrConfig == null) {
this.vrConfig = new VRConfig();
+ LogManager.info("VRConfig not found, using default values");
+ return;
}
+
+ LogManager.info("Config loaded from \"" + configPath + "\"");
}
public void atomicMove(Path from, Path to) throws IOException {
@@ -144,6 +148,16 @@ public void resetConfig() {
saveConfig();
}
+ public void setConfigPath(String newPath) {
+ if (newPath.equals(configPath)) {
+ return;
+ }
+ this.configPath = newPath;
+ loadConfig();
+ saveConfig();
+ LogManager.info("Config path set to \"" + configPath + "\"");
+ }
+
public VRConfig getVrConfig() {
return vrConfig;
}
diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsHandler.kt
index dae1e2b106..e528f8c79c 100644
--- a/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsHandler.kt
+++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsHandler.kt
@@ -1,6 +1,8 @@
package dev.slimevr.protocol.rpc.settings
import com.google.flatbuffers.FlatBufferBuilder
+import dev.slimevr.SLIMEVR_IDENTIFIER
+import dev.slimevr.CONFIG_FILENAME
import dev.slimevr.bridge.ISteamVRBridge
import dev.slimevr.config.ArmsResetModes
import dev.slimevr.filtering.TrackerFilters
@@ -10,10 +12,16 @@ import dev.slimevr.protocol.rpc.RPCHandler
import dev.slimevr.tracking.processor.config.SkeletonConfigToggles
import dev.slimevr.tracking.processor.config.SkeletonConfigValues
import dev.slimevr.tracking.trackers.TrackerRole
+import io.eiren.util.OperatingSystem
+import io.eiren.util.logging.LogManager
+import solarxr_protocol.rpc.ChangeProfileRequest
import solarxr_protocol.rpc.ChangeSettingsRequest
import solarxr_protocol.rpc.RpcMessage
import solarxr_protocol.rpc.RpcMessageHeader
import solarxr_protocol.rpc.SettingsResponse
+import kotlin.io.path.Path
+import kotlin.io.path.createDirectories
+import kotlin.io.path.exists
import kotlin.math.*
class RPCSettingsHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) {
@@ -36,9 +44,12 @@ class RPCSettingsHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) {
rpcHandler.registerPacketListener(RpcMessage.SettingsResetRequest) { conn: GenericConnection, messageHeader: RpcMessageHeader? ->
this.onSettingsResetRequest(conn, messageHeader)
}
+ rpcHandler.registerPacketListener(RpcMessage.ChangeProfileRequest) { conn: GenericConnection, messageHeader: RpcMessageHeader? ->
+ this.onChangeProfileRequest(conn, messageHeader)
+ }
}
- fun onSettingsRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) {
+ private fun onSettingsRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) {
val fbb = FlatBufferBuilder(32)
val settings = RPCSettingsBuilder.createSettingsResponse(fbb, api.server)
@@ -47,7 +58,7 @@ class RPCSettingsHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) {
conn.send(fbb.dataBuffer())
}
- fun onChangeSettingsRequest(conn: GenericConnection?, messageHeader: RpcMessageHeader) {
+ private fun onChangeSettingsRequest(conn: GenericConnection?, messageHeader: RpcMessageHeader) {
val req = messageHeader
.message(ChangeSettingsRequest()) as? ChangeSettingsRequest ?: return
@@ -348,10 +359,38 @@ class RPCSettingsHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) {
api.server.configManager.saveConfig()
}
- fun onSettingsResetRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) {
+ private fun onSettingsResetRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) {
api.server.configManager.resetConfig()
}
+ private fun onChangeProfileRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) {
+ val req = messageHeader?.message(ChangeProfileRequest()) as? ChangeProfileRequest ?: return
+ val profile = req.profileName()
+ // TODO implement profile types (tracking/proportions)
+ val type = req.type()
+
+ // trim() is needed, for some reason?
+ if (profile.trim() == "default") {
+ val defaultPath = OperatingSystem.resolveConfigDirectory(SLIMEVR_IDENTIFIER)?.resolve(CONFIG_FILENAME).toString()
+ api.server.configManager.setConfigPath(defaultPath)
+ LogManager.info("Loaded default profile")
+ return
+ }
+
+ val configDir = OperatingSystem.resolveConfigDirectory(SLIMEVR_IDENTIFIER)
+ val profileDir = Path(configDir.toString() + "/profiles/$profile")
+
+ if (!profileDir.exists()) {
+ profileDir.createDirectories()
+ LogManager.info("Profile directory created: $profileDir")
+ }
+
+ // load profile
+ val profilePath = Path("$profileDir/${CONFIG_FILENAME}").toString()
+ api.server.configManager.setConfigPath(profilePath)
+ LogManager.info("Loaded profile: $profile")
+ }
+
companion object {
fun sendSteamVRUpdatedSettings(api: ProtocolAPI, rpcHandler: RPCHandler) {
val fbb = FlatBufferBuilder(32)
diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt
index ce36aca4be..16639e6cc0 100644
--- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt
+++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt
@@ -4,6 +4,7 @@ package dev.slimevr.desktop
import dev.slimevr.Keybinding
import dev.slimevr.SLIMEVR_IDENTIFIER
+import dev.slimevr.CONFIG_FILENAME
import dev.slimevr.VRServer
import dev.slimevr.bridge.ISteamVRBridge
import dev.slimevr.desktop.platform.SteamVRBridge
@@ -236,7 +237,6 @@ fun provideFeederBridge(
return feederBridge
}
-const val CONFIG_FILENAME = "vrconfig.yml"
fun resolveConfig(): String {
// If config folder exists, then save config on relative path
if (Path("config/").exists()) {
diff --git a/solarxr-protocol b/solarxr-protocol
index e4540a5129..12fa3072f9 160000
--- a/solarxr-protocol
+++ b/solarxr-protocol
@@ -1 +1 @@
-Subproject commit e4540a5129140b5f3aa30885030910daa47768fc
+Subproject commit 12fa3072f937a08f6d1a0c934fdd170f014cd90c