This commit is contained in:
DevMiner
2025-07-17 22:45:17 +02:00
parent c0038bf14c
commit 2c52a2cdd0
10 changed files with 813 additions and 489 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import { DOCS_SITE, GH_REPO, VersionContext } from '@/App';
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';
@@ -15,7 +16,7 @@ import {
import { open } from '@tauri-apps/plugin-shell';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { ReactNode, useContext, useEffect, useState } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import { NavLink, useMatch } from 'react-router-dom';
import {
RpcMessage,
@@ -94,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);
@@ -224,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}

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,7 @@
import { UpdateManifestContext } from '@/App.js';
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 { Tooltip } from '@/components/commons/Tooltip.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';
@@ -12,10 +11,11 @@ import {
} from '@/components/settings/SettingsPageLayout';
import { useBreakpoint } from '@/hooks/breakpoint.js';
import { defaultConfig, useConfig } from '@/hooks/config';
import { Localized, useLocalization } from '@fluent/react';
import { UpdateManifest, type ChannelName } from '@slimevr/update-manifest';
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, useContext, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import Markdown from 'react-markdown';
import remark from 'remark-gfm';
@@ -30,13 +30,15 @@ export function UpdateSettings() {
const { config, setConfig } = useConfig();
const { isMobile } = useBreakpoint('mobile');
const updateManifest = useContext(UpdateManifestContext);
const { manifest: updateManifest } = useUpdateContext();
const { reset, control, watch, handleSubmit, getValues, setValue } =
useForm<SettingsForm>({
defaultValues: {
channel: config?.updateChannel ?? defaultConfig.updateChannel,
autoUpdate: config?.autoUpdate ?? defaultConfig.autoUpdate,
autoUpdate:
config?.notifyOnAvailableUpdates ??
defaultConfig.notifyOnAvailableUpdates,
},
});
@@ -45,7 +47,7 @@ export function UpdateSettings() {
const onSubmit = (values: SettingsForm) => {
setConfig({
updateChannel: values.channel,
autoUpdate: values.autoUpdate,
notifyOnAvailableUpdates: values.autoUpdate,
});
};
@@ -88,11 +90,35 @@ export function UpdateSettings() {
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">
@@ -112,7 +138,7 @@ export function UpdateSettings() {
))}
</div>
<ChangeVersion updateManifest={updateManifest} />
<ChangeVersion />
</>
</SettingsPagePaneLayout>
</form>
@@ -120,28 +146,22 @@ export function UpdateSettings() {
);
}
function ChangeVersion({ updateManifest }: { updateManifest: UpdateManifest }) {
function ChangeVersion() {
const { isMobile } = useBreakpoint('mobile');
const { config } = useConfig();
const currentChannel = config?.updateChannel ?? defaultConfig.updateChannel;
const {
manifest,
channel: currentChannel,
checkCompatibility,
} = useUpdateContext();
const [channel, setChannel] = useState<ChannelName>(currentChannel);
const [version, setVersion] = useState(
__VERSION_TAG__ in updateManifest.channels[channel].versions
? __VERSION_TAG__
: ''
const [version, setVersion] = useState<Version | null>(
manifest && __VERSION_TAG__ in manifest.channels[channel].versions
? (__VERSION_TAG__ as Version)
: null
);
const ch = updateManifest.channels[channel] ?? null;
const v = ch?.versions[version] ?? null;
const isAlreadyInstalled =
channel === currentChannel && version === __VERSION_TAG__;
// TODO(devminer): correctly figure out the current platform
const hasBuildAvailableForThisPlatform =
v && 'windows' in v.builds && 'x86_64' in v.builds['windows'];
const isInstallable = !isAlreadyInstalled && hasBuildAvailableForThisPlatform;
const res = version && checkCompatibility(channel, version);
const install = useCallback(() => {}, []);
@@ -150,33 +170,41 @@ function ChangeVersion({ updateManifest }: { updateManifest: UpdateManifest }) {
<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">
<UpdateChannelOptions
manifest={updateManifest}
value={channel}
variant={isMobile ? 'dropdown' : 'radio'}
onSelect={(channel) => {
setChannel(channel);
setVersion('');
}}
/>
{manifest && (
<UpdateChannelOptions
manifest={manifest}
value={channel}
variant={isMobile ? 'dropdown' : 'radio'}
onSelect={(channel) => {
setChannel(channel);
setVersion(null);
}}
/>
)}
</div>
</div>
<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">
<UpdateChannelVersionOptions
manifest={updateManifest}
channel={channel}
value={version}
variant={isMobile ? 'dropdown' : 'radio'}
onSelect={setVersion}
/>
{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>
)}
<div className="md:w-1/2 flex flex-col gap-2">
{v && (
{res?.version && (
<>
<Typography>Release Notes</Typography>
<div className="bg-background-60 rounded-lg px-3 py-2 max-h-[512px] overflow-auto">
@@ -189,27 +217,14 @@ function ChangeVersion({ updateManifest }: { updateManifest: UpdateManifest }) {
'prose-code:text-background-20'
)}
>
{v.release_notes}
{res.version.release_notes}
</Markdown>
</div>
<div className="inline ml-auto w-fit">
<Tooltip
content={
<Localized id="X">
<Typography
variant="standard"
whitespace="whitespace-pre-wrap"
/>
</Localized>
}
preferedDirection="bottom"
mode="corner"
>
{/* TODO(devminer): add translations */}
<Button variant="primary" disabled={!isInstallable}>
Install
</Button>
</Tooltip>
{/* TODO(devminer): add translations */}
<Button variant="primary" disabled={!res.isInstallable}>
Install
</Button>
</div>
</>
)}
@@ -217,3 +232,63 @@ function ChangeVersion({ updateManifest }: { updateManifest: UpdateManifest }) {
</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

@@ -4,6 +4,7 @@ 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';
@@ -33,7 +34,9 @@ const ItemContent = ({
{channel === defaultConfig.updateChannel && (
/* TODO(devminer): add translations */
<div className="bg-background-70 px-1.5 py-1 rounded-md">default</div>
<div className="bg-background-70 px-1.5 py-0.5 rounded-md leading-[1rem] text-[0.625rem]">
default
</div>
)}
</div>
@@ -47,13 +50,15 @@ const ItemContent = ({
export function UpdateChannelOptions({
manifest,
value,
variant = 'radio',
onSelect,
variant = 'radio',
asList = true,
}: {
manifest: UpdateManifest;
value: ChannelName;
variant: 'radio' | 'dropdown';
onSelect: (channel: ChannelName) => void;
variant?: 'radio' | 'dropdown';
asList?: boolean;
}) {
const { control, watch } = useForm<{
updateChannel: ChannelName;
@@ -91,7 +96,7 @@ export function UpdateChannelOptions({
);
return (
<div className="flex flex-col gap-2">
<div className={classNames('flex gap-2', asList && 'flex-col')}>
{Object.entries(manifest.channels).map(([channel, data]) => (
<Radio
key={channel}

View File

@@ -2,7 +2,12 @@ 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 { UpdateManifest, type ChannelName } from '@slimevr/update-manifest';
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';
@@ -25,26 +30,30 @@ const ItemContent = ({
{isLatest && (
/* TODO(devminer): add translations */
<div className="bg-background-70 px-1.5 py-1 rounded-md">latest</div>
<div className="bg-background-70 px-1.5 py-0.5 rounded-md leading-[1rem] text-[0.625rem]">
latest
</div>
)}
</div>
<Typography variant="standard" color="secondary">
{isAlreadyInstalled && (
<div className="text-yellow-background-300 flex gap-1 items-center">
<WarningIcon className="size-5 min-w-5" />
{/* TODO(devminer): add translations */}
{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
</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>
</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
</div>
)}
</Typography>
</Typography>
</div>
)}
</div>
);
};
@@ -62,6 +71,8 @@ export function UpdateChannelVersionOptions({
variant: 'radio' | 'dropdown';
onSelect: (version: string) => void;
}) {
const { channel: currentChannel, checkCompatibilityFromVersionInfo } =
useUpdateContext();
const { control, watch } = useForm<{
version: string;
}>({
@@ -87,32 +98,27 @@ export function UpdateChannelVersionOptions({
placeholder=""
maxHeight="300px"
items={Object.entries(ch.versions)
.map(([tag, version]) => [tag, version] as const)
.map(([tag, version]) => [tag as Version, version] as const)
.sort(([a, _12], [b, _22]) => compare(b, a))
.map(([tag, version]) => {
const isAlreadyInstalled = tag === __VERSION_TAG__;
// TODO(devminer): correctly figure out the current platform
const hasBuildAvailableForThisPlatform =
'windows' in version.builds &&
'x86_64' in version.builds['windows'];
const isInstallable =
!isAlreadyInstalled && hasBuildAvailableForThisPlatform;
.map(([version, versionInfo]) => {
const res = checkCompatibilityFromVersionInfo(
channel,
version,
versionInfo
);
return {
disabled: !isInstallable,
value: version,
component: (
<div className="flex flex-row gap-2 py-1 text-left">
<ItemContent
version={tag}
isLatest={ch.current_version === tag}
isAlreadyInstalled={isAlreadyInstalled}
hasBuildsAvailableForThisPlatform={
hasBuildAvailableForThisPlatform
}
version={version}
isLatest={ch.current_version === version}
isAlreadyInstalled={res.alreadyInstalled}
hasBuildsAvailableForThisPlatform={!!res.build}
/>
</div>
),
value: tag,
};
})}
/>
@@ -121,34 +127,29 @@ export function UpdateChannelVersionOptions({
return (
<div className="flex flex-col gap-2">
{Object.entries(ch.versions)
.map(([tag, version]) => [tag, version] as const)
.map(([tag, version]) => [tag as Version, version] as const)
.sort(([a, _12], [b, _22]) => compare(b, a))
.map(([tag, version]) => {
const isAlreadyInstalled = tag === __VERSION_TAG__;
// TODO(devminer): correctly figure out the current platform
const hasBuildAvailableForThisPlatform =
'windows' in version.builds &&
'x86_64' in version.builds['windows'];
const isInstallable =
!isAlreadyInstalled && hasBuildAvailableForThisPlatform;
.map(([version, versionInfo]) => {
const res = checkCompatibilityFromVersionInfo(
channel,
version,
versionInfo
);
return (
<Radio
key={tag}
key={version}
name="version"
control={control}
value={tag}
value={version}
className="hidden"
disabled={!isInstallable}
>
<div className="flex flex-row md:gap-4 gap-2">
<ItemContent
version={tag}
isLatest={ch.current_version === tag}
isAlreadyInstalled={isAlreadyInstalled}
hasBuildsAvailableForThisPlatform={
hasBuildAvailableForThisPlatform
}
version={version}
isLatest={ch.current_version === version}
isAlreadyInstalled={res.alreadyInstalled}
hasBuildsAvailableForThisPlatform={!!res.build}
/>
</div>
</Radio>

View File

@@ -30,7 +30,7 @@ export enum AssignMode {
export interface Config {
updateChannel: ChannelName;
autoUpdate: boolean;
notifyOnAvailableUpdates: boolean;
debug: boolean;
lang: string;
doneOnboarding: boolean;
@@ -61,7 +61,7 @@ export interface ConfigContext {
export const defaultConfig: Config = {
updateChannel: STABLE_CHANNEL,
autoUpdate: true,
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;
}