Implement Sentry error logging for GUI (#1153)

This commit is contained in:
Eiren Rain
2025-03-27 20:59:39 +03:00
committed by GitHub
27 changed files with 662 additions and 119 deletions

View File

@@ -79,11 +79,15 @@ jobs:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
shell: bash
run: pnpm i
- name: Build
shell: bash
run: |
pnpm i
pnpm run skipbundler --config $( ./gui/scripts/gitversion.mjs )
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm run skipbundler --config $( ./gui/scripts/gitversion.mjs )
- if: matrix.os == 'windows-latest'
name: Upload a Build Artifact (Windows)

View File

@@ -112,10 +112,13 @@ jobs:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Build GUI
run: |
pnpm i
cd gui && pnpm run build
- name: Install dependencies
run: pnpm i
- name: Build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: cd gui && pnpm run build
- name: Build with Gradle
run: ./gradlew :server:android:assembleDebug
@@ -191,10 +194,13 @@ jobs:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Build
run: |
pnpm i
pnpm run tauri build --config $( ./gui/scripts/gitversion.mjs )
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm run tauri build --config $( ./gui/scripts/gitversion.mjs )
- name: Make GUI tarball
run: |
@@ -266,11 +272,15 @@ jobs:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Build
- name: Install dependencies
run: |
rustup target add x86_64-apple-darwin
pnpm i
pnpm run tauri build --target universal-apple-darwin --config $( ./gui/scripts/gitversion.mjs )
- name: Build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm run tauri build --target universal-apple-darwin --config $( ./gui/scripts/gitversion.mjs )
- name: Modify Application
run: |
@@ -338,11 +348,15 @@ jobs:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
shell: bash
run: pnpm i
- name: Build
shell: bash
run: |
pnpm i
pnpm run skipbundler --config $( ./gui/scripts/gitversion.mjs )
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm run skipbundler --config $( ./gui/scripts/gitversion.mjs )
- name: Bundle to zips
shell: bash

3
gui/.gitignore vendored
View File

@@ -32,3 +32,6 @@ vite.config.ts.timestamp*
# eslint
.eslintcache
# Sentry Config File
.env.sentry-build-plugin

View File

@@ -11,6 +11,8 @@
"@hookform/resolvers": "^3.6.0",
"@react-three/drei": "^9.114.3",
"@react-three/fiber": "^8.17.10",
"@sentry/react": "^9.9.0",
"@sentry/vite-plugin": "^2.22.7",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^5.48.0",
"@tauri-apps/api": "^2.0.2",
@@ -91,7 +93,7 @@
"spdx-satisfies": "^5.0.1",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwindcss": "^3.4.13",
"vite": "^5.4.8",
"typescript-eslint": "^8.8.0"
"typescript-eslint": "^8.8.0",
"vite": "^5.4.8"
}
}
}

View File

@@ -359,6 +359,7 @@ settings-sidebar-utils = Utilities
settings-sidebar-serial = Serial console
settings-sidebar-appearance = Appearance
settings-sidebar-notifications = Notifications
settings-sidebar-behavior = Behavior
settings-sidebar-firmware-tool = DIY Firmware Tool
settings-sidebar-advanced = Advanced
@@ -531,9 +532,6 @@ settings-general-gesture_control-numberTrackersOverThreshold-description = Incre
## Appearance settings
settings-interface-appearance = Appearance
settings-general-interface-dev_mode = Developer Mode
settings-general-interface-dev_mode-description = This mode can be useful if you need in-depth data or to interact with connected trackers on a more advanced level.
settings-general-interface-dev_mode-label = Developer Mode
settings-general-interface-theme = Color theme
settings-general-interface-show-navbar-onboarding = Show "{ navbar-onboarding }" on navigation bar
settings-general-interface-show-navbar-onboarding-description = This changes if the "{ navbar-onboarding }" button shows on the navigation bar.
@@ -565,6 +563,12 @@ settings-general-interface-feedback_sound-volume = Feedback sound volume
settings-general-interface-connected_trackers_warning = Connected trackers warning
settings-general-interface-connected_trackers_warning-description = This option will show a pop-up every time you try exiting SlimeVR while having one or more connected trackers. It reminds you to turn off your trackers when you are done to preserve battery life.
settings-general-interface-connected_trackers_warning-label = Connected trackers warning on exit
## Behavior settings
settings-interface-behavior = Behavior
settings-general-interface-dev_mode = Developer Mode
settings-general-interface-dev_mode-description = This mode can be useful if you need in-depth data or to interact with connected trackers on a more advanced level.
settings-general-interface-dev_mode-label = Developer Mode
settings-general-interface-use_tray = Minimize to system tray
settings-general-interface-use_tray-description = Lets you close the window without closing the SlimeVR Server so you can continue using it without having the GUI bothering you.
settings-general-interface-use_tray-label = Minimize to system tray
@@ -576,6 +580,15 @@ settings-general-interface-discord_presence-message = { $amount ->
[one] Using 1 tracker
*[other] Using { $amount } trackers
}
settings-interface-behavior-error_tracking = Error collection via Sentry.io
settings-interface-behavior-error_tracking-description =
To provide the best user experience, we collect anonymized error reports, performance metrics, and operating system information. This helps us detect bugs and issues with SlimeVR. These metrics are collected via Sentry.io.
<b>We do not collect personal information</b> such as your IP address or wireless credentials. SlimeVR values your privacy!
Do you consent to the collection of anonymized error data?
settings-interface-behavior-error_tracking-label = Send errors to developers
## Serial settings
settings-serial = Serial Console
@@ -1291,3 +1304,11 @@ unknown_device-modal-description = There is a new tracker with MAC address <b>{$
Do you want to connect it to SlimeVR?
unknown_device-modal-confirm = Sure!
unknown_device-modal-forget = Ignore it
## Error collection consent modal
error_collection_modal-title = Can we collect errors?
error_collection_modal-description = { settings-interface-behavior-error_tracking-description }
You can change this setting later in the Behavior section of the settings page.
error_collection_modal-confirm = I agree
error_collection_modal-cancel = I don't want to

View File

@@ -54,6 +54,7 @@ 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';
@@ -64,6 +65,8 @@ export const VersionContext = createContext('');
export const DOCS_SITE = 'https://docs.slimevr.dev';
export const SLIMEVR_DISCORD = 'https://discord.gg/slimevr';
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
function Layout() {
const { isMobile } = useBreakpoint('mobile');
useDiscordPresence();
@@ -73,7 +76,7 @@ function Layout() {
<SerialDetectionModal></SerialDetectionModal>
<VersionUpdateModal></VersionUpdateModal>
<UnknownDeviceModal></UnknownDeviceModal>
<Routes>
<SentryRoutes>
<Route element={<AppLayout />}>
<Route
path="/"
@@ -165,7 +168,7 @@ function Layout() {
</Route>
<Route path="*" element={<TopBar></TopBar>}></Route>
</Route>
</Routes>
</SentryRoutes>
</>
);
}

View File

@@ -1,13 +1,12 @@
import { useLayoutEffect } from 'react';
import { useConfig } from './hooks/config';
import { Outlet, useNavigate } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
export function AppLayout() {
const { loading, config } = useConfig();
const navigate = useNavigate();
const { config } = useConfig();
useLayoutEffect(() => {
if (loading || !config) return;
if (!config) return;
if (config.theme !== undefined) {
document.documentElement.dataset.theme = config.theme;
}
@@ -25,23 +24,7 @@ export function AppLayout() {
`${config.textSize}rem`
);
}
}, [config, loading]);
useLayoutEffect(() => {
if (config && !config.doneOnboarding) {
navigate('/onboarding/home');
}
}, [config?.doneOnboarding]);
// const location = useLocation();
// const navigationType = useNavigationType();
// useEffect(() => {
// if (import.meta.env.PROD) return;
// console.log('The current URL is', { ...location });
// console.log('The last navigation action was', navigationType);
// }, [location, navigationType]);
if (loading) return <></>;
}, [config]);
return (
<>

View File

@@ -0,0 +1,57 @@
import { Localized, useLocalization } from '@fluent/react';
import { BaseModal } from './commons/BaseModal';
import { Button } from './commons/Button';
import { Typography } from './commons/Typography';
export function ErrorConsentModal({
isOpen = true,
cancel,
accept,
}: {
/**
* Is the parent/sibling component opened?
*/
isOpen: boolean;
/**
* Function to trigger when you still want to close the app
*/
accept: () => void;
/**
* Function to trigger when cancelling app close
*/
cancel?: () => void;
}) {
const { l10n } = useLocalization();
return (
<BaseModal isOpen={isOpen} onRequestClose={cancel} closeable={false}>
<div className="flex flex-col gap-3">
<>
<div className="flex flex-col items-center gap-3 fill-accent-background-20">
<div className="flex flex-col items-center gap-2 max-w-[512px]">
<Typography variant="main-title">
{l10n.getString('error_collection_modal-title')}
</Typography>
<Localized
id={'error_collection_modal-description'}
elems={{ b: <b></b> }}
>
<Typography
variant="standard"
whitespace="whitespace-pre-line"
/>
</Localized>
</div>
</div>
<Button variant="primary" onClick={accept}>
{l10n.getString('error_collection_modal-confirm')}
</Button>
<Button variant="tertiary" onClick={cancel}>
{l10n.getString('error_collection_modal-cancel')}
</Button>
</>
</div>
</BaseModal>
);
}

View File

@@ -110,7 +110,7 @@ export function SerialDetectionModal() {
{l10n.getString('serial_detection-new_device-p1')}
</Typography>
</div>
<div className="flex flex-col gap-3 rounded-xl max-w-sm">
<div className="flex flex-col gap-3 rounded-xl max-w-sm sentry-mask">
<Localized
id="onboarding-wifi_creds-ssid"
attrs={{ placeholder: true, label: true }}

View File

@@ -29,6 +29,7 @@ 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,
@@ -333,6 +334,11 @@ export function TopBar({
getCurrentWindow().requestUserAttention(null);
}}
></TrackersStillOnModal>
<ErrorConsentModal
isOpen={config?.errorTracking === null}
accept={() => setConfig({ errorTracking: true })}
cancel={() => setConfig({ errorTracking: false })}
/>
</>
);
}

View File

@@ -5,17 +5,19 @@ import ReactModal from 'react-modal';
export function BaseModal({
children,
important = false,
closeable = true,
...props
}: {
isOpen: boolean;
children: ReactNode;
important?: boolean;
closeable?: boolean;
} & ReactModal.Props) {
return (
<ReactModal
{...props}
shouldCloseOnOverlayClick
shouldCloseOnEsc
shouldCloseOnOverlayClick={closeable}
shouldCloseOnEsc={closeable}
overlayClassName={
props.overlayClassName ||
classNames(

View File

@@ -87,7 +87,9 @@ export const InputInside = forwardRef<
<div className="relative w-full">
<input
type={forceText ? 'text' : type}
className={classNames(classes, { 'pr-10': type === 'password' })}
className={classNames(classes, {
'pr-10 sentry-mask': type === 'password',
})}
placeholder={placeholder || undefined}
autoComplete={autocomplete ? 'off' : 'on'}
onChange={onChange}

View File

@@ -11,6 +11,7 @@ export function Typography({
italic = false,
truncate = false,
textAlign,
sentryMask = false,
}: {
variant?:
| 'main-title'
@@ -37,6 +38,7 @@ export function Typography({
| 'text-start'
| 'text-end';
children?: ReactNode;
sentryMask?: boolean;
}) {
const tag = useMemo(() => {
const tags = {
@@ -72,6 +74,7 @@ export function Typography({
truncate && 'leading-3 text-ellipsis',
truncate && (config?.textSize ?? 12) > 12 && 'line-clamp-1',
truncate && (config?.textSize ?? 12) <= 12 && 'line-clamp-2',
sentryMask && 'sentry-mask',
]),
},
children || []

View File

@@ -62,3 +62,22 @@ export function ArrowRightIcon() {
</svg>
);
}
export function ArrowRightLeftIcon() {
return (
<svg
width="24"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
/>
</svg>
);
}

View File

@@ -52,7 +52,7 @@ export function WifiCredsPage() {
</div>
<div
className={classNames(
'flex flex-col gap-3 p-10 rounded-xl max-w-sm',
'flex flex-col gap-3 p-10 rounded-xl max-w-sm sentry-mask',
!state.alonePage && 'bg-background-70',
state.alonePage && 'bg-background-60'
)}

View File

@@ -326,7 +326,7 @@ export function BodyProportions({
<Typography variant="section-title" bold>
{l10n.getString(label)}
</Typography>
<Typography variant="mobile-title" bold>
<Typography variant="mobile-title" bold sentryMask>
{type === LabelType.GroupPart
? /* Make number rounding so it's based on .5 decimals */
percentageFormat.format(Math.round(value * 200) / 200)

View File

@@ -66,7 +66,9 @@ export function VerifyResultsStep({
{bodyParts?.map(({ bone, label, value }) => (
<div className="flex justify-between" key={bone}>
<Typography color="secondary">{label}</Typography>
<Typography bold>{(value * 100).toFixed(2)} CM</Typography>
<Typography bold sentryMask>
{(value * 100).toFixed(2)} CM
</Typography>
</div>
))}
{hasCalibration === ProcessStatus.PENDING &&

View File

@@ -1,17 +1,30 @@
import { ReactNode, useEffect, useContext } from 'react';
import { ConfigContextC, useConfigProvider } from '@/hooks/config';
import { ReactNode, useContext, useLayoutEffect } from 'react';
import { ConfigContextC, loadConfig, useConfigProvider } from '@/hooks/config';
import { DEFAULT_LOCALE, LangContext } from '@/i18n/config';
import { getSentryOrCompute } from '@/utils/sentry';
const config = await loadConfig();
if (config?.errorTracking !== undefined) {
// load sentry ASAP to catch early errors
getSentryOrCompute(config.errorTracking ?? false);
}
export function ConfigContextProvider({ children }: { children: ReactNode }) {
const context = useConfigProvider();
const context = useConfigProvider(config);
const { changeLocales } = useContext(LangContext);
useEffect(() => {
context.loadConfig().then((config) => {
changeLocales([config?.lang || DEFAULT_LOCALE]);
});
useLayoutEffect(() => {
changeLocales([config?.lang || DEFAULT_LOCALE]);
}, []);
useLayoutEffect(() => {
if (config?.errorTracking !== undefined) {
// Alows for sentry to refresh if user change the setting once the gui
// is initialized
getSentryOrCompute(config.errorTracking ?? false);
}
}, [config?.errorTracking]);
return (
<ConfigContextC.Provider value={context}>
{children}

View File

@@ -22,7 +22,7 @@ export function SettingSelectorMobile() {
},
{
label: l10n.getString('settings-sidebar-interface'),
value: { url: '/settings/interface', scrollTo: 'appearance' },
value: { url: '/settings/interface', scrollTo: 'notifications' },
},
{
label: l10n.getString('settings-sidebar-osc_router'),

View File

@@ -74,6 +74,9 @@ export function SettingsSidebar() {
<SettingsLink to="/settings/interface" scrollTo="notifications">
{l10n.getString('settings-sidebar-notifications')}
</SettingsLink>
<SettingsLink to="/settings/interface" scrollTo="behavior">
{l10n.getString('settings-sidebar-behavior')}
</SettingsLink>
<SettingsLink to="/settings/interface" scrollTo="appearance">
{l10n.getString('settings-sidebar-appearance')}
</SettingsLink>

View File

@@ -1,4 +1,4 @@
import { useLocalization } from '@fluent/react';
import { Localized, useLocalization } from '@fluent/react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { CheckBox } from '@/components/commons/Checkbox';
@@ -16,24 +16,28 @@ import { LangSelector } from '@/components/commons/LangSelector';
import { BellIcon } from '@/components/commons/icon/BellIcon';
import { Range } from '@/components/commons/Range';
import { Dropdown } from '@/components/commons/Dropdown';
import { ArrowRightLeftIcon } from '@/components/commons/icon/ArrowIcons';
import { isTrayAvailable } from '@/utils/tauri';
interface InterfaceSettingsForm {
appearance: {
devmode: boolean;
theme: string;
showNavbarOnboarding: boolean;
textSize: number;
fonts: string;
decorations: boolean;
};
behavior: {
devmode: boolean;
useTray: boolean;
discordPresence: boolean;
errorTracking: boolean;
};
notifications: {
watchNewDevices: boolean;
feedbackSound: boolean;
feedbackSoundVolume: number;
connectedTrackersWarning: boolean;
useTray: boolean;
discordPresence: boolean;
};
}
@@ -44,7 +48,6 @@ export function InterfaceSettings() {
const { control, watch, handleSubmit } = useForm<InterfaceSettingsForm>({
defaultValues: {
appearance: {
devmode: config?.debug ?? defaultConfig.debug,
theme: config?.theme ?? defaultConfig.theme,
showNavbarOnboarding:
config?.showNavbarOnboarding ?? defaultConfig.showNavbarOnboarding,
@@ -61,27 +64,34 @@ export function InterfaceSettings() {
connectedTrackersWarning:
config?.connectedTrackersWarning ??
defaultConfig.connectedTrackersWarning,
},
behavior: {
devmode: config?.debug ?? defaultConfig.debug,
useTray: config?.useTray ?? defaultConfig.useTray ?? false,
discordPresence:
config?.discordPresence ?? defaultConfig.discordPresence,
errorTracking: config?.errorTracking ?? false,
},
},
});
const onSubmit = (values: InterfaceSettingsForm) => {
setConfig({
debug: values.appearance.devmode,
watchNewDevices: values.notifications.watchNewDevices,
feedbackSound: values.notifications.feedbackSound,
feedbackSoundVolume: values.notifications.feedbackSoundVolume,
connectedTrackersWarning: values.notifications.connectedTrackersWarning,
theme: values.appearance.theme,
showNavbarOnboarding: values.appearance.showNavbarOnboarding,
fonts: values.appearance.fonts.split(','),
textSize: values.appearance.textSize,
connectedTrackersWarning: values.notifications.connectedTrackersWarning,
useTray: values.notifications.useTray,
discordPresence: values.notifications.discordPresence,
decorations: values.appearance.decorations,
useTray: values.behavior.useTray,
discordPresence: values.behavior.discordPresence,
debug: values.behavior.devmode,
errorTracking: values.behavior.errorTracking,
});
};
@@ -195,6 +205,17 @@ export function InterfaceSettings() {
)}
/>
</div>
</>
</SettingsPagePaneLayout>
<SettingsPagePaneLayout
icon={<ArrowRightLeftIcon></ArrowRightLeftIcon>}
id="behavior"
>
<>
<Typography variant="main-title">
{l10n.getString('settings-interface-behavior')}
</Typography>
{isTrayAvailable && (
<>
@@ -213,7 +234,7 @@ export function InterfaceSettings() {
variant="toggle"
control={control}
outlined
name="notifications.useTray"
name="behavior.useTray"
label={l10n.getString(
'settings-general-interface-use_tray-label'
)}
@@ -237,23 +258,12 @@ export function InterfaceSettings() {
variant="toggle"
control={control}
outlined
name="notifications.discordPresence"
name="behavior.discordPresence"
label={l10n.getString(
'settings-general-interface-discord_presence-label'
)}
/>
</div>
</>
</SettingsPagePaneLayout>
<SettingsPagePaneLayout
icon={<SquaresIcon></SquaresIcon>}
id="appearance"
>
<>
<Typography variant="main-title">
{l10n.getString('settings-interface-appearance')}
</Typography>
<Typography bold>
{l10n.getString('settings-general-interface-dev_mode')}
@@ -270,13 +280,49 @@ export function InterfaceSettings() {
variant="toggle"
control={control}
outlined
name="appearance.devmode"
name="behavior.devmode"
label={l10n.getString(
'settings-general-interface-dev_mode-label'
)}
/>
</div>
<Typography bold>
{l10n.getString('settings-interface-behavior-error_tracking')}
</Typography>
<div className="flex flex-col pt-1 pb-2">
<Localized
id={'settings-interface-behavior-error_tracking-description'}
elems={{ b: <b></b> }}
>
<Typography
color="secondary"
whitespace="whitespace-pre-line"
></Typography>
</Localized>
</div>
<div className="grid sm:grid-cols-2 pb-4">
<CheckBox
variant="toggle"
control={control}
outlined
name="behavior.errorTracking"
label={l10n.getString(
'settings-interface-behavior-error_tracking-label'
)}
/>
</div>
</>
</SettingsPagePaneLayout>
<SettingsPagePaneLayout
icon={<SquaresIcon></SquaresIcon>}
id="appearance"
>
<>
<Typography variant="main-title">
{l10n.getString('settings-interface-appearance')}
</Typography>
<Typography bold>
{l10n.getString('settings-interface-appearance-decorations')}
</Typography>

View File

@@ -291,7 +291,7 @@ export function TrackerSettingsPage() {
<Typography color="secondary">
{l10n.getString('tracker-infos-custom_name')}
</Typography>
<Typography>
<Typography sentry-mask>
{tracker?.tracker.info?.customName || '--'}
</Typography>
</div>
@@ -489,7 +489,7 @@ export function TrackerSettingsPage() {
deviceId={tracker.tracker.trackerId?.deviceId?.id}
/>
)}
<div className="flex flex-col gap-2 w-full mt-3">
<div className="flex flex-col gap-2 w-full mt-3 sentry-mask">
<Typography variant="section-title">
{l10n.getString('tracker-settings-name_section')}
</Typography>

View File

@@ -26,6 +26,7 @@ import { useDataFeedConfig } from './datafeed-config';
import { useWebsocketAPI } from './websocket-api';
import { error } from '@/utils/logging';
import { cacheWrap } from './cache';
import { updateSentryContext } from '@/utils/sentry';
export interface FirmwareRelease {
name: string;
@@ -114,6 +115,10 @@ export function useProvideAppContext(): AppContext {
dispatch({ type: 'datafeed', value: packet });
});
useEffect(() => {
updateSentryContext(state);
}, [state.datafeed?.devices]);
useRPCPacket(RpcMessage.ResetResponse, ({ status, resetType }: ResetResponseT) => {
if (!config?.feedbackSound) return;
try {

View File

@@ -38,15 +38,14 @@ export interface Config {
mirrorView: boolean;
assignMode: AssignMode;
discordPresence: boolean;
errorTracking: boolean | null;
decorations: boolean;
showNavbarOnboarding: boolean;
}
export interface ConfigContext {
config: Config | null;
loading: boolean;
setConfig: (config: Partial<Config>) => Promise<void>;
loadConfig: () => Promise<Config | null>;
saveConfig: () => Promise<void>;
}
@@ -65,6 +64,7 @@ export const defaultConfig: Omit<Config, 'devSettings'> = {
mirrorView: true,
assignMode: AssignMode.Core,
discordPresence: false,
errorTracking: null,
decorations: false,
showNavbarOnboarding: true,
};
@@ -87,9 +87,39 @@ function fallbackToDefaults(loadedConfig: any): Config {
return Object.assign({}, defaultConfig, loadedConfig);
}
export function useConfigProvider(): ConfigContext {
const [currConfig, set] = useState<Config | null>(null);
const [loading, setLoading] = useState(false);
// Move the load of the config ouside of react
// allows to load everything before the first render
export const loadConfig = async () => {
try {
const migrated = await store.get('configMigratedToTauri');
if (!migrated) {
const oldConfig = localStorage.getItem('config.json');
if (oldConfig) await store.set('config.json', oldConfig);
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);
// setLoading(false);
return loadedConfig;
} catch (e) {
error(e);
// setConfig(defaultConfig);
// setLoading(false);
return null;
}
};
export function useConfigProvider(initialConfig: Config | null): ConfigContext {
const [currConfig, set] = useState<Config | null>(
initialConfig || (defaultConfig as Config)
);
const tauri = useIsTauri();
useDebouncedEffect(
@@ -146,36 +176,7 @@ export function useConfigProvider(): ConfigContext {
return {
config: currConfig,
loading,
setConfig,
loadConfig: async () => {
setLoading(true);
try {
const migrated = await store.get('configMigratedToTauri');
if (!migrated) {
const oldConfig = localStorage.getItem('config.json');
if (oldConfig) await store.set('config.json', oldConfig);
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);
setLoading(false);
return loadedConfig;
} catch (e) {
error(e);
setConfig(defaultConfig);
setLoading(false);
return null;
}
},
saveConfig: async () => {
if (!tauri) return;
await (store as Store).save();

88
gui/src/utils/sentry.ts Normal file
View File

@@ -0,0 +1,88 @@
import * as Sentry from '@sentry/react';
import { error, log } from './logging';
import { useEffect } from 'react';
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import { AppState } from '@/hooks/app';
export function getSentryOrCompute(enabled = false) {
// if sentry is already initialized - SKIP
if (enabled && Sentry.isInitialized()) return;
const client = Sentry.getClient();
if (client) {
log(`${enabled ? 'Enabled' : 'Disabled'} error logging with Sentry.`);
client.getOptions().enabled = enabled;
return client;
}
if (!enabled) return;
const newClient = Sentry.init({
dsn: 'https://e9ef9f8541352c50cff8600ba520d348@o4507810483535872.ingest.de.sentry.io/4507810579284048',
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
Sentry.browserProfilingIntegration(),
Sentry.replayIntegration({
maskAllText: false,
maskAllInputs: true,
blockAllMedia: false,
}),
],
beforeSend: (ev) => (newClient?.getOptions().enabled ? ev : null),
environment: import.meta.env.MODE,
release: (__VERSION_TAG__ || __COMMIT_HASH__) + (__GIT_CLEAN__ ? '' : '-dirty'),
// Tracing
tracesSampleRate: import.meta.env.PROD ? 0.5 : 1.0, // Capture 50% of the transactions
// Set profilesSampleRate to 1.0 to profile every transaction.
// Since profilesSampleRate is relative to tracesSampleRate,
// the final profiling rate can be computed as tracesSampleRate * profilesSampleRate
// For example, a tracesSampleRate of 0.5 and profilesSampleRate of 0.5 would
// results in 25% of transactions being profiled (0.5*0.5=0.25)
profilesSampleRate: 0.2,
// Session Replay
replaysSessionSampleRate: import.meta.env.PROD ? 0.1 : 1.0, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
normalizeDepth: 8,
enabled,
});
if (!newClient) {
error('Couldnt initialize Sentry for error logging');
} else {
log('Initialized the Sentry client');
}
return newClient;
}
export function updateSentryContext(state: AppState) {
// We filter out the shit we dont want. We dont need rotation data or ip addresses
const trackers = (state.datafeed?.devices || []).map(
({ hardwareInfo, trackers, id }) => ({
id: id?.id,
hardwareInfo: { ...hardwareInfo, ipAddress: undefined },
trackers: trackers.map(({ info, trackerId }) => ({
info,
trackerId: {
trackerNum: trackerId?.trackerNum,
deviceId: trackerId?.deviceId?.id,
},
})),
})
);
// Will send the latest context to sentry when an error happens
Sentry.setContext('trackers', {
trackers,
});
}

View File

@@ -1,3 +1,4 @@
import { sentryVitePlugin } from '@sentry/vite-plugin';
import react from '@vitejs/plugin-react';
import { defineConfig, PluginOption } from 'vite';
import { execSync } from 'child_process';
@@ -39,13 +40,24 @@ export default defineConfig({
__VERSION_TAG__: JSON.stringify(versionTag),
__GIT_CLEAN__: gitClean,
},
plugins: [react(), i18nHotReload(), visualizer() as PluginOption],
plugins: [
react(),
i18nHotReload(),
visualizer() as PluginOption,
sentryVitePlugin({
org: 'slimevr',
project: 'slimevr-server-gui-react',
}),
],
build: {
target: 'es2022',
emptyOutDir: true,
commonjsOptions: {
include: [/solarxr-protocol/, /node_modules/],
},
sourcemap: true,
},
optimizeDeps: {
esbuildOptions: {

254
pnpm-lock.yaml generated
View File

@@ -38,6 +38,12 @@ importers:
'@react-three/fiber':
specifier: ^8.17.10
version: 8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)
'@sentry/react':
specifier: ^9.9.0
version: 9.9.0(react@18.3.1)
'@sentry/vite-plugin':
specifier: ^2.22.7
version: 2.22.7
'@tailwindcss/typography':
specifier: ^0.5.15
version: 0.5.15(tailwindcss@3.4.14(ts-node@9.1.1(typescript@5.6.3)))
@@ -964,6 +970,94 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@sentry-internal/browser-utils@9.9.0':
resolution: {integrity: sha512-V/YhKLis98JFkqBGZaEBlDNFpJHJjoCvNb05raAYXdITfDIl37Kxqj0zX+IzyRhqnswkQ+DBTyoEoci09IR2bQ==}
engines: {node: '>=18'}
'@sentry-internal/feedback@9.9.0':
resolution: {integrity: sha512-hrxuOLm0Xsnx75hTNt3eLgNNjER3egrHZShdRzlMiakfKpA9f2X10z75vlZmT5ZUygDQnp9UVUnu28cDuVb9Zw==}
engines: {node: '>=18'}
'@sentry-internal/replay-canvas@9.9.0':
resolution: {integrity: sha512-YK0ixGjquahGpNsQskCEVwycdHlwNBLCx9XJr1BmGnlOw6fUCmpyVetaGg/ZyhkzKGNXAGoTa4s7FUFnAG4bKg==}
engines: {node: '>=18'}
'@sentry-internal/replay@9.9.0':
resolution: {integrity: sha512-EWczKMu3qiZ0SUUWU3zkGod+AWD/VQCLiQw+tw+PEpdHbRZIdYKsEptengZCFKthrwe2QmYpVCTSRxGvujJ/6g==}
engines: {node: '>=18'}
'@sentry/babel-plugin-component-annotate@2.22.7':
resolution: {integrity: sha512-aa7XKgZMVl6l04NY+3X7BP7yvQ/s8scn8KzQfTLrGRarziTlMGrsCOBQtCNWXOPEbtxAIHpZ9dsrAn5EJSivOQ==}
engines: {node: '>= 14'}
'@sentry/browser@9.9.0':
resolution: {integrity: sha512-pIMdkOC+iggZefBs6ck5fL1mBhbLzjdw/8K99iqSeDh+lLvmlHVZajAhPlmw50xfH8CyQ1s22dhcL+zXbg3NKw==}
engines: {node: '>=18'}
'@sentry/bundler-plugin-core@2.22.7':
resolution: {integrity: sha512-ouQh5sqcB8vsJ8yTTe0rf+iaUkwmeUlGNFi35IkCFUQlWJ22qS6OfvNjOqFI19e6eGUXks0c/2ieFC4+9wJ+1g==}
engines: {node: '>= 14'}
'@sentry/cli-darwin@2.39.1':
resolution: {integrity: sha512-kiNGNSAkg46LNGatfNH5tfsmI/kCAaPA62KQuFZloZiemTNzhy9/6NJP8HZ/GxGs8GDMxic6wNrV9CkVEgFLJQ==}
engines: {node: '>=10'}
os: [darwin]
'@sentry/cli-linux-arm64@2.39.1':
resolution: {integrity: sha512-5VbVJDatolDrWOgaffsEM7znjs0cR8bHt9Bq0mStM3tBolgAeSDHE89NgHggfZR+DJ2VWOy4vgCwkObrUD6NQw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux, freebsd]
'@sentry/cli-linux-arm@2.39.1':
resolution: {integrity: sha512-DkENbxyRxUrfLnJLXTA4s5UL/GoctU5Cm4ER1eB7XN7p9WsamFJd/yf2KpltkjEyiTuplv0yAbdjl1KX3vKmEQ==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux, freebsd]
'@sentry/cli-linux-i686@2.39.1':
resolution: {integrity: sha512-pXWVoKXCRrY7N8vc9H7mETiV9ZCz+zSnX65JQCzZxgYrayQPJTc+NPRnZTdYdk5RlAupXaFicBI2GwOCRqVRkg==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [linux, freebsd]
'@sentry/cli-linux-x64@2.39.1':
resolution: {integrity: sha512-IwayNZy+it7FWG4M9LayyUmG1a/8kT9+/IEm67sT5+7dkMIMcpmHDqL8rWcPojOXuTKaOBBjkVdNMBTXy0mXlA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux, freebsd]
'@sentry/cli-win32-i686@2.39.1':
resolution: {integrity: sha512-NglnNoqHSmE+Dz/wHeIVRnV2bLMx7tIn3IQ8vXGO5HWA2f8zYJGktbkLq1Lg23PaQmeZLPGlja3gBQfZYSG10Q==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [win32]
'@sentry/cli-win32-x64@2.39.1':
resolution: {integrity: sha512-xv0R2CMf/X1Fte3cMWie1NXuHmUyQPDBfCyIt6k6RPFPxAYUgcqgMPznYwVMwWEA1W43PaOkSn3d8ZylsDaETw==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@sentry/cli@2.39.1':
resolution: {integrity: sha512-JIb3e9vh0+OmQ0KxmexMXg9oZsR/G7HMwxt5BUIKAXZ9m17Xll4ETXTRnRUBT3sf7EpNGAmlQk1xEmVN9pYZYQ==}
engines: {node: '>= 10'}
hasBin: true
'@sentry/core@9.9.0':
resolution: {integrity: sha512-GxKvx8PSgoWhLLS+/WBGIXy7rsFcnJBPDqFXIfcAGy89k2j06d9IP0kiIc63qBGStSUkh5FFJLPTakZ5RXiFXA==}
engines: {node: '>=18'}
'@sentry/react@9.9.0':
resolution: {integrity: sha512-7BE2Lx5CNtHtlNSS7Z9HxKquohC0xhdFceO3NlMXlx+dZuVCMoQmLISB8SQEcHw+2VO24MvtP3LPEzdeNbkIfg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
'@sentry/vite-plugin@2.22.7':
resolution: {integrity: sha512-sYRNiNm4toQGq2BfZSJPdw36em3eQaLu+3NTFpA7Hl4g3Sp2Rt3CYObnW5bxlFEruRhxzvdyB383N9OefVZ6KA==}
engines: {node: '>= 14'}
'@sindresorhus/is@5.6.0':
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
engines: {node: '>=14.16'}
@@ -1406,6 +1500,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@@ -2428,6 +2526,10 @@ packages:
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
engines: {node: '>=10.19.0'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
human-signals@1.1.1:
resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
engines: {node: '>=8.12.0'}
@@ -2822,6 +2924,10 @@ packages:
'@types/three': '>=0.144.0'
three: '>=0.144.0'
magic-string@0.30.8:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'}
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
@@ -3354,6 +3460,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
promise-worker-transferable@1.0.4:
resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==}
@@ -3370,6 +3480,9 @@ packages:
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
pump@3.0.0:
resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
@@ -4074,6 +4187,9 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
unplugin@1.0.1:
resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==}
update-browserslist-db@1.0.16:
resolution: {integrity: sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==}
hasBin: true
@@ -4159,6 +4275,13 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webpack-sources@3.2.3:
resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==}
engines: {node: '>=10.13.0'}
webpack-virtual-modules@0.5.0:
resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -5066,6 +5189,105 @@ snapshots:
'@rtsao/scc@1.1.0': {}
'@sentry-internal/browser-utils@9.9.0':
dependencies:
'@sentry/core': 9.9.0
'@sentry-internal/feedback@9.9.0':
dependencies:
'@sentry/core': 9.9.0
'@sentry-internal/replay-canvas@9.9.0':
dependencies:
'@sentry-internal/replay': 9.9.0
'@sentry/core': 9.9.0
'@sentry-internal/replay@9.9.0':
dependencies:
'@sentry-internal/browser-utils': 9.9.0
'@sentry/core': 9.9.0
'@sentry/babel-plugin-component-annotate@2.22.7': {}
'@sentry/browser@9.9.0':
dependencies:
'@sentry-internal/browser-utils': 9.9.0
'@sentry-internal/feedback': 9.9.0
'@sentry-internal/replay': 9.9.0
'@sentry-internal/replay-canvas': 9.9.0
'@sentry/core': 9.9.0
'@sentry/bundler-plugin-core@2.22.7':
dependencies:
'@babel/core': 7.25.8
'@sentry/babel-plugin-component-annotate': 2.22.7
'@sentry/cli': 2.39.1
dotenv: 16.4.5
find-up: 5.0.0
glob: 9.3.5
magic-string: 0.30.8
unplugin: 1.0.1
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/cli-darwin@2.39.1':
optional: true
'@sentry/cli-linux-arm64@2.39.1':
optional: true
'@sentry/cli-linux-arm@2.39.1':
optional: true
'@sentry/cli-linux-i686@2.39.1':
optional: true
'@sentry/cli-linux-x64@2.39.1':
optional: true
'@sentry/cli-win32-i686@2.39.1':
optional: true
'@sentry/cli-win32-x64@2.39.1':
optional: true
'@sentry/cli@2.39.1':
dependencies:
https-proxy-agent: 5.0.1
node-fetch: 2.7.0
progress: 2.0.3
proxy-from-env: 1.1.0
which: 2.0.2
optionalDependencies:
'@sentry/cli-darwin': 2.39.1
'@sentry/cli-linux-arm': 2.39.1
'@sentry/cli-linux-arm64': 2.39.1
'@sentry/cli-linux-i686': 2.39.1
'@sentry/cli-linux-x64': 2.39.1
'@sentry/cli-win32-i686': 2.39.1
'@sentry/cli-win32-x64': 2.39.1
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/core@9.9.0': {}
'@sentry/react@9.9.0(react@18.3.1)':
dependencies:
'@sentry/browser': 9.9.0
'@sentry/core': 9.9.0
hoist-non-react-statics: 3.3.2
react: 18.3.1
'@sentry/vite-plugin@2.22.7':
dependencies:
'@sentry/bundler-plugin-core': 2.22.7
unplugin: 1.0.1
transitivePeerDependencies:
- encoding
- supports-color
'@sindresorhus/is@5.6.0': {}
'@swc/core-darwin-arm64@1.6.5':
@@ -5524,6 +5746,12 @@ snapshots:
acorn@8.12.0: {}
agent-base@6.0.2:
dependencies:
debug: 4.3.7
transitivePeerDependencies:
- supports-color
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -6734,6 +6962,13 @@ snapshots:
quick-lru: 5.1.1
resolve-alpn: 1.2.1
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.3.7
transitivePeerDependencies:
- supports-color
human-signals@1.1.1: {}
human-signals@5.0.0: {}
@@ -7114,6 +7349,10 @@ snapshots:
'@types/three': 0.163.0
three: 0.163.0
magic-string@0.30.8:
dependencies:
'@jridgewell/sourcemap-codec': 1.4.15
make-error@1.3.6: {}
markdown-table@3.0.4: {}
@@ -7823,6 +8062,8 @@ snapshots:
prettier@3.3.3: {}
progress@2.0.3: {}
promise-worker-transferable@1.0.4:
dependencies:
is-promise: 2.2.2
@@ -7843,6 +8084,8 @@ snapshots:
property-information@6.5.0: {}
proxy-from-env@1.1.0: {}
pump@3.0.0:
dependencies:
end-of-stream: 1.4.4
@@ -8690,6 +8933,13 @@ snapshots:
universalify@2.0.1: {}
unplugin@1.0.1:
dependencies:
acorn: 8.12.0
chokidar: 3.6.0
webpack-sources: 3.2.3
webpack-virtual-modules: 0.5.0
update-browserslist-db@1.0.16(browserslist@4.23.1):
dependencies:
browserslist: 4.23.1
@@ -8751,6 +9001,10 @@ snapshots:
webidl-conversions@3.0.1: {}
webpack-sources@3.2.3: {}
webpack-virtual-modules@0.5.0: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3