diff --git a/src/app.css b/src/app.css index bb36d92e..91dd43a1 100644 --- a/src/app.css +++ b/src/app.css @@ -351,3 +351,11 @@ i.x-status-icon.red { [data-sonner-toast] [data-content] [data-description] { white-space: pre-line; } + +.main-layout-wrapper { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + min-height: 0; +} diff --git a/src/components/StatusBar.vue b/src/components/StatusBar.vue new file mode 100644 index 00000000..edd0b059 --- /dev/null +++ b/src/components/StatusBar.vue @@ -0,0 +1,609 @@ + + + + + diff --git a/src/components/__tests__/statusBarUtils.test.js b/src/components/__tests__/statusBarUtils.test.js new file mode 100644 index 00000000..fa3c2355 --- /dev/null +++ b/src/components/__tests__/statusBarUtils.test.js @@ -0,0 +1,338 @@ +import { describe, expect, test, beforeEach } from 'vitest'; + +import { + defaultVisibility, + formatAppUptime, + formatUtcHour, + loadClockCount, + loadClocks, + loadVisibility, + normalizeClock, + normalizeUtcHour, + parseClockOffset +} from '../statusBarUtils'; + +// ─── normalizeUtcHour ──────────────────────────────────────────────── + +describe('normalizeUtcHour', () => { + test('passes through normal integer values', () => { + expect(normalizeUtcHour(0)).toBe(0); + expect(normalizeUtcHour(5)).toBe(5); + expect(normalizeUtcHour(-5)).toBe(-5); + }); + + test('clamps to lower bound -12', () => { + expect(normalizeUtcHour(-12)).toBe(-12); + expect(normalizeUtcHour(-13)).toBe(-12); + expect(normalizeUtcHour(-100)).toBe(-12); + }); + + test('clamps to upper bound 14', () => { + expect(normalizeUtcHour(14)).toBe(14); + expect(normalizeUtcHour(15)).toBe(14); + expect(normalizeUtcHour(100)).toBe(14); + }); + + test('rounds fractional values', () => { + expect(normalizeUtcHour(5.4)).toBe(5); + expect(normalizeUtcHour(5.5)).toBe(6); + expect(normalizeUtcHour(-5.7)).toBe(-6); + }); + + test('returns 0 for NaN and Infinity', () => { + expect(normalizeUtcHour(NaN)).toBe(0); + expect(normalizeUtcHour(Infinity)).toBe(0); + expect(normalizeUtcHour(-Infinity)).toBe(0); + }); + + test('coerces string numbers', () => { + expect(normalizeUtcHour('9')).toBe(9); + expect(normalizeUtcHour('-3')).toBe(-3); + }); + + test('returns 0 for non-numeric strings', () => { + expect(normalizeUtcHour('abc')).toBe(0); + }); +}); + +// ─── formatUtcHour ─────────────────────────────────────────────────── + +describe('formatUtcHour', () => { + test('formats positive offsets with plus sign', () => { + expect(formatUtcHour(9)).toBe('UTC+9'); + expect(formatUtcHour(14)).toBe('UTC+14'); + }); + + test('formats negative offsets', () => { + expect(formatUtcHour(-5)).toBe('UTC-5'); + expect(formatUtcHour(-12)).toBe('UTC-12'); + }); + + test('formats zero as positive', () => { + expect(formatUtcHour(0)).toBe('UTC+0'); + }); + + test('normalises before formatting', () => { + expect(formatUtcHour(20)).toBe('UTC+14'); + expect(formatUtcHour(-20)).toBe('UTC-12'); + }); +}); + +// ─── parseClockOffset ──────────────────────────────────────────────── + +describe('parseClockOffset', () => { + test('parses numeric input', () => { + expect(parseClockOffset(9)).toBe(9); + expect(parseClockOffset(-3)).toBe(-3); + }); + + test('parses plain numeric strings', () => { + expect(parseClockOffset('5')).toBe(5); + expect(parseClockOffset('-7')).toBe(-7); + expect(parseClockOffset('+3')).toBe(3); + }); + + test('parses numeric strings with whitespace', () => { + expect(parseClockOffset(' 5 ')).toBe(5); + }); + + test('parses UTC+N pattern', () => { + expect(parseClockOffset('UTC+9')).toBe(9); + expect(parseClockOffset('UTC-5')).toBe(-5); + expect(parseClockOffset('utc+0')).toBe(0); + }); + + test('parses UTC pattern with half-hour offset', () => { + expect(parseClockOffset('UTC+5:30')).toBe(6); // 5.5 rounds to 6 + expect(parseClockOffset('UTC-9:30')).toBe(-9); // -9.5 rounds to -9 (Math.round toward +Infinity) + }); + + test('returns 0 for non-string non-number input', () => { + expect(parseClockOffset(null)).toBe(0); + expect(parseClockOffset(undefined)).toBe(0); + expect(parseClockOffset(true)).toBe(0); + expect(parseClockOffset([])).toBe(0); + }); + + test('returns 0 for unrecognised string patterns', () => { + expect(parseClockOffset('not-a-timezone')).toBe(0); + }); +}); + +// ─── normalizeClock ────────────────────────────────────────────────── + +describe('normalizeClock', () => { + test('normalises entry with offset key', () => { + expect(normalizeClock({ offset: 9 })).toEqual({ offset: 9 }); + expect(normalizeClock({ offset: '5' })).toEqual({ offset: 5 }); + }); + + test('normalises legacy entry with timezone key', () => { + expect(normalizeClock({ timezone: 'UTC+9' })).toEqual({ offset: 9 }); + }); + + test('prefers offset over timezone when both present', () => { + expect(normalizeClock({ offset: 3, timezone: 'UTC+9' })).toEqual({ + offset: 3 + }); + }); + + test('returns { offset: 0 } for non-object input', () => { + expect(normalizeClock(null)).toEqual({ offset: 0 }); + expect(normalizeClock(undefined)).toEqual({ offset: 0 }); + expect(normalizeClock(42)).toEqual({ offset: 0 }); + expect(normalizeClock('string')).toEqual({ offset: 0 }); + }); + + test('returns { offset: 0 } for object without known keys', () => { + expect(normalizeClock({ foo: 'bar' })).toEqual({ offset: 0 }); + expect(normalizeClock({})).toEqual({ offset: 0 }); + }); +}); + +// ─── loadVisibility ────────────────────────────────────────────────── + +describe('loadVisibility', () => { + let storage; + + beforeEach(() => { + storage = createMockStorage(); + }); + + test('returns defaults when storage is empty', () => { + expect(loadVisibility(storage)).toEqual(defaultVisibility); + }); + + test('merges saved values with defaults', () => { + storage.setItem( + 'VRCX_statusBarVisibility', + JSON.stringify({ vrchat: false, ws: false }) + ); + const result = loadVisibility(storage); + expect(result.vrchat).toBe(false); + expect(result.ws).toBe(false); + // Other defaults preserved + expect(result.proxy).toBe(true); + expect(result.zoom).toBe(true); + }); + + test('returns defaults on corrupt JSON', () => { + storage.setItem('VRCX_statusBarVisibility', '{bad-json'); + expect(loadVisibility(storage)).toEqual(defaultVisibility); + }); + + test('returns a new object each time (no shared reference)', () => { + const a = loadVisibility(storage); + const b = loadVisibility(storage); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); +}); + +// ─── loadClocks ────────────────────────────────────────────────────── + +describe('loadClocks', () => { + const defaults = [{ offset: 9 }, { offset: 0 }, { offset: -5 }]; + let storage; + + beforeEach(() => { + storage = createMockStorage(); + }); + + test('returns defaults when storage is empty', () => { + const result = loadClocks(storage, defaults); + expect(result).toEqual(defaults); + }); + + test('loads valid saved clocks', () => { + storage.setItem( + 'VRCX_statusBarClocks', + JSON.stringify([{ offset: 1 }, { offset: 2 }, { offset: 3 }]) + ); + expect(loadClocks(storage, defaults)).toEqual([ + { offset: 1 }, + { offset: 2 }, + { offset: 3 } + ]); + }); + + test('returns defaults for wrong array length', () => { + storage.setItem( + 'VRCX_statusBarClocks', + JSON.stringify([{ offset: 1 }]) + ); + expect(loadClocks(storage, defaults)).toEqual(defaults); + }); + + test('returns defaults for non-array JSON', () => { + storage.setItem('VRCX_statusBarClocks', JSON.stringify({ offset: 1 })); + expect(loadClocks(storage, defaults)).toEqual(defaults); + }); + + test('returns defaults on corrupt JSON', () => { + storage.setItem('VRCX_statusBarClocks', 'not-json'); + expect(loadClocks(storage, defaults)).toEqual(defaults); + }); + + test('normalises clock entries from storage', () => { + storage.setItem( + 'VRCX_statusBarClocks', + JSON.stringify([ + { offset: '5' }, + { timezone: 'UTC+3' }, + { offset: 99 } + ]) + ); + expect(loadClocks(storage, defaults)).toEqual([ + { offset: 5 }, + { offset: 3 }, + { offset: 14 } // clamped + ]); + }); + + test('returned defaults are independent copies', () => { + const a = loadClocks(storage, defaults); + const b = loadClocks(storage, defaults); + expect(a).not.toBe(b); + a[0].offset = 999; + expect(b[0].offset).not.toBe(999); + }); +}); + +// ─── loadClockCount ────────────────────────────────────────────────── + +describe('loadClockCount', () => { + let storage; + + beforeEach(() => { + storage = createMockStorage(); + }); + + test('returns 3 when storage is empty', () => { + expect(loadClockCount(storage)).toBe(3); + }); + + test.each([0, 1, 2, 3])('returns valid stored count %i', (n) => { + storage.setItem('VRCX_statusBarClockCount', String(n)); + expect(loadClockCount(storage)).toBe(n); + }); + + test('returns 3 for out-of-range values', () => { + storage.setItem('VRCX_statusBarClockCount', '4'); + expect(loadClockCount(storage)).toBe(3); + + storage.setItem('VRCX_statusBarClockCount', '-1'); + expect(loadClockCount(storage)).toBe(3); + }); + + test('returns 3 for non-numeric values', () => { + storage.setItem('VRCX_statusBarClockCount', 'abc'); + expect(loadClockCount(storage)).toBe(3); + }); +}); + +// ─── formatAppUptime ───────────────────────────────────────────────── + +describe('formatAppUptime', () => { + test('formats zero seconds', () => { + expect(formatAppUptime(0)).toBe('00:00:00'); + }); + + test('formats seconds only', () => { + expect(formatAppUptime(45)).toBe('00:00:45'); + }); + + test('formats minutes and seconds', () => { + expect(formatAppUptime(125)).toBe('00:02:05'); + }); + + test('formats hours, minutes, and seconds', () => { + expect(formatAppUptime(3661)).toBe('01:01:01'); + }); + + test('handles large values (over 24 hours)', () => { + // 100 hours = 360000 seconds + expect(formatAppUptime(360000)).toBe('100:00:00'); + }); + + test('treats negative values as zero', () => { + expect(formatAppUptime(-10)).toBe('00:00:00'); + }); + + test('floors fractional seconds', () => { + expect(formatAppUptime(59.9)).toBe('00:00:59'); + }); +}); + +// ─── test helpers ──────────────────────────────────────────────────── + +/** Minimal in-memory Storage-like object for testing. */ +function createMockStorage() { + const data = new Map(); + return { + getItem: (key) => (data.has(key) ? data.get(key) : null), + setItem: (key, value) => data.set(key, String(value)), + removeItem: (key) => data.delete(key), + clear: () => data.clear() + }; +} diff --git a/src/components/statusBarUtils.js b/src/components/statusBarUtils.js new file mode 100644 index 00000000..4eb8a364 --- /dev/null +++ b/src/components/statusBarUtils.js @@ -0,0 +1,167 @@ +import dayjs from 'dayjs'; + +/** + * Default visibility flags for StatusBar indicators. + */ +export const defaultVisibility = { + vrchat: true, + steamvr: true, + proxy: true, + ws: true, + uptime: true, + clocks: true, + zoom: true +}; + +/** + * Clamp and round a numeric value to a valid UTC offset range [-12, 14]. + * Returns 0 for non-finite values. + * @param {*} value - raw offset value + * @returns {number} normalised integer offset + */ +export function normalizeUtcHour(value) { + const n = Number(value); + if (!Number.isFinite(n)) return 0; + return Math.max(-12, Math.min(14, Math.round(n))); +} + +/** + * Format a numeric UTC offset as a human-readable string, e.g. "UTC+9", "UTC-5". + * @param {number} offset + * @returns {string} + */ +export function formatUtcHour(offset) { + const n = normalizeUtcHour(offset); + return `UTC${n >= 0 ? '+' : ''}${n}`; +} + +/** + * Parse a clock offset value into a normalised integer. + * + * Accepted inputs: + * - number (clamped/rounded) + * - numeric string like `"5"` or `"-3"` + * - UTC pattern like `"UTC+9"`, `"UTC-5:30"` + * - legacy IANA timezone name (resolved via dayjs) + * @param {*} value + * @returns {number} + */ +export function parseClockOffset(value) { + if (typeof value === 'number') { + return normalizeUtcHour(value); + } + if (typeof value !== 'string') { + return 0; + } + if (/^[+-]?\d+$/.test(value.trim())) { + return normalizeUtcHour(Number(value)); + } + const utcMatch = value.match(/^UTC([+-])(\d{1,2})(?::(\d{1,2}))?$/i); + if (utcMatch) { + const sign = utcMatch[1] === '+' ? 1 : -1; + const hours = Number(utcMatch[2]); + const minutes = Number(utcMatch[3] || 0); + return normalizeUtcHour(sign * (hours + minutes / 60)); + } + // Backward compatibility: old clocks stored IANA timezone names. + try { + return normalizeUtcHour(dayjs().tz(value).utcOffset() / 60); + } catch { + return 0; + } +} + +/** + * Normalise a single clock config entry. + * Handles current `{ offset }` format and legacy `{ timezone }` format. + * @param {*} entry + * @returns {{ offset: number }} + */ +export function normalizeClock(entry) { + if (entry && typeof entry === 'object') { + if ('offset' in entry) { + return { offset: parseClockOffset(entry.offset) }; + } + if ('timezone' in entry) { + return { offset: parseClockOffset(entry.timezone) }; + } + } + return { offset: 0 }; +} + +/** + * Load visibility settings from a Storage-like object, merging with defaults. + * @param {Storage} storage - object with `getItem(key)` method + * @returns {object} + */ +export function loadVisibility(storage) { + try { + const saved = storage.getItem('VRCX_statusBarVisibility'); + if (saved) { + return { ...defaultVisibility, ...JSON.parse(saved) }; + } + } catch { + // ignore + } + return { ...defaultVisibility }; +} + +/** + * Load saved clocks array from a Storage-like object. + * Returns the default clocks when stored data is absent or invalid. + * @param {Storage} storage + * @param {Array} defaults - fallback clock definitions + * @returns {Array<{ offset: number }>} + */ +export function loadClocks(storage, defaults) { + try { + const saved = storage.getItem('VRCX_statusBarClocks'); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.length === 3) { + return parsed.map(normalizeClock); + } + } + } catch { + // ignore + } + return defaults.map((c) => ({ ...c })); +} + +/** + * Load the clock count (0-3) from a Storage-like object. + * Returns 3 when stored data is absent or invalid. + * @param {Storage} storage + * @returns {number} + */ +export function loadClockCount(storage) { + try { + const saved = storage.getItem('VRCX_statusBarClockCount'); + if (saved !== null) { + const n = Number(saved); + if (n >= 0 && n <= 3) return n; + } + } catch { + // ignore + } + return 3; +} + +/** + * Format an elapsed-seconds value into an `HH:MM:SS` string. + * @param {number} elapsedSeconds + * @returns {string} + */ +export function formatAppUptime(elapsedSeconds) { + const safe = Math.max(0, Math.floor(elapsedSeconds)); + const hours = Math.floor(safe / 3600) + .toString() + .padStart(2, '0'); + const minutes = Math.floor((safe % 3600) / 60) + .toString() + .padStart(2, '0'); + const seconds = Math.floor(safe % 60) + .toString() + .padStart(2, '0'); + return `${hours}:${minutes}:${seconds}`; +} diff --git a/src/localization/en.json b/src/localization/en.json index a838ad4c..8fd9509b 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -660,6 +660,7 @@ "zoom": "Zoom", "vrcplus_profile_icons": "VRCPlus Profile Icons", "show_instance_id": "Show Instance Name", + "show_status_bar": "Show Status Bar", "age_gated_instances": "Age Gated Instances", "nicknames": "Memo Nicknames", "sort_favorite_by": "Sort Favorites by", @@ -2627,5 +2628,26 @@ "back_to_top": "Back to top", "toggle_password": "Toggle password visibility", "clear_input": "Clear input" + }, + "status_bar": { + "vrchat": "VRChat", + "vrchat_running": "VRChat is running", + "vrchat_stopped": "VRChat is not running", + "steamvr": "SteamVR", + "steamvr_running": "SteamVR is running", + "steamvr_stopped": "SteamVR is not running", + "proxy": "Proxy", + "ws_connected": "Connected", + "ws_disconnected": "Disconnected", + "ws_avg_per_minute": "{count} avg/min", + "zoom": "Zoom", + "zoom_tooltip": "Click to adjust zoom level", + "app_uptime": "Application uptime", + "app_uptime_short": "Uptime", + "clocks": "Clocks", + "clock": "Clock", + "clocks_label": "Clocks", + "clocks_none": "None", + "timezone": "Timezone" } } diff --git a/src/service/websocket.js b/src/service/websocket.js index e4ba4c36..860ebe27 100644 --- a/src/service/websocket.js +++ b/src/service/websocket.js @@ -1,3 +1,5 @@ +import { reactive } from 'vue'; + import Noty from 'noty'; import { @@ -22,6 +24,20 @@ import * as workerTimers from 'worker-timers'; let webSocket = null; let lastWebSocketMessage = ''; +/** + * Reactive WebSocket state for status bar telemetry. + * - connected: whether the WS is currently open + * - messageCount: total messages received (used for rate delta) + */ +export const wsState = reactive({ + connected: false, + messageCount: 0, + bytesReceived: 0 +}); + +/** + * + */ export function initWebsocket() { if (!watchState.isFriendsLoaded || webSocket !== null) { return; @@ -53,11 +69,13 @@ function connectWebSocket(token) { } const socket = new WebSocket(`${AppDebug.websocketDomain}/?auth=${token}`); socket.onopen = () => { + wsState.connected = true; if (AppDebug.debugWebSocket) { console.log('WebSocket connected'); } }; socket.onclose = () => { + wsState.connected = false; if (webSocket === socket) { webSocket = null; } @@ -96,6 +114,8 @@ function connectWebSocket(token) { }; socket.onmessage = ({ data }) => { try { + wsState.messageCount++; + wsState.bytesReceived += data.length; if (lastWebSocketMessage === data) { // pls no spam return; diff --git a/src/stores/settings/appearance.js b/src/stores/settings/appearance.js index 51d79c86..bd49a661 100644 --- a/src/stores/settings/appearance.js +++ b/src/stores/settings/appearance.js @@ -109,6 +109,7 @@ export const useAppearanceSettingsStore = defineStore( const isDataTableStriped = ref(false); const showPointerOnHover = ref(false); + const showStatusBar = ref(true); const tableLimitsDialog = ref({ visible: false, maxTableSize: 500, @@ -167,6 +168,7 @@ export const useAppearanceSettingsStore = defineStore( navIsCollapsedConfig, dataTableStripedConfig, showPointerOnHoverConfig, + showStatusBarConfig, appFontFamilyConfig, lastDarkThemeConfig ] = await Promise.all([ @@ -230,6 +232,7 @@ export const useAppearanceSettingsStore = defineStore( configRepository.getBool('VRCX_navIsCollapsed', false), configRepository.getBool('VRCX_dataTableStriped', false), configRepository.getBool('VRCX_showPointerOnHover', false), + configRepository.getBool('VRCX_showStatusBar', true), configRepository.getString( 'VRCX_fontFamily', APP_FONT_DEFAULT_KEY @@ -330,6 +333,7 @@ export const useAppearanceSettingsStore = defineStore( isNavCollapsed.value = navIsCollapsedConfig; isDataTableStriped.value = dataTableStripedConfig; showPointerOnHover.value = showPointerOnHoverConfig; + showStatusBar.value = showStatusBarConfig; applyPointerHoverClass(); @@ -560,6 +564,13 @@ export const useAppearanceSettingsStore = defineStore( showInstanceIdInLocation.value ); } + /** + * + */ + function setShowStatusBar() { + showStatusBar.value = !showStatusBar.value; + configRepository.setBool('VRCX_showStatusBar', showStatusBar.value); + } /** * */ @@ -1088,6 +1099,7 @@ export const useAppearanceSettingsStore = defineStore( isNavCollapsed, isDataTableStriped, showPointerOnHover, + showStatusBar, tableLimitsDialog, TABLE_MAX_SIZE_MIN, TABLE_MAX_SIZE_MAX, @@ -1098,6 +1110,7 @@ export const useAppearanceSettingsStore = defineStore( setDisplayVRCPlusIconsAsAvatar, setHideNicknames, setShowInstanceIdInLocation, + setShowStatusBar, setIsAgeGatedInstancesVisible, setSortFavorites, setInstanceUsersSortAlphabetical, diff --git a/src/views/Layout/MainLayout.vue b/src/views/Layout/MainLayout.vue index cc0c9488..46323e1b 100644 --- a/src/views/Layout/MainLayout.vue +++ b/src/views/Layout/MainLayout.vue @@ -1,55 +1,61 @@