diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index 1bf5064d36..7fbfaeaef4 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -280,6 +280,7 @@ settings-sidebar-utils = Utilities settings-sidebar-serial = Serial console settings-sidebar-appearance = Appearance settings-sidebar-notifications = Notifications +settings-sidebar-profiles = Profiles settings-sidebar-advanced = Advanced ## SteamVR settings @@ -604,6 +605,44 @@ settings-osc-vmc-mirror_tracking = Mirror tracking settings-osc-vmc-mirror_tracking-description = Mirror the tracking horizontally. settings-osc-vmc-mirror_tracking-label = Mirror tracking +## Profile settings +settings-utils-profiles = Profiles +settings-utils-profiles-description = Manage your settings profiles to quickly switch between settings: GUI, tracking, body proportions, etc. + +settings-utils-profiles-default = Default profile + +settings-utils-profiles-profile = Current profile +settings-utils-profiles-profile-description = Choose a profile to load or save your settings to. + +settings-utils-profiles-new = Create new profile +settings-utils-profiles-new-description = Create a new profile with the defaults or your current settings. +settings-utils-profiles-new-label = Create +settings-utils-profiles-modal = + Would you like to use the default settings or copy your current + settings for your new profile? +settings-utils-profiles-modal-default = Default settings +settings-utils-profiles-modal-copy = Copy settings +settings-utils-profiles-modal-cancel = Cancel + +settings-utils-profiles-new-cant = + This profile name contains invalid characters or already exists. + Please choose another name. +settings-utils-profiles-new-cant-ok = OK + +settings-utils-profiles-delete = Delete profile +settings-utils-profiles-delete-description = Delete the selected profile. +settings-utils-profiles-delete-label = Delete profile +settings-utils-profiles-delete-warning = + Warning: This will delete the selected profile ({ $name }). + Are you sure you want to do this? +settings-utils-profiles-delete-warning-done = Yes +settings-utils-profiles-delete-warning-cancel = Cancel + +settings-utils-profiles-delete-cant = + You cannot delete this profile ({ $name }). Please select + another profile to delete. +settings-utils-profiles-delete-cant-ok = OK + ## Advanced settings settings-utils-advanced = Advanced diff --git a/gui/src-tauri/capabilities/migrated.json b/gui/src-tauri/capabilities/migrated.json index 5e8916e035..4a7808601f 100644 --- a/gui/src-tauri/capabilities/migrated.json +++ b/gui/src-tauri/capabilities/migrated.json @@ -22,13 +22,15 @@ "core:window:allow-show", "core:window:allow-set-focus", "core:window:allow-set-decorations", - "store:default", "os:allow-os-type", "dialog:allow-save", "shell:allow-open", - "store:allow-get", - "store:allow-set", - "store:allow-save", - "fs:allow-write-text-file" + "store:default", + "fs:default", + "fs:allow-config-write-recursive", + { + "identifier": "fs:scope", + "allow": [{ "path": "$APP" }, { "path": "$APP/*" }] + } ] } diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 9865a08b91..43e14715b6 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -55,6 +55,7 @@ import { AppLayout } from './AppLayout'; import { Preload } from './components/Preload'; import { UnknownDeviceModal } from './components/UnknownDeviceModal'; import { useDiscordPresence } from './hooks/discord-presence'; +import { ProfileSettings } from './components/settings/pages/Profiles'; import { AdvancedSettings } from './components/settings/pages/AdvancedSettings'; export const GH_REPO = 'SlimeVR/SlimeVR-Server'; @@ -111,6 +112,7 @@ function Layout() { } /> } /> } /> + } /> } /> error('failed to fetch releases')); + fetchReleases().catch((e) => error(`Failed to fetch releases: ${e}`)); }, []); if (isTauri) { @@ -237,11 +239,11 @@ export default function App() { ) ); } else if (eventType === 'error') { - error('Error: %s', s); + error(`Error: ${s}`); } else if (eventType === 'terminated') { - error('Server Process Terminated: %s', s); + error(`Server Process Terminated: ${s}`); } else if (eventType === 'other') { - log('Other process event: %s', s); + log(`Other process event: ${s}`); } } ); diff --git a/gui/src/components/commons/Dropdown.tsx b/gui/src/components/commons/Dropdown.tsx index ec6c5e8d1c..adc453587a 100644 --- a/gui/src/components/commons/Dropdown.tsx +++ b/gui/src/components/commons/Dropdown.tsx @@ -156,7 +156,7 @@ export function Dropdown({ // Works but have a slight delay when resizing, kinda looks laggy // 2 - We close the dropdown on resize. // We could consider this as the same as clicking outside of the dropdown - // This is the approach choosen RN + // This is the approach chosen RN setOpen(false); }; diff --git a/gui/src/components/commons/TipBox.tsx b/gui/src/components/commons/TipBox.tsx index 35ecd0a7c6..dae082386c 100644 --- a/gui/src/components/commons/TipBox.tsx +++ b/gui/src/components/commons/TipBox.tsx @@ -3,6 +3,11 @@ import { BulbIcon } from './icon/BulbIcon'; import { WarningIcon } from './icon/WarningIcon'; import { Typography } from './Typography'; import classNames from 'classnames'; +import { QuestionIcon } from './icon/QuestionIcon'; + +/** + * Enabling "whitespace" will respect the newlines and spacing given in the text. + */ export function TipBox({ children, @@ -30,7 +35,7 @@ export function TipBox({ > -
+
+
+ +
+
+ + {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? + + + +
+ + +
+ +
+
+
+ ); +} 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? + + + +
+ + +
+
+
+
+ ); +} 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. + + + +
+ +
+
+
+
+ ); +} 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. + + + +
+ +
+
+
+
+ ); +} 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')} + +
+
+
+ +
+ + { + 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