This commit is contained in:
pa
2026-03-10 17:44:15 +09:00
parent 17b582c904
commit ff1529920b
237 changed files with 419 additions and 419 deletions

18
src/plugins/components.js Normal file
View File

@@ -0,0 +1,18 @@
import { TooltipWrapper } from '../components/ui/tooltip';
import AvatarInfo from '../components/AvatarInfo.vue';
import CountdownTimer from '../components/CountdownTimer.vue';
import DisplayName from '../components/DisplayName.vue';
import Location from '../components/Location.vue';
import LocationWorld from '../components/LocationWorld.vue';
import Timer from '../components/Timer.vue';
export function initComponents(app) {
app.component('Location', Location);
app.component('Timer', Timer);
app.component('CountdownTimer', CountdownTimer);
app.component('AvatarInfo', AvatarInfo);
app.component('DisplayName', DisplayName);
app.component('LocationWorld', LocationWorld);
app.component('TooltipWrapper', TooltipWrapper);
}

18
src/plugins/dayjs.js Normal file
View File

@@ -0,0 +1,18 @@
import customParseFormat from 'dayjs/plugin/customParseFormat';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
export function initDayjs() {
dayjs.extend(duration);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(isSameOrAfter);
dayjs.extend(localizedFormat);
dayjs.extend(customParseFormat);
dayjs.extend(relativeTime);
}

31
src/plugins/i18n.js Normal file
View File

@@ -0,0 +1,31 @@
import { createI18n } from 'vue-i18n';
import { getLocalizedStrings } from '../localization';
const FALLBACK_LOCALE = 'en';
const i18n = createI18n({
locale: FALLBACK_LOCALE,
fallbackLocale: FALLBACK_LOCALE,
legacy: false,
globalInjection: false,
missingWarn: false,
warnHtmlMessage: false,
fallbackWarn: false
});
async function loadLocalizedStrings(code) {
const localesToLoad =
code === FALLBACK_LOCALE ? [FALLBACK_LOCALE] : [FALLBACK_LOCALE, code];
for (const locale of localesToLoad) {
const messages = await getLocalizedStrings(locale);
i18n.global.setLocaleMessage(locale, messages);
}
}
async function updateLocalizedStrings() {
await loadLocalizedStrings(i18n.global.locale.value);
}
export { i18n, loadLocalizedStrings, updateLocalizedStrings };

24
src/plugins/index.js Normal file
View File

@@ -0,0 +1,24 @@
import { initDayjs } from './dayjs';
import { initInteropApi } from './interopApi';
import { initNoty } from './noty';
import { initUi } from './ui';
/**
* @param {boolean} isVrOverlay
* @returns {Promise<void>}
*/
export async function initPlugins(isVrOverlay = false) {
await initInteropApi(isVrOverlay);
if (!isVrOverlay) {
await initUi();
}
initDayjs();
if (isVrOverlay) {
initNoty(true);
}
}
export * from './i18n';
export * from './components';
export * from './sentry';
export * from './router';

42
src/plugins/interopApi.js Normal file
View File

@@ -0,0 +1,42 @@
// @ts-nocheck
import InteropApi from '../ipc-electron/interopApi.js';
import configRepository from '../services/config.js';
import vrcxJsonStorage from '../services/jsonStorage.js';
export async function initInteropApi(isVrOverlay = false) {
if (isVrOverlay) {
if (WINDOWS) {
await CefSharp.BindObjectAsync('AppApiVr');
} else {
// @ts-ignore
window.AppApiVr = InteropApi.AppApiVrElectron;
}
} else {
// #region | Init Cef C# bindings
if (WINDOWS) {
await CefSharp.BindObjectAsync(
'AppApi',
'WebApi',
'VRCXStorage',
'SQLite',
'LogWatcher',
'Discord',
'AssetBundleManager'
);
} else {
window.AppApi = InteropApi.AppApiElectron;
window.WebApi = InteropApi.WebApi;
window.VRCXStorage = InteropApi.VRCXStorage;
window.SQLite = InteropApi.SQLite;
window.LogWatcher = InteropApi.LogWatcher;
window.Discord = InteropApi.Discord;
window.AssetBundleManager = InteropApi.AssetBundleManager;
window.AppApiVrElectron = InteropApi.AppApiVrElectron;
}
await configRepository.init();
new vrcxJsonStorage(VRCXStorage);
AppApi.SetUserAgent();
}
}

25
src/plugins/noty.js Normal file
View File

@@ -0,0 +1,25 @@
import Noty from 'noty';
export function initNoty(isVrOverlay = false) {
if (isVrOverlay) {
Noty.overrideDefaults({
animation: {
open: 'animate__animated animate__fadeIn',
close: 'animate__animated animate__zoomOut'
},
layout: 'topCenter',
theme: 'relax',
timeout: 3000
});
} else {
Noty.overrideDefaults({
animation: {
open: 'animate__animated animate__bounceInLeft',
close: 'animate__animated animate__bounceOutLeft'
},
layout: 'bottomLeft',
theme: 'mint',
timeout: 2000
});
}
}

View File

@@ -0,0 +1,135 @@
import dayjs from 'dayjs';
const STORAGE_KEY = 'vrcx:sentry:piniaActions';
const DEFAULT_MAX_ENTRIES = 200;
function getStorage() {
try {
return localStorage;
} catch {
return null;
}
}
function safeJsonParse(value) {
if (!value) return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
function safeJsonStringify(value, fallback = '[]') {
try {
return JSON.stringify(value);
} catch {
return fallback;
}
}
export function getPiniaActionTrail() {
const storage = getStorage();
if (!storage) return [];
const data = safeJsonParse(storage.getItem(STORAGE_KEY));
return Array.isArray(data) ? data : [];
}
export function clearPiniaActionTrail() {
const storage = getStorage();
if (!storage) return;
storage.removeItem(STORAGE_KEY);
}
export function appendPiniaActionTrail(entry, options) {
const storage = getStorage();
if (!storage) return;
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
const existing = getPiniaActionTrail();
existing.push(entry);
if (existing.length > maxEntries) {
existing.splice(0, existing.length - maxEntries);
}
try {
storage.setItem(STORAGE_KEY, safeJsonStringify(existing));
} catch {
// ignore
}
}
export function createPiniaActionTrailPlugin(options) {
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
return ({ store }) => {
store.$onAction(({ name }) => {
appendPiniaActionTrail(
{
t: dayjs().format('HH:mm:ss'),
a: name
},
{ maxEntries }
);
});
};
}
function readPerformanceMemory() {
// @ts-ignore
const memory = window?.performance?.memory;
if (!memory) return null;
const { usedJSHeapSize, jsHeapSizeLimit } = memory;
if (
typeof usedJSHeapSize !== 'number' ||
typeof jsHeapSizeLimit !== 'number'
) {
return null;
}
return { usedJSHeapSize, jsHeapSizeLimit };
}
export function startRendererMemoryThresholdReport(
Sentry,
{ intervalMs = 10_000, thresholdRatio = 0.8, cooldownMs = 5 * 60_000 } = {}
) {
const initial = readPerformanceMemory();
if (!initial) return null;
if (!Sentry?.withScope) return null;
let lastSent = 0;
return setInterval(() => {
const m = readPerformanceMemory();
if (!m) return;
const ratio = m.usedJSHeapSize / m.jsHeapSizeLimit;
if (!Number.isFinite(ratio) || ratio < thresholdRatio) return;
const now = Date.now();
if (now - lastSent < cooldownMs) return;
lastSent = now;
const trail = getPiniaActionTrail();
const trailText = JSON.stringify(trail);
Sentry.withScope((scope) => {
scope.setLevel('warning');
scope.setTag('reason', 'high-js-heap');
scope.setContext('memory', {
usedMB: m.usedJSHeapSize / 1024 / 1024,
limitMB: m.jsHeapSizeLimit / 1024 / 1024,
ratio
});
scope.setContext('pinia_actions', {
trailText,
count: trail.length
});
Sentry.captureMessage(
'Memory usage critical: nearing JS heap limit'
);
});
}, intervalMs);
}

151
src/plugins/router.js Normal file
View File

@@ -0,0 +1,151 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import { watchState } from '../services/watchState';
import FavoritesAvatar from './../views/Favorites/FavoritesAvatar.vue';
import FavoritesFriend from './../views/Favorites/FavoritesFriend.vue';
import FavoritesWorld from './../views/Favorites/FavoritesWorld.vue';
import Feed from './../views/Feed/Feed.vue';
import FriendList from './../views/FriendList/FriendList.vue';
import FriendLog from './../views/FriendLog/FriendLog.vue';
import FriendsLocations from './../views/FriendsLocations/FriendsLocations.vue';
import Gallery from './../views/Tools/Gallery.vue';
import GameLog from './../views/GameLog/GameLog.vue';
import Login from './../views/Login/Login.vue';
import MainLayout from '../views/Layout/MainLayout.vue';
import Moderation from './../views/Moderation/Moderation.vue';
import MyAvatars from './../views/MyAvatars/MyAvatars.vue';
import Notification from './../views/Notifications/Notification.vue';
import PlayerList from './../views/PlayerList/PlayerList.vue';
import ScreenshotMetadata from './../views/Tools/ScreenshotMetadata.vue';
import Search from './../views/Search/Search.vue';
import Settings from './../views/Settings/Settings.vue';
import Tools from './../views/Tools/Tools.vue';
const routes = [
{
path: '/login',
name: 'login',
component: Login,
meta: { public: true }
},
{
path: '/',
component: MainLayout,
meta: { requiresAuth: true },
children: [
{ path: '', redirect: { name: 'feed' } },
{ path: 'feed', name: 'feed', component: Feed },
{
path: 'friends-locations',
name: 'friends-locations',
component: FriendsLocations
},
{ path: 'game-log', name: 'game-log', component: GameLog },
{ path: 'player-list', name: 'player-list', component: PlayerList },
{ path: 'search', name: 'search', component: Search },
{
path: 'favorites/friends',
name: 'favorite-friends',
component: FavoritesFriend
},
{
path: 'favorites/worlds',
name: 'favorite-worlds',
component: FavoritesWorld
},
{
path: 'favorites/avatars',
name: 'favorite-avatars',
component: FavoritesAvatar
},
{
path: 'social/friend-log',
name: 'friend-log',
component: FriendLog
},
{
path: 'social/moderation',
name: 'moderation',
component: Moderation
},
{
path: 'my-avatars',
name: 'my-avatars',
component: MyAvatars
},
{
path: 'notification',
name: 'notification',
component: Notification
},
{
path: 'social/friend-list',
name: 'friend-list',
component: FriendList
},
{
path: 'charts',
name: 'charts',
redirect: { name: 'charts-instance' }
},
{
path: 'charts/instance',
name: 'charts-instance',
component: () =>
import('./../views/Charts/components/InstanceActivity.vue')
},
{
path: 'charts/mutual',
name: 'charts-mutual',
component: () =>
import('./../views/Charts/components/MutualFriends.vue')
},
{ path: 'tools', name: 'tools', component: Tools },
{
path: 'tools/gallery',
name: 'gallery',
component: Gallery,
meta: { navKey: 'tools' }
},
{
path: 'tools/screenshot-metadata',
name: 'screenshot-metadata',
component: ScreenshotMetadata,
meta: { navKey: 'tools' }
},
{ path: 'settings', name: 'settings', component: Settings }
]
}
];
export const router = createRouter({
history: createWebHashHistory(),
// @ts-ignore
routes
});
export function initRouter(app) {
app.use(router);
}
router.beforeEach((to) => {
if (to.path === '/social') {
return false;
}
if (to.name === 'login' && watchState.isLoggedIn) {
return { name: 'feed' };
}
const requiresAuth = to.matched.some((record) => record.meta?.requiresAuth);
if (requiresAuth && !watchState.isLoggedIn) {
const redirect = to.fullPath;
if (redirect && redirect !== '/feed') {
return { name: 'login', query: { redirect } };
}
return { name: 'login' };
}
return true;
});

110
src/plugins/sentry.js Normal file
View File

@@ -0,0 +1,110 @@
import { router } from './router';
import { startRendererMemoryThresholdReport } from './piniaActionTrail';
import configRepository from '../services/config';
export async function isSentryOptedIn() {
return NIGHTLY && configRepository.getBool('VRCX_SentryEnabled', false);
}
/**
* Guarded import, prevents leaking Sentry into non-nightly bundles.
*/
export function getSentry() {
return NIGHTLY ? import('@sentry/vue') : null;
}
export async function initSentry(app) {
if (!NIGHTLY) return;
try {
if (!(await isSentryOptedIn())) return;
const vrcxId = await configRepository.getString('VRCX_id', '');
const response = await webApiService.execute({
url: 'https://api0.vrcx.app/errorreporting/getdsn',
method: 'GET',
headers: {
Referer: 'https://vrcx.app',
'VRCX-ID': vrcxId
}
});
if (response.status !== 200) {
console.error(
'Failed to get Sentry DSN:',
response.status,
response.data
);
return;
}
const dsn = atob(response.data);
const Sentry = await getSentry();
Sentry.init({
app,
dsn,
environment: 'nightly',
release: VERSION,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
tracesSampleRate: 0.0001,
beforeSend(event, hint) {
const error = hint.originalException;
if (error && typeof error.message === 'string') {
if (
error.message.includes('401') ||
error.message.includes('403') ||
error.message.includes('404') ||
error.message.includes('500') ||
error.message.includes('503') ||
error.message.includes('No such host is known') ||
error.message.includes(
'The SSL connection could not be established'
) ||
error.message.includes('A connection attempt failed') ||
error.message.includes(
'no data of the requested type was found'
) ||
error.message.includes(
'An error occurred while sending the request'
) ||
error.message.includes('database or disk is full') ||
error.message.includes('disk I/O error') ||
error.message.includes(
'There is not enough space on the disk.'
) ||
error.message.includes(
'The requested address is not valid in its context.'
)
) {
return null;
}
return event;
}
return event;
},
integrations: [
Sentry.replayIntegration({
maskAllText: true,
blockAllMedia: true
}),
Sentry.browserTracingIntegration({ router }),
Sentry.vueIntegration({
tracingOptions: {
trackComponents: true
}
})
]
});
console.log('Sentry initialized');
startRendererMemoryThresholdReport(Sentry, {
thresholdRatio: 0.8,
intervalMs: 10_000,
cooldownMs: 5 * 60_000
});
} catch (e) {
console.error('Failed to initialize Sentry:', e);
return;
}
}

35
src/plugins/ui.js Normal file
View File

@@ -0,0 +1,35 @@
import {
// changeAppDarkStyle,
changeAppThemeStyle,
changeHtmlLangAttribute,
getThemeMode,
initThemeColor,
refreshCustomCss
// setLoginContainerStyle
} from '../shared/utils/base/ui';
import { i18n, loadLocalizedStrings } from './i18n';
import configRepository from '../services/config';
export async function initUi() {
try {
const language = await configRepository.getString(
'VRCX_appLanguage',
'en'
);
// @ts-ignore
i18n.locale = language;
await loadLocalizedStrings(language);
changeHtmlLangAttribute(language);
const { initThemeMode, isDarkMode } =
await getThemeMode(configRepository);
// setLoginContainerStyle(isDarkMode);
changeAppThemeStyle(initThemeMode);
await initThemeColor();
} catch (error) {
console.error('Error initializing locale and theme:', error);
}
refreshCustomCss();
}