Compare commits

...

7 Commits

Author SHA1 Message Date
DevMiner
2c52a2cdd0 xxx 2025-07-17 22:45:17 +02:00
DevMiner
c0038bf14c xx 2025-07-17 17:53:55 +02:00
DevMiner
4aa9f85c1a x 2025-07-17 15:37:36 +02:00
DevMiner
4f0bddc98e idk 2 2025-07-17 14:09:08 +02:00
DevMiner
be9373bd8a idk 2025-07-17 00:50:37 +02:00
DevMiner
fadbfa23ab feat(gui/topbar): show update channel when not on stable channel 2025-07-16 22:35:44 +02:00
DevMiner
a355bc5d90 refactor(gui): pass version information into the final Tauri binary 2025-07-16 21:09:17 +02:00
22 changed files with 1459 additions and 155 deletions

View File

@@ -14,6 +14,7 @@
"@react-three/fiber": "^8.17.10",
"@sentry/react": "^9.9.0",
"@sentry/vite-plugin": "^2.22.7",
"@slimevr/update-manifest": "^0.0.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^5.48.0",
"@tauri-apps/api": "^2.0.2",

View File

@@ -1,6 +1,93 @@
use std::process;
use cfg_aliases::cfg_aliases;
fn main() -> shadow_rs::SdResult<()> {
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/index");
let commit_hash = process::Command::new("git")
.args(&["rev-parse", "--verify", "--short", "HEAD"])
.output()
.map(|output| {
if output.status.success() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
eprintln!("Warning: Failed to get git commit hash: {:?}", output);
"unknown_commit".to_string()
}
})
.unwrap_or_else(|e| {
eprintln!("Warning: Could not run 'git rev-parse': {}", e);
"no_git_commit".to_string()
});
let version_tag = process::Command::new("git")
.args(&[
"--no-pager",
"tag",
"--sort",
"-taggerdate",
"--points-at",
"HEAD",
])
.output()
.map(|output| {
if output.status.success() {
String::from_utf8_lossy(&output.stdout)
.split('\n')
.next()
.unwrap_or("")
.trim()
.to_string()
} else {
eprintln!("Warning: Failed to get git tag: {:?}", output);
"".to_string()
}
})
.unwrap_or_else(|e| {
eprintln!("Warning: Could not run 'git tag': {}", e);
"".to_string()
});
let git_status_output = process::Command::new("git")
.args(&["status", "--porcelain"])
.output()
.map(|output| {
if output.status.success() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
eprintln!("Warning: Failed to get git status: {:?}", output);
"error".to_string()
}
})
.unwrap_or_else(|e| {
eprintln!("Warning: Could not run 'git status': {}", e);
"error".to_string()
});
let git_clean = git_status_output.is_empty();
if !git_clean {
println!(
"cargo:warning=Git is dirty because of:\n'{}'",
git_status_output
);
}
let mut version = if !version_tag.is_empty() {
version_tag
} else {
commit_hash
};
if !git_clean {
version.push_str("-dirty");
}
println!("cargo:warning=Version is {}", version);
println!("cargo:rustc-env=SLIMEVR_SERVER_VERSION={}", version);
// Bypass for Nix script having libudev-zero and Tauri not liking it
if let Some(path) = option_env!("SLIMEVR_RUST_LD_LIBRARY_PATH") {
println!("cargo:rustc-env=LD_LIBRARY_PATH={path}");

View File

@@ -37,6 +37,9 @@
"allow": [
{
"url": "https://github.com/SlimeVR/SlimeVR-Tracker-ESP/releases/download/*"
},
{
"url": "https://github.com/SlimeVR/SlimeVR-Server/releases/download/v0.0.0-update-manifest/update-manifest.json"
}
]
}

View File

@@ -43,6 +43,8 @@ fn update_window_state(
Ok(())
}
const VERSION: &str = env!("SLIMEVR_SERVER_VERSION");
#[tauri::command]
fn logging(msg: String) {
log::info!(target: "webview", "{}", msg)
@@ -95,10 +97,12 @@ fn main() -> Result<()> {
}));
let cli = Cli::parse();
let tauri_context = tauri::generate_context!();
// Set up loggers and global handlers
let _logger = setup_logger(&tauri_context);
log::info!(target: "backend", "Starting SlimeVR GUI v{}", VERSION);
// Ensure child processes die when spawned on windows
// and then check for WebView2's existence

View File

@@ -1,8 +1,8 @@
import { createContext, useEffect, useState } from 'react';
import { useEffect } from 'react';
import {
BrowserRouter as Router,
Outlet,
Route,
BrowserRouter as Router,
Routes,
} from 'react-router-dom';
import { Home } from './components/home/Home';
@@ -16,54 +16,54 @@ import {
WebSocketApiContext,
} from './hooks/websocket-api';
import { UpdateContextProvider } from '@/components/providers/UpdateContext.js';
import { UpdateSettings } from '@/components/settings/pages/UpdateSettings.js';
import { withSentryReactRouterV6Routing } from '@sentry/react';
import { Event, listen } from '@tauri-apps/api/event';
import * as os from '@tauri-apps/plugin-os';
import { open } from '@tauri-apps/plugin-shell';
import { AppLayout } from './AppLayout';
import { FirmwareToolSettings } from './components/firmware-tool/FirmwareTool';
import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate';
import { OnboardingContextProvider } from './components/onboarding/OnboardingContextProvicer';
import { OnboardingLayout } from './components/onboarding/OnboardingLayout';
import { AssignmentTutorialPage } from './components/onboarding/pages/assignment-preparation/AssignmentTutorial';
import { AutomaticProportionsPage } from './components/onboarding/pages/body-proportions/AutomaticProportions';
import { ManualProportionsPage } from './components/onboarding/pages/body-proportions/ManualProportions';
import { ScaledProportionsPage } from './components/onboarding/pages/body-proportions/ScaledProportions';
import { CalibrationTutorialPage } from './components/onboarding/pages/CalibrationTutorial';
import { ConnectionLost } from './components/onboarding/pages/ConnectionLost';
import { ConnectTrackersPage } from './components/onboarding/pages/ConnectTracker';
import { DonePage } from './components/onboarding/pages/Done';
import { EnterVRPage } from './components/onboarding/pages/EnterVR';
import { HomePage } from './components/onboarding/pages/Home';
import { AutomaticMountingPage } from './components/onboarding/pages/mounting/AutomaticMounting';
import { ManualMountingPage } from './components/onboarding/pages/mounting/ManualMounting';
import { MountingChoose } from './components/onboarding/pages/mounting/MountingChoose';
import { ResetTutorialPage } from './components/onboarding/pages/ResetTutorial';
import { StayAlignedSetup } from './components/onboarding/pages/stay-aligned/StayAlignedSetup';
import { TrackersAssignPage } from './components/onboarding/pages/trackers-assign/TrackerAssignment';
import { WifiCredsPage } from './components/onboarding/pages/WifiCreds';
import { Preload } from './components/Preload';
import { ConfigContextProvider } from './components/providers/ConfigContext';
import { StatusProvider } from './components/providers/StatusSystemContext';
import { SerialDetectionModal } from './components/SerialDetectionModal';
import { AdvancedSettings } from './components/settings/pages/AdvancedSettings';
import { InterfaceSettings } from './components/settings/pages/InterfaceSettings';
import { OSCRouterSettings } from './components/settings/pages/OSCRouterSettings';
import { VMCSettings } from './components/settings/pages/VMCSettings';
import { VRCOSCSettings } from './components/settings/pages/VRCOSCSettings';
import { TopBar } from './components/TopBar';
import { TrackerSettingsPage } from './components/tracker/TrackerSettings';
import { OSCRouterSettings } from './components/settings/pages/OSCRouterSettings';
import * as os from '@tauri-apps/plugin-os';
import { VMCSettings } from './components/settings/pages/VMCSettings';
import { MountingChoose } from './components/onboarding/pages/mounting/MountingChoose';
import { StatusProvider } from './components/providers/StatusSystemContext';
import { VersionUpdateModal } from './components/VersionUpdateModal';
import { CalibrationTutorialPage } from './components/onboarding/pages/CalibrationTutorial';
import { AssignmentTutorialPage } from './components/onboarding/pages/assignment-preparation/AssignmentTutorial';
import { open } from '@tauri-apps/plugin-shell';
import semver from 'semver';
import { useBreakpoint, useIsTauri } from './hooks/breakpoint';
import { VRModePage } from './components/vr-mode/VRModePage';
import { InterfaceSettings } from './components/settings/pages/InterfaceSettings';
import { error, log } from './utils/logging';
import { FirmwareToolSettings } from './components/firmware-tool/FirmwareTool';
import { AppLayout } from './AppLayout';
import { Preload } from './components/Preload';
import { UnknownDeviceModal } from './components/UnknownDeviceModal';
import { useDiscordPresence } from './hooks/discord-presence';
import { withSentryReactRouterV6Routing } from '@sentry/react';
import { ScaledProportionsPage } from './components/onboarding/pages/body-proportions/ScaledProportions';
import { AdvancedSettings } from './components/settings/pages/AdvancedSettings';
import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate';
import { ConnectionLost } from './components/onboarding/pages/ConnectionLost';
import { VersionUpdateModal } from './components/VersionUpdateModal';
import { VRModePage } from './components/vr-mode/VRModePage';
import { VRCWarningsPage } from './components/vrc/VRCWarningsPage';
import { StayAlignedSetup } from './components/onboarding/pages/stay-aligned/StayAlignedSetup';
import { useBreakpoint, useIsTauri } from './hooks/breakpoint';
import { useDiscordPresence } from './hooks/discord-presence';
import { error, log } from './utils/logging';
export const GH_REPO = 'SlimeVR/SlimeVR-Server';
export const VersionContext = createContext('');
export const DOCS_SITE = 'https://docs.slimevr.dev';
export const SLIMEVR_DISCORD = 'https://discord.gg/slimevr';
@@ -128,6 +128,7 @@ function Layout() {
</SettingsLayout>
}
>
<Route path="updates" element={<UpdateSettings />} />
<Route path="firmware-tool" element={<FirmwareToolSettings />} />
<Route path="trackers" element={<GeneralSettings />} />
<Route path="serial" element={<Serial />} />
@@ -186,7 +187,6 @@ function Layout() {
export default function App() {
const websocketAPI = useProvideWebsocketApi();
const [updateFound, setUpdateFound] = useState('');
const isTauri = useIsTauri();
useEffect(() => {
@@ -205,25 +205,6 @@ export default function App() {
return () => window.removeEventListener('keydown', onKeydown);
}, []);
useEffect(() => {
async function fetchReleases() {
const releases = await fetch(
`https://api.github.com/repos/${GH_REPO}/releases`
)
.then((res) => res.json())
.then((json: any[]) => json.filter((rl) => rl?.prerelease === false));
if (
__VERSION_TAG__ &&
typeof releases[0].tag_name === 'string' &&
semver.gt(releases[0].tag_name, __VERSION_TAG__)
) {
setUpdateFound(releases[0].tag_name);
}
}
fetchReleases().catch(() => error('failed to fetch releases'));
}, []);
if (isTauri) {
useEffect(() => {
const type = os.type();
@@ -293,7 +274,7 @@ export default function App() {
<AppContextProvider>
<OnboardingContextProvider>
<StatusProvider>
<VersionContext.Provider value={updateFound}>
<UpdateContextProvider>
<div className="h-full w-full text-standard bg-background-80 text-background-10">
<Preload />
{!websocketAPI.isConnected && (
@@ -301,7 +282,7 @@ export default function App() {
)}
{websocketAPI.isConnected && <Layout></Layout>}
</div>
</VersionContext.Provider>
</UpdateContextProvider>
</StatusProvider>
</OnboardingContextProvider>
</AppContextProvider>

View File

@@ -1,4 +1,22 @@
import { ReactNode, useContext, useEffect, useState } from 'react';
import { DOCS_SITE, GH_REPO } from '@/App';
import { useBreakpoint, useIsTauri } from '@/hooks/breakpoint';
import { STABLE_CHANNEL, useConfig } from '@/hooks/config';
import { useUpdateContext } from '@/hooks/update.js';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { connectedIMUTrackersAtom } from '@/store/app-store';
import { error } from '@/utils/logging';
import { isTrayAvailable } from '@/utils/tauri';
import { invoke } from '@tauri-apps/api/core';
import { listen, TauriEvent } from '@tauri-apps/api/event';
import {
CloseRequestedEvent,
getCurrentWindow,
UserAttentionType,
} from '@tauri-apps/api/window';
import { open } from '@tauri-apps/plugin-shell';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { ReactNode, useEffect, useState } from 'react';
import { NavLink, useMatch } from 'react-router-dom';
import {
RpcMessage,
@@ -6,38 +24,21 @@ import {
ServerInfosResponseT,
TrackerStatus,
} from 'solarxr-protocol';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { useDoubleTap } from 'use-double-tap';
import { CloseIcon } from './commons/icon/CloseIcon';
import { DownloadIcon } from './commons/icon/DownloadIcon';
import { GearIcon } from './commons/icon/GearIcon';
import { MaximiseIcon } from './commons/icon/MaximiseIcon';
import { MinimiseIcon } from './commons/icon/MinimiseIcon';
import { QuestionIcon } from './commons/icon/QuestionIcon';
import { SlimeVRIcon } from './commons/icon/SimevrIcon';
import { ProgressBar } from './commons/ProgressBar';
import { Typography } from './commons/Typography';
import { DownloadIcon } from './commons/icon/DownloadIcon';
import { open } from '@tauri-apps/plugin-shell';
import { DOCS_SITE, GH_REPO, VersionContext } from '@/App';
import classNames from 'classnames';
import { QuestionIcon } from './commons/icon/QuestionIcon';
import { useBreakpoint, useIsTauri } from '@/hooks/breakpoint';
import { GearIcon } from './commons/icon/GearIcon';
import { invoke } from '@tauri-apps/api/core';
import { TrackersStillOnModal } from './TrackersStillOnModal';
import { useConfig } from '@/hooks/config';
import { listen, TauriEvent } from '@tauri-apps/api/event';
import { TrayOrExitModal } from './TrayOrExitModal';
import { error } from '@/utils/logging';
import { useDoubleTap } from 'use-double-tap';
import { isTrayAvailable } from '@/utils/tauri';
import { ErrorConsentModal } from './ErrorConsentModal';
import {
CloseRequestedEvent,
getCurrentWindow,
UserAttentionType,
} from '@tauri-apps/api/window';
import { useAtomValue } from 'jotai';
import { connectedIMUTrackersAtom } from '@/store/app-store';
import { TrackersStillOnModal } from './TrackersStillOnModal';
import { TrayOrExitModal } from './TrayOrExitModal';
export function VersionTag() {
function VersionTag() {
return (
<div
className={classNames(
@@ -55,6 +56,34 @@ export function VersionTag() {
);
}
export function VersionIndicator() {
const { config } = useConfig();
const channel = config?.updateChannel ?? STABLE_CHANNEL;
const isStable = channel === STABLE_CHANNEL;
return (
<div className="bg-opacity-10 bg-status-success rounded-lg overflow-hidden flex gap-1">
<VersionTag />
<NavLink
to="/settings/updates"
className="flex justify-around flex-col py-1 pr-0.5 transition-all fill-status-success"
data-tauri-drag-region
state={{ scrollTo: 'channel' }}
>
<GearIcon />
</NavLink>
{!isStable && (
<div className="flex justify-around flex-col text-standard-bold text-status-success pl-0.5 pr-3 select-text">
{channel}
</div>
)}
</div>
);
}
export function TopBar({
progress,
}: {
@@ -66,7 +95,7 @@ export function TopBar({
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
const { config, setConfig, saveConfig } = useConfig();
const version = useContext(VersionContext);
const { isUpToDate } = useUpdateContext();
const [localIp, setLocalIp] = useState<string | null>(null);
const [showConnectedTrackersWarning, setConnectedTrackerWarning] =
useState(false);
@@ -181,7 +210,7 @@ export function TopBar({
)}
{(!(isMobile && !config?.decorations) || showVersionMobile) && (
<>
<VersionTag></VersionTag>
<VersionIndicator />
{doesMatchSettings && (
<div
className={classNames(
@@ -196,7 +225,7 @@ export function TopBar({
</>
)}
{version && (
{!isUpToDate && (
<div
className="cursor-pointer"
onClick={() => {

View File

@@ -1,23 +1,25 @@
import { GH_REPO } from '@/App';
import { useUpdateContext } from '@/hooks/update.js';
import { error } from '@/utils/logging';
import { useLocalization } from '@fluent/react';
import { useContext, useState } from 'react';
import { open } from '@tauri-apps/plugin-shell';
import { useState } from 'react';
import semver from 'semver';
import { BaseModal } from './commons/BaseModal';
import { Button } from './commons/Button';
import { Typography } from './commons/Typography';
import { open } from '@tauri-apps/plugin-shell';
import semver from 'semver';
import { GH_REPO, VersionContext } from '@/App';
import { error } from '@/utils/logging';
export function VersionUpdateModal() {
const { l10n } = useLocalization();
const newVersion = useContext(VersionContext);
const { latestVersionOnChannel: newVersion } = useUpdateContext();
const [forceClose, setForceClose] = useState(false);
const closeModal = () => {
localStorage.setItem('lastVersionFound', newVersion);
localStorage.setItem('lastVersionFound', newVersion ?? '');
setForceClose(true);
};
let isVersionNew = false;
try {
// TODO(devminer): check over this, if this is still necessary
if (newVersion) {
isVersionNew = semver.gt(
newVersion,
@@ -28,6 +30,8 @@ export function VersionUpdateModal() {
error('failed to parse new version');
}
if (newVersion === null) return null;
return (
<BaseModal
isOpen={!forceClose && !!newVersion && isVersionNew}

View File

@@ -1,8 +1,8 @@
import { a11yClick } from '@/utils/a11y';
import classNames from 'classnames';
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Control, Controller, UseControllerProps } from 'react-hook-form';
import { a11yClick } from '@/utils/a11y';
import { createPortal } from 'react-dom';
import { Control, Controller, UseControllerProps } from 'react-hook-form';
import { ArrowDownIcon } from './icon/ArrowIcons';
interface DropdownProps {
@@ -33,6 +33,7 @@ export interface DropdownItem {
component?: ReactNode;
value: string;
fontName?: string;
disabled?: boolean;
}
export type DropdownDirection = 'up' | 'down';
@@ -99,20 +100,34 @@ export function DropdownItems({
<li
style={item.fontName ? { fontFamily: item.fontName } : {}}
className={classNames(
'py-2 px-4 min-w-max cursor-pointer first-of-type:*:pointer-events-none',
variant == 'primary' &&
'checked-hover:bg-background-50 text-background-20 ' +
'checked-hover:text-background-10',
variant == 'secondary' &&
'checked-hover:bg-background-60 text-background-20 ' +
'checked-hover:text-background-10',
variant == 'tertiary' &&
'bg-accent-background-30 checked-hover:bg-accent-background-20'
'py-2 px-4 min-w-max first-of-type:*:pointer-events-none',
{
'cursor-pointer': !item.disabled,
},
item.disabled
? {
'bg-background-70':
variant === 'primary',
'bg-background-80':
variant === 'secondary',
'bg-background-40':
variant === 'tertiary',
}
: {
'checked-hover:bg-background-50 text-background-20 checked-hover:text-background-10':
variant === 'primary',
'checked-hover:bg-background-60 text-background-20 checked-hover:text-background-10':
variant === 'secondary',
'bg-accent-background-30 checked-hover:bg-accent-background-20':
variant == 'tertiary',
}
)}
onClick={() => {
if (item.disabled) return;
onSelectItem(item);
}}
onKeyDown={(ev) => {
if (item.disabled) return;
if (!a11yClick(ev)) return;
onSelectItem(item);
}}

View File

@@ -0,0 +1,6 @@
import { A } from '@/components/commons/A.js';
import { ComponentProps } from 'react';
export const MarkdownLink = (props: ComponentProps<'a'>) => (
<A href={props.href}>{props.children}</A>
);

View File

@@ -1,13 +1,13 @@
import { useBreakpoint } from '@/hooks/breakpoint';
import classNames from 'classnames';
import {
MutableRefObject,
ReactElement,
ReactNode,
useLayoutEffect,
useMemo,
useRef,
useState,
ReactElement,
useLayoutEffect,
MutableRefObject,
useMemo,
} from 'react';
import { createPortal } from 'react-dom';
import { Typography } from './Typography';
@@ -419,6 +419,7 @@ export function DrawerTooltip({
>
<div className="h-12 rounded-t-lg relative flex justify-center items-center">
<Typography variant="section-title" textAlign="text-center">
{/* TODO(futura): translations */}
Pro tip
</Typography>
<button

View File

@@ -1,25 +1,25 @@
import { Localized, useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { useFirmwareTool } from '@/hooks/firmware-tool';
import { Button } from '@/components/commons/Button';
import { Control, useForm } from 'react-hook-form';
import {
CreateImuConfigDTO,
Imudto,
} from '@/firmware-tool-api/firmwareToolSchemas';
import { CheckBox } from '@/components/commons/Checkbox';
import { Dropdown } from '@/components/commons/Dropdown';
import { TrashIcon } from '@/components/commons/icon/TrashIcon';
import { Input } from '@/components/commons/Input';
import { Typography } from '@/components/commons/Typography';
import {
ArrowDownIcon,
ArrowUpIcon,
} from '@/components/commons/icon/ArrowIcons';
import { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { useElemSize } from '@/hooks/layout';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { TrashIcon } from '@/components/commons/icon/TrashIcon';
import { useGetFirmwaresImus } from '@/firmware-tool-api/firmwareToolComponents';
import { CheckBox } from '@/components/commons/Checkbox';
import {
CreateImuConfigDTO,
Imudto,
} from '@/firmware-tool-api/firmwareToolSchemas';
import { useFirmwareTool } from '@/hooks/firmware-tool';
import { useElemSize } from '@/hooks/layout';
import { Localized, useLocalization } from '@fluent/react';
import classNames from 'classnames';
import { useEffect, useRef, useState } from 'react';
import { Control, useForm } from 'react-hook-form';
function IMUCard({
control,

View File

@@ -1,7 +1,28 @@
import { Localized, ReactLocalization, useLocalization } from '@fluent/react';
import { Button } from '@/components/commons/Button';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { MarkdownLink } from '@/components/commons/MarkdownLink.js';
import { WarningBox } from '@/components/commons/TipBox';
import { Typography } from '@/components/commons/Typography';
import { DeviceCardControl } from '@/components/firmware-tool/DeviceCard';
import { useAppContext } from '@/hooks/app';
import {
firmwareUpdateErrorStatus,
getFlashingRequests,
SelectedDevice,
} from '@/hooks/firmware-tool';
import { checkForUpdate } from '@/hooks/firmware-update';
import { getTrackerName } from '@/hooks/tracker';
import { ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { devicesAtom } from '@/store/app-store';
import { Localized, ReactLocalization, useLocalization } from '@fluent/react';
import { yupResolver } from '@hookform/resolvers/yup';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Control, useForm } from 'react-hook-form';
import Markdown from 'react-markdown';
import { useNavigate } from 'react-router-dom';
import remark from 'remark-gfm';
import {
DeviceDataT,
DeviceIdTableT,
@@ -12,28 +33,7 @@ import {
RpcMessage,
TrackerStatus,
} from 'solarxr-protocol';
import classNames from 'classnames';
import { Button } from '@/components/commons/Button';
import Markdown from 'react-markdown';
import remark from 'remark-gfm';
import { WarningBox } from '@/components/commons/TipBox';
import { useAppContext } from '@/hooks/app';
import { DeviceCardControl } from '@/components/firmware-tool/DeviceCard';
import { Control, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import {
firmwareUpdateErrorStatus,
getFlashingRequests,
SelectedDevice,
} from '@/hooks/firmware-tool';
import { yupResolver } from '@hookform/resolvers/yup';
import { object } from 'yup';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { A } from '@/components/commons/A';
import { useAtomValue } from 'jotai';
import { devicesAtom } from '@/store/app-store';
import { checkForUpdate } from '@/hooks/firmware-update';
interface FirmwareUpdateForm {
selectedDevices: { [key: string]: boolean };
@@ -95,10 +95,6 @@ const StatusList = ({ status }: { status: Record<string, UpdateStatus> }) => {
});
};
const MarkdownLink = (props: ComponentProps<'a'>) => (
<A href={props.href}>{props.children}</A>
);
export function FirmwareUpdate() {
const navigate = useNavigate();
const { l10n } = useLocalization();

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,9 @@
import { Typography } from '@/components/commons/Typography';
import { useVRCConfig } from '@/hooks/vrc-config';
import { useLocalization } from '@fluent/react';
import classNames from 'classnames';
import { ReactNode, useMemo } from 'react';
import { NavLink, useLocation, useMatch } from 'react-router-dom';
import { Typography } from '@/components/commons/Typography';
import { useLocalization } from '@fluent/react';
import { useVRCConfig } from '@/hooks/vrc-config';
export function SettingsLink({
to,
@@ -69,6 +69,9 @@ export function SettingsSidebar() {
<SettingsLink to="/settings/trackers" scrollTo="gestureControl">
{l10n.getString('settings-sidebar-gesture_control')}
</SettingsLink>
<SettingsLink to="/settings/updates" scrollTo="updates">
{l10n.getString('settings-sidebar-updates')}
</SettingsLink>
</div>
</div>
<div className="flex flex-col gap-3">

View File

@@ -0,0 +1,294 @@
import { Button } from '@/components/commons/Button.js';
import { SteamIcon } from '@/components/commons/icon/SteamIcon';
import { Input } from '@/components/commons/Input.js';
import { MarkdownLink } from '@/components/commons/MarkdownLink.js';
import { Typography } from '@/components/commons/Typography';
import { UpdateChannelOptions } from '@/components/settings/pages/components/UpdateChannelOptions.js';
import { UpdateChannelVersionOptions } from '@/components/settings/pages/components/UpdateVersionOptions.js';
import {
SettingsPageLayout,
SettingsPagePaneLayout,
} from '@/components/settings/SettingsPageLayout';
import { useBreakpoint } from '@/hooks/breakpoint.js';
import { defaultConfig, useConfig } from '@/hooks/config';
import { useUpdateContext } from '@/hooks/update.js';
import { useLocalization } from '@fluent/react';
import { type ChannelName, type Version } from '@slimevr/update-manifest';
import classNames from 'classnames';
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import Markdown from 'react-markdown';
import remark from 'remark-gfm';
export type SettingsForm = {
channel: ChannelName;
autoUpdate: boolean;
};
export function UpdateSettings() {
const { l10n } = useLocalization();
const { config, setConfig } = useConfig();
const { isMobile } = useBreakpoint('mobile');
const { manifest: updateManifest } = useUpdateContext();
const { reset, control, watch, handleSubmit, getValues, setValue } =
useForm<SettingsForm>({
defaultValues: {
channel: config?.updateChannel ?? defaultConfig.updateChannel,
autoUpdate:
config?.notifyOnAvailableUpdates ??
defaultConfig.notifyOnAvailableUpdates,
},
});
const { channel } = watch();
const onSubmit = (values: SettingsForm) => {
setConfig({
updateChannel: values.channel,
notifyOnAvailableUpdates: values.autoUpdate,
});
};
useEffect(() => {
const subscription = watch(() => handleSubmit(onSubmit)());
return () => subscription.unsubscribe();
}, []);
if (!updateManifest) {
return null;
}
console.log(updateManifest);
return (
<SettingsPageLayout>
<form className="flex flex-col gap-2 w-full">
<SettingsPagePaneLayout icon={<SteamIcon />} id="channels">
<>
<Typography variant="main-title">
{l10n.getString('settings-general-update')}
</Typography>
<Typography bold>
{l10n.getString('settings-general-update-subtitle')}
</Typography>
<div className="flex flex-col py-2">
{l10n
.getString('settings-general-update-description')
.split('\n')
.map((line, i) => (
<Typography color="secondary" key={i}>
{line}
</Typography>
))}
</div>
<div className="flex flex-col md:gap-4 sm:gap-2 xs:gap-1 mobile:gap-4">
<UpdateChannelOptions
manifest={updateManifest}
value={channel}
variant={isMobile ? 'dropdown' : 'radio'}
onSelect={(channel) => setValue('channel', channel)}
asList={false}
/>
</div>
</>
</SettingsPagePaneLayout>
<SettingsPagePaneLayout icon={<SteamIcon />} id="change_system">
<>
<Typography variant="main-title">
{l10n.getString('settings-general-change_system')}
</Typography>
<Typography bold>
{l10n.getString('settings-general-change_system-subtitle')}
</Typography>
<div className="flex flex-col py-2">
{l10n
.getString('settings-general-change_system-description')
.split('\n')
.map((line, i) => (
<Typography color="secondary" key={i}>
{line}
</Typography>
))}
</div>
<ChangeSystem />
</>
</SettingsPagePaneLayout>
<SettingsPagePaneLayout icon={<SteamIcon />} id="change_version">
<>
<Typography variant="main-title">
{l10n.getString('settings-general-change_version')}
</Typography>
<Typography bold>
{l10n.getString('settings-general-change_version-subtitle')}
</Typography>
<div className="flex flex-col py-2">
{l10n
.getString('settings-general-change_version-description')
.split('\n')
.map((line, i) => (
<Typography color="secondary" key={i}>
{line}
</Typography>
))}
</div>
<ChangeVersion />
</>
</SettingsPagePaneLayout>
</form>
</SettingsPageLayout>
);
}
function ChangeVersion() {
const { isMobile } = useBreakpoint('mobile');
const { config } = useConfig();
const {
manifest,
channel: currentChannel,
checkCompatibility,
} = useUpdateContext();
const [channel, setChannel] = useState<ChannelName>(currentChannel);
const [version, setVersion] = useState<Version | null>(
manifest && __VERSION_TAG__ in manifest.channels[channel].versions
? (__VERSION_TAG__ as Version)
: null
);
const res = version && checkCompatibility(channel, version);
const install = useCallback(() => {}, []);
return (
<div className="flex md-max:flex-col gap-2">
<div className="md:w-1/4">
<Typography>Channel</Typography>
<div className="flex flex-col md:gap-4 sm:gap-2 xs:gap-1 mobile:gap-4 max-h-[384px] md:max-h-[512px] pr-1">
{manifest && (
<UpdateChannelOptions
manifest={manifest}
value={channel}
variant={isMobile ? 'dropdown' : 'radio'}
onSelect={(channel) => {
setChannel(channel);
setVersion(null);
}}
/>
)}
</div>
</div>
{config?.debug && (
<div className="md:w-1/4">
<Typography>Version</Typography>
<div className="flex flex-col md:gap-4 sm:gap-2 xs:gap-1 mobile:gap-4 max-h-[384px] md:max-h-[512px] overflow-auto pr-1">
{manifest && (
<UpdateChannelVersionOptions
manifest={manifest}
channel={channel}
value={version ?? ''}
variant={isMobile ? 'dropdown' : 'radio'}
onSelect={(version) =>
setVersion(version === '' ? null : (version as Version))
}
/>
)}
</div>
</div>
)}
<div className="md:w-1/2 flex flex-col gap-2">
{res?.version && (
<>
<Typography>Release Notes</Typography>
<div className="bg-background-60 rounded-lg px-3 py-2 max-h-[512px] overflow-auto">
<Markdown
remarkPlugins={[remark]}
components={{ a: MarkdownLink }}
className={classNames(
'w-full text-sm prose-xl prose text-background-10 prose-h1:text-background-10',
'prose-h2:text-background-10 prose-h3:text-background-10 prose-a:text-background-20 prose-strong:text-background-10',
'prose-code:text-background-20'
)}
>
{res.version.release_notes}
</Markdown>
</div>
<div className="inline ml-auto w-fit">
{/* TODO(devminer): add translations */}
<Button variant="primary" disabled={!res.isInstallable}>
Install
</Button>
</div>
</>
)}
</div>
</div>
);
}
type ChangeSYstemForm = {
platform: string;
architecture: string;
};
function ChangeSystem() {
const { platform, architecture, changeSystem } = useUpdateContext();
const { reset, control, watch, handleSubmit, getValues, setValue } =
useForm<ChangeSYstemForm>({
defaultValues: {
platform,
architecture,
},
});
const onSubmit = (values: ChangeSYstemForm) => {
console.log(values);
changeSystem(values.platform, values.architecture);
};
useEffect(() => {
const subscription = watch(() => handleSubmit(onSubmit)());
return () => subscription.unsubscribe();
}, []);
return (
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-2 items-center grid-rows-2 grid-cols-[fit-content(20%),1fr]">
<Typography bold variant="vr-accessible">
Platform
</Typography>
<Input control={control} name="platform" placeholder={platform} />
<Typography bold variant="vr-accessible">
Architecture
</Typography>
<Input
control={control}
name="architecture"
placeholder={architecture}
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
reset();
}}
>
Reset to real values
</Button>
</div>
</form>
);
}

View File

@@ -9,7 +9,7 @@ import { useLocalization } from '@fluent/react';
import { Dispatch, ReactNode, SetStateAction } from 'react';
import { StayAlignedRelaxedPose } from 'solarxr-protocol';
function StaAlignedPoseModal({
function StayAlignedPoseModal({
open,
title,
descriptionKeys,
@@ -80,7 +80,7 @@ export const StandingRelaxedPoseModal = ({
}: {
open: [boolean, Dispatch<SetStateAction<boolean>>];
}) => (
<StaAlignedPoseModal
<StayAlignedPoseModal
open={open}
title={'onboarding-stay_aligned-relaxed_poses-standing-title'}
descriptionKeys={[
@@ -94,7 +94,7 @@ export const StandingRelaxedPoseModal = ({
width={260}
alt="Reset position"
/>
</StaAlignedPoseModal>
</StayAlignedPoseModal>
);
export const SittingRelaxedPoseModal = ({
@@ -102,7 +102,7 @@ export const SittingRelaxedPoseModal = ({
}: {
open: [boolean, Dispatch<SetStateAction<boolean>>];
}) => (
<StaAlignedPoseModal
<StayAlignedPoseModal
open={open}
title={'onboarding-stay_aligned-relaxed_poses-sitting-title'}
descriptionKeys={[
@@ -116,7 +116,7 @@ export const SittingRelaxedPoseModal = ({
width={260}
alt="Reset position"
/>
</StaAlignedPoseModal>
</StayAlignedPoseModal>
);
export const FlatRelaxedPoseModal = ({
@@ -124,7 +124,7 @@ export const FlatRelaxedPoseModal = ({
}: {
open: [boolean, Dispatch<SetStateAction<boolean>>];
}) => (
<StaAlignedPoseModal
<StayAlignedPoseModal
open={open}
title={'onboarding-stay_aligned-relaxed_poses-flat-title'}
descriptionKeys={[
@@ -138,5 +138,5 @@ export const FlatRelaxedPoseModal = ({
width={560}
alt="Reset position"
/>
</StaAlignedPoseModal>
</StayAlignedPoseModal>
);

View File

@@ -0,0 +1,118 @@
import { Dropdown } from '@/components/commons/Dropdown';
import { Radio } from '@/components/commons/Radio';
import { Typography } from '@/components/commons/Typography';
import { ASSIGNMENT_MODES } from '@/components/onboarding/BodyAssignment';
import { AssignMode, defaultConfig } from '@/hooks/config';
import { UpdateManifest, type ChannelName } from '@slimevr/update-manifest';
import classNames from 'classnames';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
// Ordered collection of assign modes with the number of IMU trackers
const ASSIGN_MODE_OPTIONS = [
AssignMode.LowerBody,
AssignMode.Core,
AssignMode.EnhancedCore,
AssignMode.FullBody,
AssignMode.All,
].reduce(
(options, mode) => ({ ...options, [mode]: ASSIGNMENT_MODES[mode].length }),
{} as Record<AssignMode, number>
);
const ItemContent = ({
name: channel,
description,
}: {
name: ChannelName;
description?: string;
}) => {
return (
<div className="flex flex-col">
<div className="flex gap-2 items-center">
<Typography>{channel}</Typography>
{channel === defaultConfig.updateChannel && (
/* TODO(devminer): add translations */
<div className="bg-background-70 px-1.5 py-0.5 rounded-md leading-[1rem] text-[0.625rem]">
default
</div>
)}
</div>
<Typography variant="standard" color="secondary">
{description}
</Typography>
</div>
);
};
export function UpdateChannelOptions({
manifest,
value,
onSelect,
variant = 'radio',
asList = true,
}: {
manifest: UpdateManifest;
value: ChannelName;
onSelect: (channel: ChannelName) => void;
variant?: 'radio' | 'dropdown';
asList?: boolean;
}) {
const { control, watch } = useForm<{
updateChannel: ChannelName;
}>({
defaultValues: {
updateChannel: value,
},
});
const { updateChannel } = watch();
useEffect(() => {
onSelect(updateChannel);
}, [updateChannel]);
if (variant == 'dropdown')
return (
<Dropdown
control={control}
name="updateChannel"
display="block"
direction="down"
placeholder=""
items={Object.entries(manifest.channels).map(([channel, data]) => ({
component: (
<div className="flex flex-row gap-2 py-1 text-left">
<ItemContent
name={channel as ChannelName}
description={data.description}
/>
</div>
),
value: channel,
}))}
></Dropdown>
);
return (
<div className={classNames('flex gap-2', asList && 'flex-col')}>
{Object.entries(manifest.channels).map(([channel, data]) => (
<Radio
key={channel}
name="updateChannel"
control={control}
value={channel}
className="hidden"
>
<div className="flex flex-row md:gap-4 gap-2">
<ItemContent
name={channel as ChannelName}
description={data.description}
/>
</div>
</Radio>
))}
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { Dropdown } from '@/components/commons/Dropdown';
import { WarningIcon } from '@/components/commons/icon/WarningIcon.js';
import { Radio } from '@/components/commons/Radio';
import { Typography } from '@/components/commons/Typography';
import { useUpdateContext } from '@/hooks/update.js';
import {
UpdateManifest,
Version,
type ChannelName,
} from '@slimevr/update-manifest';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { compare } from 'semver';
const ItemContent = ({
version,
isLatest,
isAlreadyInstalled,
hasBuildsAvailableForThisPlatform,
}: {
version: string;
isLatest: boolean;
isAlreadyInstalled: boolean;
hasBuildsAvailableForThisPlatform: boolean;
}) => {
return (
<div className="flex flex-col">
<div className="flex gap-2 items-center">
<Typography>{version}</Typography>
{isLatest && (
/* TODO(devminer): add translations */
<div className="bg-background-70 px-1.5 py-0.5 rounded-md leading-[1rem] text-[0.625rem]">
latest
</div>
)}
</div>
{isAlreadyInstalled && (
<div className="text-yellow-background-300 flex gap-1 items-center">
<WarningIcon className="size-5 min-w-5" />
{/* TODO(devminer): add translations */}
<Typography variant="standard" color="secondary">
already installed
</Typography>
</div>
)}
{!hasBuildsAvailableForThisPlatform && (
<div className="text-yellow-background-300 flex gap-1 items-center">
<WarningIcon className="size-5 min-w-5" />
{/* TODO(devminer): add translations */}
<Typography variant="standard" color="secondary">
no build available for your platform
</Typography>
</div>
)}
</div>
);
};
export function UpdateChannelVersionOptions({
manifest,
channel,
value,
variant = 'radio',
onSelect,
}: {
manifest: UpdateManifest;
channel: ChannelName;
value: string;
variant: 'radio' | 'dropdown';
onSelect: (version: string) => void;
}) {
const { channel: currentChannel, checkCompatibilityFromVersionInfo } =
useUpdateContext();
const { control, watch } = useForm<{
version: string;
}>({
defaultValues: {
version: value,
},
});
const { version } = watch();
const ch = manifest.channels[channel];
useEffect(() => {
onSelect(version);
}, [version]);
if (variant == 'dropdown')
return (
<Dropdown
control={control}
name="version"
display="block"
direction="down"
placeholder=""
maxHeight="300px"
items={Object.entries(ch.versions)
.map(([tag, version]) => [tag as Version, version] as const)
.sort(([a, _12], [b, _22]) => compare(b, a))
.map(([version, versionInfo]) => {
const res = checkCompatibilityFromVersionInfo(
channel,
version,
versionInfo
);
return {
value: version,
component: (
<div className="flex flex-row gap-2 py-1 text-left">
<ItemContent
version={version}
isLatest={ch.current_version === version}
isAlreadyInstalled={res.alreadyInstalled}
hasBuildsAvailableForThisPlatform={!!res.build}
/>
</div>
),
};
})}
/>
);
return (
<div className="flex flex-col gap-2">
{Object.entries(ch.versions)
.map(([tag, version]) => [tag as Version, version] as const)
.sort(([a, _12], [b, _22]) => compare(b, a))
.map(([version, versionInfo]) => {
const res = checkCompatibilityFromVersionInfo(
channel,
version,
versionInfo
);
return (
<Radio
key={version}
name="version"
control={control}
value={version}
className="hidden"
>
<div className="flex flex-row md:gap-4 gap-2">
<ItemContent
version={version}
isLatest={ch.current_version === version}
isAlreadyInstalled={res.alreadyInstalled}
hasBuildsAvailableForThisPlatform={!!res.build}
/>
</div>
</Radio>
);
})}
</div>
);
}

View File

@@ -1,14 +1,17 @@
import { createContext, useContext, useState } from 'react';
import {
defaultValues as defaultDevSettings,
DeveloperModeWidgetForm,
} from '@/components/widgets/DeveloperModeWidget';
import { error } 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 { error } from '@/utils/logging';
import { type ChannelName } from '@slimevr/update-manifest';
import { isTauri } from '@tauri-apps/api/core';
import { createStore, Store } from '@tauri-apps/plugin-store';
import { createContext, useContext, useState } from 'react';
import { useIsTauri } from './breakpoint';
import { useDebouncedEffect } from './timeout';
export const STABLE_CHANNEL = 'stable' as ChannelName;
export interface WindowConfig {
width: number;
@@ -26,6 +29,8 @@ export enum AssignMode {
}
export interface Config {
updateChannel: ChannelName;
notifyOnAvailableUpdates: boolean;
debug: boolean;
lang: string;
doneOnboarding: boolean;
@@ -55,6 +60,8 @@ export interface ConfigContext {
}
export const defaultConfig: Config = {
updateChannel: STABLE_CHANNEL,
notifyOnAvailableUpdates: true,
lang: 'en',
debug: false,
doneOnboarding: false,

54
gui/src/hooks/update.ts Normal file
View File

@@ -0,0 +1,54 @@
import {
UpdateManifestChannel,
UpdateManifestChannelVersion,
UpdateManifestChannelVersionBuild,
type ChannelName,
type UpdateManifest,
type Version,
} from '@slimevr/update-manifest';
import { createContext, useContext } from 'react';
export interface UpdateContext {
channel: ChannelName;
notifyOnAvailableUpdates: boolean;
isUpToDate: boolean;
latestVersionOnChannel: Version | null;
manifest: UpdateManifest | null;
platform: string;
architecture: string;
changeSystem(platform: string, architecture: string): void;
checkCompatibility(
channelName: ChannelName,
version: Version
): {
alreadyInstalled: boolean;
channel: UpdateManifestChannel | null;
version: UpdateManifestChannelVersion | null;
build: UpdateManifestChannelVersionBuild | null;
isInstallable: boolean;
};
checkCompatibilityFromVersionInfo(
channelName: ChannelName,
version: Version,
versionInfo: UpdateManifestChannelVersion
): {
alreadyInstalled: boolean;
build: UpdateManifestChannelVersionBuild | null;
isInstallable: boolean;
};
}
export const UpdateContextC = createContext<UpdateContext | null>(null);
export function useUpdateContext() {
const context = useContext(UpdateContextC);
if (!context) {
throw new Error('useUpdateContext must be within a UpdateContext Provider');
}
return context;
}

90
gui/src/utils/update.ts Normal file
View File

@@ -0,0 +1,90 @@
import {
ChannelName,
UpdateManifest,
UpdateManifestChannel,
UpdateManifestChannelVersion,
UpdateManifestChannelVersionBuild,
Version,
} from '@slimevr/update-manifest';
type System = {
platform: string;
architecture: string;
};
export function checkVersionCompatibility(
manifest: UpdateManifest,
channelName: ChannelName,
version: Version,
currentChannelName: ChannelName,
system: System
): {
alreadyInstalled: boolean;
isInstallable: boolean;
channel: UpdateManifestChannel | null;
version: UpdateManifestChannelVersion | null;
build: UpdateManifestChannelVersionBuild | null;
} {
const alreadyInstalled = isAlreadyInstalled(channelName, version, currentChannelName);
const channel = manifest.channels[channelName];
const versionInfo = channel.versions[version];
if (!versionInfo)
return {
alreadyInstalled,
channel,
version: null,
build: null,
isInstallable: false,
};
const build = findBuildForPlatformAndArchitecture(versionInfo, system);
return {
alreadyInstalled,
channel,
version: versionInfo,
build,
isInstallable: !alreadyInstalled && build !== null,
};
}
function isAlreadyInstalled(
channelName: ChannelName,
version: Version,
currentChannelName: ChannelName
) {
return currentChannelName === channelName && __VERSION_TAG__ === version;
}
export function checkVersionCompatibility2(
channelName: ChannelName,
version: Version,
versionInfo: UpdateManifestChannelVersion,
currentChannelName: ChannelName,
system: System
) {
const alreadyInstalled = isAlreadyInstalled(channelName, version, currentChannelName);
const build = findBuildForPlatformAndArchitecture(versionInfo, system);
return {
alreadyInstalled,
build,
isInstallable: !alreadyInstalled && build !== null,
};
}
function findBuildForPlatformAndArchitecture(
versionInfo: UpdateManifestChannelVersion,
system: System
) {
const buildsForPlatform = versionInfo.builds[system.platform];
if (!buildsForPlatform) return null;
const buildForPlatformAndArchitecture = buildsForPlatform[system.architecture];
if (!buildForPlatformAndArchitecture) return null;
return buildForPlatformAndArchitecture;
}

24
pnpm-lock.yaml generated
View File

@@ -47,6 +47,9 @@ importers:
'@sentry/vite-plugin':
specifier: ^2.22.7
version: 2.22.7
'@slimevr/update-manifest':
specifier: ^0.0.1
version: 0.0.1(typescript@5.6.3)
'@tailwindcss/typography':
specifier: ^0.5.15
version: 0.5.15(tailwindcss@3.4.14(ts-node@9.1.1(typescript@5.6.3)))
@@ -1083,6 +1086,9 @@ packages:
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
engines: {node: '>=14.16'}
'@slimevr/update-manifest@0.0.1':
resolution: {integrity: sha512-SqPUQuyhSRdO2kQZG6LPHv+X6mRKzR7/oBJULSiNUQrUhT9NbqssDjGDhZX49UZX8R0lM+BK6468p+7l3oQxMQ==}
'@swc/core-darwin-arm64@1.6.5':
resolution: {integrity: sha512-RGQhMdni2v1/ANQ/2K+F+QYdzaucekYBewZcX1ogqJ8G5sbPaBdYdDN1qQ4kHLCIkPtGP6qC7c71qPEqL2RidQ==}
engines: {node: '>=10'}
@@ -4271,6 +4277,14 @@ packages:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
valibot@1.1.0:
resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
vfile-message@4.0.2:
resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==}
@@ -5337,6 +5351,12 @@ snapshots:
'@sindresorhus/is@5.6.0': {}
'@slimevr/update-manifest@0.0.1(typescript@5.6.3)':
dependencies:
valibot: 1.1.0(typescript@5.6.3)
transitivePeerDependencies:
- typescript
'@swc/core-darwin-arm64@1.6.5':
optional: true
@@ -9032,6 +9052,10 @@ snapshots:
uuid@9.0.1: {}
valibot@1.1.0(typescript@5.6.3):
optionalDependencies:
typescript: 5.6.3
vfile-message@4.0.2:
dependencies:
'@types/unist': 3.0.3