♻️ refactor: centralize app settings

This commit is contained in:
2026-06-30 21:16:05 +07:00
parent 5fe93b26df
commit 9b90140cfd
20 changed files with 284 additions and 134 deletions
+43 -3
View File
@@ -2,18 +2,48 @@ import { app } from "electron";
import { join } from "node:path"; import { join } from "node:path";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { jsonFile } from "../lib/jsonFile"; import { jsonFile } from "../lib/jsonFile";
import type { AppConfig, PreferredRegion } from "../../shared/types/appConfig"; import type { AppConfig, AppPreferences, PreferredRegion } from "../../shared/types/appConfig";
import { DEFAULT_LOCALE, isAppLocale } from "../../shared/locales";
import { detectedGamePath } from "../game/steam"; import { detectedGamePath } from "../game/steam";
const path = () => join(app.getPath("userData"), "app-config.json"); const path = () => join(app.getPath("userData"), "app-config.json");
type StoredConfig = Omit<AppConfig, "version" | "detectedGamePath">; type StoredConfig = Omit<AppConfig, "version" | "detectedGamePath">;
const DEFAULTS: StoredConfig = { gamePath: null, preferredRegion: "auto" }; const DEFAULT_PREFERENCES: AppPreferences = {
schemeMode: "auto",
accent: null,
locale: DEFAULT_LOCALE,
showDebugNav: false,
};
const DEFAULTS: StoredConfig = {
gamePath: null,
preferredRegion: "auto",
preferences: DEFAULT_PREFERENCES,
};
function normalizePreferences(raw: Partial<AppPreferences> | undefined): AppPreferences {
const schemeMode = raw?.schemeMode;
const locale = raw?.locale;
const accent = raw?.accent?.trim() || null;
return {
schemeMode: schemeMode === "light" || schemeMode === "dark" || schemeMode === "auto" ? schemeMode : "auto",
accent: accent && /^#[\da-f]{6}$/i.test(accent) ? accent : null,
locale: isAppLocale(locale) ? locale : DEFAULT_LOCALE,
showDebugNav: raw?.showDebugNav === true,
};
}
const configFile = jsonFile<StoredConfig>( const configFile = jsonFile<StoredConfig>(
path, path,
() => ({ ...DEFAULTS }), () => ({ ...DEFAULTS }),
(raw) => ({ ...DEFAULTS, ...(raw as Partial<StoredConfig>) }), (raw) => {
const stored = raw as Partial<StoredConfig>;
return {
...DEFAULTS,
...stored,
preferences: normalizePreferences(stored.preferences),
};
},
); );
const readStored = configFile.read; const readStored = configFile.read;
@@ -39,6 +69,16 @@ export function setPreferredRegion(region: PreferredRegion): AppConfig {
return withDerived(next); return withDerived(next);
} }
export function setPreferences(patch: Partial<AppPreferences>): AppConfig {
const stored = readStored();
const next: StoredConfig = {
...stored,
preferences: normalizePreferences({ ...stored.preferences, ...patch }),
};
writeStored(next);
return withDerived(next);
}
export function getConfig(): AppConfig { export function getConfig(): AppConfig {
return withDerived(readStored()); return withDerived(readStored());
} }
+1
View File
@@ -135,6 +135,7 @@ const handlers = {
if (p.region === "auto") void region.detectBestRegion(); if (p.region === "auto") void region.detectBestRegion();
return next; return next;
}), }),
"config:setPreferences": (p) => guard(async () => appConfig.setPreferences(p)),
"region:detect": () => guard(() => region.detectBestRegion()), "region:detect": () => guard(() => region.detectBestRegion()),
"region:ping": () => "region:ping": () =>
+16 -10
View File
@@ -33,6 +33,7 @@ import { LaunchButton } from "../features/game/LaunchButton";
import { NavProvider, useNav, type View } from "../features/navigation/NavContext"; import { NavProvider, useNav, type View } from "../features/navigation/NavContext";
import { useI18n } from "../lib/i18n"; import { useI18n } from "../lib/i18n";
import { api, events } from "../lib/api"; import { api, events } from "../lib/api";
import { useDebugNavVisible } from "../lib/debugSettings";
import "../styles/app-shell.css"; import "../styles/app-shell.css";
export function AppShell() { export function AppShell() {
@@ -58,6 +59,7 @@ function Shell() {
const { t } = useI18n(); const { t } = useI18n();
const [leftOpen, setLeftOpen] = useState(true); const [leftOpen, setLeftOpen] = useState(true);
const [friendsOpen, setFriendsOpen] = useState(true); const [friendsOpen, setFriendsOpen] = useState(true);
const showDebugNav = useDebugNavVisible();
useEffect( useEffect(
() => () =>
@@ -119,16 +121,20 @@ function Shell() {
divider: true, divider: true,
onClick: () => nav.openSettings(), onClick: () => nav.openSettings(),
}, },
{ ...(showDebugNav
id: "debug", ? [
label: t("nav:debug"), {
icon: Settings, id: "debug",
external: true, label: t("nav:debug"),
onClick: (e) => { icon: Settings,
if (e.ctrlKey || e.metaKey) void api.debug.cacheClear(); external: true,
else void api.debug.openWindow(); onClick: (e) => {
}, if (e.ctrlKey || e.metaKey) void api.debug.cacheClear();
}, else void api.debug.openWindow();
},
} satisfies NavItem,
]
: []),
]; ];
const openProfile = (id: "me" | string) => nav.openUser(id); const openProfile = (id: "me" | string) => nav.openUser(id);
@@ -3,6 +3,7 @@ import {
AlertTriangle, AlertTriangle,
Boxes, Boxes,
Check, Check,
Bug,
Download, Download,
Droplet, Droplet,
FolderOpen, FolderOpen,
@@ -26,6 +27,8 @@ import type { AppConfig, PreferredRegion, RegionPing } from "../../../../shared/
import type { UnityStatus } from "../../../../shared/types/unity"; import type { UnityStatus } from "../../../../shared/types/unity";
import { api, errorMessage } from "../../lib/api"; import { api, errorMessage } from "../../lib/api";
import { useAsync } from "../../lib/useAsync"; import { useAsync } from "../../lib/useAsync";
import { useSetDebugNavVisible, useDebugNavVisible } from "../../lib/debugSettings";
import { useAppConfig } from "../../lib/AppConfigContext";
import { regionFlag, regionLabel } from "../../lib/vrchat"; import { regionFlag, regionLabel } from "../../lib/vrchat";
import { import {
Button, Button,
@@ -34,12 +37,13 @@ import {
PAGE_TITLE, PAGE_TITLE,
SettingsSection as Section, SettingsSection as Section,
Tabs, Tabs,
Toggle,
} from "../../components/ui"; } from "../../components/ui";
const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10"; const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10";
const SECTIONS = "animate-rise flex flex-col gap-[18px]"; const SECTIONS = "animate-rise flex flex-col gap-[18px]";
type SettingsTab = "appearance" | "game" | "creator" | "about"; type SettingsTab = "appearance" | "game" | "creator" | "debug" | "about";
export function SettingsView() { export function SettingsView() {
const { t } = useI18n(); const { t } = useI18n();
@@ -56,6 +60,7 @@ export function SettingsView() {
{ id: "appearance", label: t("settings:tabs.appearance") }, { id: "appearance", label: t("settings:tabs.appearance") },
{ id: "game", label: t("settings:tabs.game") }, { id: "game", label: t("settings:tabs.game") },
{ id: "creator", label: t("settings:tabs.creator") }, { id: "creator", label: t("settings:tabs.creator") },
{ id: "debug", label: t("settings:tabs.debug") },
{ id: "about", label: t("settings:tabs.about") }, { id: "about", label: t("settings:tabs.about") },
]} ]}
active={tab} active={tab}
@@ -83,6 +88,12 @@ export function SettingsView() {
</div> </div>
) : null} ) : null}
{tab === "debug" ? (
<div className={SECTIONS}>
<DebugSection />
</div>
) : null}
{tab === "about" ? ( {tab === "about" ? (
<div className={SECTIONS}> <div className={SECTIONS}>
<AboutSection /> <AboutSection />
@@ -92,6 +103,27 @@ export function SettingsView() {
); );
} }
function DebugSection() {
const { t } = useI18n();
const showDebugNav = useDebugNavVisible();
const setDebugNavVisible = useSetDebugNavVisible();
return (
<Section
title={t("settings:debug.title")}
icon={<Bug size={16} />}
description={t("settings:debug.description")}
>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-surface-2 p-3">
<div>
<div className="text-[13px] font-semibold text-text">{t("settings:debug.sidebar")}</div>
<div className="mt-0.5 text-[12px] text-muted">{t("settings:debug.sidebarHint")}</div>
</div>
<Toggle checked={showDebugNav} onChange={setDebugNavVisible} />
</div>
</Section>
);
}
const SCHEME_OPTIONS: { mode: SchemeMode; labelKey: string; hintKey: string; icon: ReactNode }[] = [ const SCHEME_OPTIONS: { mode: SchemeMode; labelKey: string; hintKey: string; icon: ReactNode }[] = [
{ {
mode: "auto", mode: "auto",
@@ -259,18 +291,15 @@ function LanguageSection() {
function GameSection() { function GameSection() {
const { t } = useI18n(); const { t } = useI18n();
const [config, setConfig] = useState<AppConfig | null>(null); const { config, setGamePath, pickGamePath } = useAppConfig();
const [path, setPath] = useState(""); const [path, setPath] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null); const [ok, setOk] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
api.config.get().then((c) => { setPath(config?.gamePath ?? "");
setConfig(c); }, [config?.gamePath]);
setPath(c.gamePath ?? "");
});
}, []);
async function run(p: Promise<AppConfig>, okMsg: string) { async function run(p: Promise<AppConfig>, okMsg: string) {
setBusy(true); setBusy(true);
@@ -278,7 +307,6 @@ function GameSection() {
setOk(null); setOk(null);
try { try {
const next = await p; const next = await p;
setConfig(next);
setPath(next.gamePath ?? ""); setPath(next.gamePath ?? "");
setOk(okMsg); setOk(okMsg);
} catch (e) { } catch (e) {
@@ -307,7 +335,7 @@ function GameSection() {
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => void run(api.config.pickGamePath(), t("settings:game.saved"))} onClick={() => void run(pickGamePath(), t("settings:game.saved"))}
> >
<FolderOpen size={14} /> <FolderOpen size={14} />
{t("settings:game.browse")} {t("settings:game.browse")}
@@ -339,7 +367,7 @@ function GameSection() {
<div className="mt-3 flex flex-wrap items-center gap-2.5"> <div className="mt-3 flex flex-wrap items-center gap-2.5">
<Button <Button
onClick={() => onClick={() =>
void run(api.config.setGamePath(path.trim() || null), t("settings:game.saved")) void run(setGamePath(path.trim() || null), t("settings:game.saved"))
} }
loading={busy} loading={busy}
disabled={!dirty} disabled={!dirty}
@@ -349,7 +377,7 @@ function GameSection() {
{config?.gamePath ? ( {config?.gamePath ? (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => void run(api.config.setGamePath(null), t("settings:game.resetDone"))} onClick={() => void run(setGamePath(null), t("settings:game.resetDone"))}
disabled={busy} disabled={busy}
> >
{t("settings:game.reset")} {t("settings:game.reset")}
@@ -365,20 +393,16 @@ const REGION_OPTIONS: PreferredRegion[] = ["auto", "us", "use", "eu", "jp"];
function RegionSection() { function RegionSection() {
const { t } = useI18n(); const { t } = useI18n();
const [region, setRegion] = useState<PreferredRegion>("auto"); const { config, setPreferredRegion } = useAppConfig();
const [ok, setOk] = useState<string | null>(null); const [ok, setOk] = useState<string | null>(null);
const [pings, setPings] = useState<RegionPing[] | null>(null); const [pings, setPings] = useState<RegionPing[] | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
useEffect(() => { const region = config?.preferredRegion ?? "auto";
api.config.get().then((c) => setRegion(c.preferredRegion));
}, []);
const choose = async (r: PreferredRegion) => { const choose = async (r: PreferredRegion) => {
setRegion(r);
setOk(null); setOk(null);
const next = await api.config.setPreferredRegion(r); await setPreferredRegion(r);
setRegion(next.preferredRegion);
setOk(t("settings:region.saved")); setOk(t("settings:region.saved"));
}; };
+52
View File
@@ -0,0 +1,52 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import type { AppConfig, AppPreferences, PreferredRegion } from "../../../shared/types/appConfig";
import { api } from "./api";
interface AppConfigContextValue {
config: AppConfig | null;
setGamePath: (gamePath: string | null) => Promise<AppConfig>;
pickGamePath: () => Promise<AppConfig>;
setPreferredRegion: (region: PreferredRegion) => Promise<AppConfig>;
setPreferences: (patch: Partial<AppPreferences>) => Promise<AppConfig>;
}
const AppConfigContext = createContext<AppConfigContextValue | null>(null);
export function AppConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<AppConfig | null>(null);
useEffect(() => {
let cancelled = false;
void api.config.get().then((next) => {
if (!cancelled) setConfig(next);
});
return () => {
cancelled = true;
};
}, []);
const commit = useCallback(async (next: Promise<AppConfig>) => {
const config = await next;
setConfig(config);
return config;
}, []);
const value = useMemo<AppConfigContextValue>(
() => ({
config,
setGamePath: (gamePath) => commit(api.config.setGamePath(gamePath)),
pickGamePath: () => commit(api.config.pickGamePath()),
setPreferredRegion: (region) => commit(api.config.setPreferredRegion(region)),
setPreferences: (patch) => commit(api.config.setPreferences(patch)),
}),
[config, commit],
);
return <AppConfigContext.Provider value={value}>{children}</AppConfigContext.Provider>;
}
export function useAppConfig(): AppConfigContextValue {
const ctx = useContext(AppConfigContext);
if (!ctx) throw new Error("useAppConfig must be used within <AppConfigProvider>");
return ctx;
}
+31 -11
View File
@@ -4,6 +4,7 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@@ -12,13 +13,11 @@ import {
applyTheme, applyTheme,
builtInThemes, builtInThemes,
initialTheme, initialTheme,
persistScheme,
storedAccent,
storedScheme,
systemScheme, systemScheme,
type SchemeMode, type SchemeMode,
type Theme, type Theme,
} from "./theme"; } from "./theme";
import { useAppConfig } from "./AppConfigContext";
interface ThemeContextValue { interface ThemeContextValue {
theme: Theme; theme: Theme;
@@ -34,44 +33,64 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | null>(null); const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const { config, setPreferences } = useAppConfig();
const [themes, setThemes] = useState<Theme[]>(builtInThemes); const [themes, setThemes] = useState<Theme[]>(builtInThemes);
const [theme, setActive] = useState<Theme>(() => { const [theme, setActive] = useState<Theme>(() => {
const t = initialTheme(builtInThemes); const t = initialTheme(builtInThemes, "auto");
applyTheme(t); applyTheme(t);
applyAccent(null);
return t; return t;
}); });
const [schemeMode, setMode] = useState<SchemeMode>(() => storedScheme()); const [schemeMode, setMode] = useState<SchemeMode>("auto");
const [accent, setAccentState] = useState<string | null>(() => storedAccent()); const [accent, setAccentState] = useState<string | null>(null);
const lastApplied = useRef<string>("");
const setAccent = useCallback((hex: string | null) => { const setAccent = useCallback((hex: string | null) => {
applyAccent(hex); applyAccent(hex);
setAccentState(hex); setAccentState(hex);
}, []); void setPreferences({ accent: hex });
}, [setPreferences]);
const setTheme = useCallback( const setTheme = useCallback(
(id: string) => { (id: string) => {
const next = themes.find((t) => t.id === id); const next = themes.find((t) => t.id === id);
if (!next) return; if (!next) return;
applyTheme(next); applyTheme(next);
applyAccent(accent);
setActive(next); setActive(next);
}, },
[themes], [themes, accent],
); );
const setSchemeMode = useCallback( const setSchemeMode = useCallback(
(mode: SchemeMode) => { (mode: SchemeMode) => {
persistScheme(mode);
setMode(mode); setMode(mode);
const target = mode === "auto" ? systemScheme() : mode; const target = mode === "auto" ? systemScheme() : mode;
const next = themes.find((t) => t.scheme === target); const next = themes.find((t) => t.scheme === target);
if (next) { if (next) {
applyTheme(next); applyTheme(next);
applyAccent(accent);
setActive(next); setActive(next);
} }
void setPreferences({ schemeMode: mode });
}, },
[themes], [themes, accent, setPreferences],
); );
useEffect(() => {
const prefs = config?.preferences;
if (!prefs) return;
const key = `${prefs.schemeMode}:${prefs.accent ?? ""}`;
if (key === lastApplied.current) return;
lastApplied.current = key;
setMode(prefs.schemeMode);
setAccentState(prefs.accent);
const next = initialTheme(themes, prefs.schemeMode);
applyTheme(next);
applyAccent(prefs.accent);
setActive(next);
}, [config?.preferences, themes]);
useEffect(() => { useEffect(() => {
if (schemeMode !== "auto") return; if (schemeMode !== "auto") return;
const mq = window.matchMedia("(prefers-color-scheme: light)"); const mq = window.matchMedia("(prefers-color-scheme: light)");
@@ -79,12 +98,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const next = themes.find((t) => t.scheme === systemScheme()); const next = themes.find((t) => t.scheme === systemScheme());
if (next) { if (next) {
applyTheme(next); applyTheme(next);
applyAccent(accent);
setActive(next); setActive(next);
} }
}; };
mq.addEventListener("change", onChange); mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange); return () => mq.removeEventListener("change", onChange);
}, [schemeMode, themes]); }, [schemeMode, themes, accent]);
const registerTheme = useCallback((custom: Theme, activate = false) => { const registerTheme = useCallback((custom: Theme, activate = false) => {
setThemes((prev) => { setThemes((prev) => {
+2 -1
View File
@@ -4,7 +4,7 @@ import type { ContentFilterKey } from "../../../shared/types/settings";
import type { UserStatus } from "../../../shared/types/user"; import type { UserStatus } from "../../../shared/types/user";
import type { EnhancementId } from "../../../shared/types/enhancements"; import type { EnhancementId } from "../../../shared/types/enhancements";
import type { CreateInstanceInput } from "../../../shared/types/instance"; import type { CreateInstanceInput } from "../../../shared/types/instance";
import type { PreferredRegion } from "../../../shared/types/appConfig"; import type { AppPreferences, PreferredRegion } from "../../../shared/types/appConfig";
import type { AvatarEdit, FavoriteGroupEdit, MoveResult } from "../../../shared/types/avatar"; import type { AvatarEdit, FavoriteGroupEdit, MoveResult } from "../../../shared/types/avatar";
import type { import type {
FavoriteGroupEdit as WorldFavoriteGroupEdit, FavoriteGroupEdit as WorldFavoriteGroupEdit,
@@ -150,6 +150,7 @@ export const api = {
setGamePath: (gamePath: string | null) => call("config:setGamePath", { gamePath }), setGamePath: (gamePath: string | null) => call("config:setGamePath", { gamePath }),
pickGamePath: () => call("config:pickGamePath"), pickGamePath: () => call("config:pickGamePath"),
setPreferredRegion: (region: PreferredRegion) => call("config:setPreferredRegion", { region }), setPreferredRegion: (region: PreferredRegion) => call("config:setPreferredRegion", { region }),
setPreferences: (p: Partial<AppPreferences>) => call("config:setPreferences", p),
}, },
region: { region: {
detect: () => call("region:detect"), detect: () => call("region:detect"),
+16
View File
@@ -0,0 +1,16 @@
import { useCallback } from "react";
import { useAppConfig } from "./AppConfigContext";
export function useDebugNavVisible(): boolean {
return useAppConfig().config?.preferences.showDebugNav ?? false;
}
export function useSetDebugNavVisible(): (visible: boolean) => void {
const { setPreferences } = useAppConfig();
return useCallback(
(visible: boolean) => {
void setPreferences({ showDebugNav: visible });
},
[setPreferences],
);
}
+11 -4
View File
@@ -1,9 +1,15 @@
import { useCallback, type ReactNode } from "react"; import { useCallback, useEffect, type ReactNode } from "react";
import { I18nextProvider, useTranslation } from "react-i18next"; import { I18nextProvider, useTranslation } from "react-i18next";
import type { Locale, LocaleCode } from "./types"; import type { Locale, LocaleCode } from "./types";
import { availableLocales, getLocale, i18n, persistLocale } from "./registry"; import { availableLocales, getLocale, i18n } from "./registry";
import { useAppConfig } from "../AppConfigContext";
export function I18nProvider({ children }: { children: ReactNode }) { export function I18nProvider({ children }: { children: ReactNode }) {
const { config } = useAppConfig();
useEffect(() => {
const locale = config?.preferences.locale;
if (locale && i18n.language !== locale) void i18n.changeLanguage(locale);
}, [config?.preferences.locale]);
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>; return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
} }
@@ -16,15 +22,16 @@ interface I18nValue {
} }
export function useI18n(): I18nValue { export function useI18n(): I18nValue {
const { setPreferences } = useAppConfig();
const { t, i18n: instance } = useTranslation(); const { t, i18n: instance } = useTranslation();
const locale = instance.language as LocaleCode; const locale = instance.language as LocaleCode;
const setLocale = useCallback( const setLocale = useCallback(
(code: LocaleCode) => { (code: LocaleCode) => {
persistLocale(code);
void instance.changeLanguage(code); void instance.changeLanguage(code);
void setPreferences({ locale: code });
}, },
[instance], [instance, setPreferences],
); );
return { locale, current: getLocale(locale), locales: availableLocales(), setLocale, t }; return { locale, current: getLocale(locale), locales: availableLocales(), setLocale, t };
-2
View File
@@ -4,8 +4,6 @@ export {
NAMESPACES, NAMESPACES,
availableLocales, availableLocales,
getLocale, getLocale,
storedLocale,
persistLocale,
i18n, i18n,
} from "./registry"; } from "./registry";
export type { Locale, LocaleCode, LocaleMeta, Messages } from "./types"; export type { Locale, LocaleCode, LocaleMeta, Messages } from "./types";
@@ -5,6 +5,7 @@
"appearance": "Appearance", "appearance": "Appearance",
"game": "Game", "game": "Game",
"creator": "Creator", "creator": "Creator",
"debug": "Debug",
"about": "About" "about": "About"
}, },
"appearance": { "appearance": {
@@ -83,6 +84,12 @@
"version": "Version", "version": "Version",
"disclaimer": "Unofficial. Not affiliated with or endorsed by VRChat Inc." "disclaimer": "Unofficial. Not affiliated with or endorsed by VRChat Inc."
}, },
"debug": {
"title": "Debug",
"description": "Developer and troubleshooting options.",
"sidebar": "Show Debug in the sidebar",
"sidebarHint": "Adds the Debug shortcut back to the left navigation."
},
"region": { "region": {
"title": "Instance Region", "title": "Instance Region",
"description": "The region new instances you create are hosted in. Auto picks the lowest-latency region for your connection.", "description": "The region new instances you create are hosted in. Auto picks the lowest-latency region for your connection.",
@@ -5,6 +5,7 @@
"appearance": "外観", "appearance": "外観",
"game": "ゲーム", "game": "ゲーム",
"creator": "クリエイター", "creator": "クリエイター",
"debug": "デバッグ",
"about": "情報" "about": "情報"
}, },
"appearance": { "appearance": {
@@ -83,6 +84,12 @@
"version": "バージョン", "version": "バージョン",
"disclaimer": "非公式です。VRChat Inc. とは提携しておらず、承認も受けていません。" "disclaimer": "非公式です。VRChat Inc. とは提携しておらず、承認も受けていません。"
}, },
"debug": {
"title": "デバッグ",
"description": "開発者向けとトラブルシューティング用の設定です。",
"sidebar": "サイドバーにデバッグを表示",
"sidebarHint": "左のナビゲーションにデバッグのショートカットを表示します。"
},
"region": { "region": {
"title": "インスタンスのリージョン", "title": "インスタンスのリージョン",
"description": "作成する新しいインスタンスのホスト地域です。自動を選ぶと、接続に最も遅延の少ないリージョンが選ばれます。", "description": "作成する新しいインスタンスのホスト地域です。自動を選ぶと、接続に最も遅延の少ないリージョンが選ばれます。",
@@ -5,6 +5,7 @@
"appearance": "รูปลักษณ์", "appearance": "รูปลักษณ์",
"game": "เกม", "game": "เกม",
"creator": "ครีเอเตอร์", "creator": "ครีเอเตอร์",
"debug": "ดีบัก",
"about": "เกี่ยวกับ" "about": "เกี่ยวกับ"
}, },
"appearance": { "appearance": {
@@ -83,6 +84,12 @@
"version": "เวอร์ชัน", "version": "เวอร์ชัน",
"disclaimer": "ไม่เป็นทางการ ไม่ได้มีส่วนเกี่ยวข้องหรือได้รับการรับรองจาก VRChat Inc." "disclaimer": "ไม่เป็นทางการ ไม่ได้มีส่วนเกี่ยวข้องหรือได้รับการรับรองจาก VRChat Inc."
}, },
"debug": {
"title": "ดีบัก",
"description": "ตัวเลือกสำหรับนักพัฒนาและการแก้ปัญหา",
"sidebar": "แสดงดีบักในแถบด้านข้าง",
"sidebarHint": "เพิ่มทางลัดดีบักกลับไปที่เมนูนำทางด้านซ้าย"
},
"region": { "region": {
"title": "ภูมิภาคของอินสแตนซ์", "title": "ภูมิภาคของอินสแตนซ์",
"description": "ภูมิภาคที่ใช้โฮสต์อินสแตนซ์ใหม่ที่คุณสร้าง อัตโนมัติจะเลือกภูมิภาคที่มีความหน่วงต่ำที่สุดสำหรับการเชื่อมต่อของคุณ", "description": "ภูมิภาคที่ใช้โฮสต์อินสแตนซ์ใหม่ที่คุณสร้าง อัตโนมัติจะเลือกภูมิภาคที่มีความหน่วงต่ำที่สุดสำหรับการเชื่อมต่อของคุณ",
+6 -27
View File
@@ -1,16 +1,9 @@
import i18n, { type Resource, type ResourceLanguage } from "i18next"; import i18n, { type Resource, type ResourceLanguage } from "i18next";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import type { Locale, LocaleCode, LocaleMeta } from "./types"; import { DEFAULT_LOCALE, LOCALES } from "../../../../shared/locales";
import type { Locale, LocaleCode } from "./types";
export const DEFAULT_LOCALE: LocaleCode = "en"; export { DEFAULT_LOCALE } from "../../../../shared/locales";
const STORAGE_KEY = "vrc-circle.locale";
const META: Record<LocaleCode, LocaleMeta> = {
en: { code: "en", nativeName: "English", englishName: "English" },
ja: { code: "ja", nativeName: "日本語", englishName: "Japanese" },
th: { code: "th", nativeName: "ไทย", englishName: "Thai" },
};
const files = import.meta.glob<{ default: Record<string, unknown> }>("./locales/*/*.json", { const files = import.meta.glob<{ default: Record<string, unknown> }>("./locales/*/*.json", {
eager: true, eager: true,
@@ -29,11 +22,11 @@ export const NAMESPACES = [
]; ];
function buildLocale(code: LocaleCode): Locale { function buildLocale(code: LocaleCode): Locale {
return { meta: META[code], messages: resources[code] ?? {} }; return { meta: LOCALES.find((locale) => locale.code === code)!, messages: resources[code] ?? {} };
} }
const registry = new Map<LocaleCode, Locale>( const registry = new Map<LocaleCode, Locale>(
(Object.keys(META) as LocaleCode[]).map((code) => [code, buildLocale(code)]), LOCALES.map((locale) => [locale.code, buildLocale(locale.code)]),
); );
export function availableLocales(): Locale[] { export function availableLocales(): Locale[] {
@@ -44,22 +37,8 @@ export function getLocale(code: LocaleCode): Locale {
return registry.get(code) ?? registry.get(DEFAULT_LOCALE)!; return registry.get(code) ?? registry.get(DEFAULT_LOCALE)!;
} }
export function storedLocale(): LocaleCode {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v && registry.has(v as LocaleCode)) return v as LocaleCode;
} catch {}
return DEFAULT_LOCALE;
}
export function persistLocale(code: LocaleCode): void {
try {
localStorage.setItem(STORAGE_KEY, code);
} catch {}
}
void i18n.use(initReactI18next).init({ void i18n.use(initReactI18next).init({
lng: storedLocale(), lng: DEFAULT_LOCALE,
fallbackLng: DEFAULT_LOCALE, fallbackLng: DEFAULT_LOCALE,
ns: NAMESPACES, ns: NAMESPACES,
defaultNS: "common", defaultNS: "common",
+3 -1
View File
@@ -1,4 +1,6 @@
export type LocaleCode = "en" | "ja" | "th"; import type { AppLocale } from "../../../../shared/locales";
export type LocaleCode = AppLocale;
export interface LocaleMeta { export interface LocaleMeta {
code: LocaleCode; code: LocaleCode;
+1 -45
View File
@@ -88,10 +88,6 @@ export const lightTheme: Theme = {
export const builtInThemes: Theme[] = [darkTheme, lightTheme]; export const builtInThemes: Theme[] = [darkTheme, lightTheme];
const STORAGE_KEY = "vrc-circle.theme";
const ACCENT_KEY = "vrc-circle.accent";
const SCHEME_KEY = "vrc-circle.scheme";
export type SchemeMode = "light" | "dark" | "auto"; export type SchemeMode = "light" | "dark" | "auto";
export const ACCENT_PRESETS: { key: string; name: string; value: string }[] = [ export const ACCENT_PRESETS: { key: string; name: string; value: string }[] = [
@@ -114,10 +110,6 @@ export function applyTheme(theme: Theme): void {
} }
root.style.colorScheme = theme.scheme; root.style.colorScheme = theme.scheme;
root.dataset.theme = theme.id; root.dataset.theme = theme.id;
try {
localStorage.setItem(STORAGE_KEY, theme.id);
} catch {}
applyAccent(storedAccent());
} }
export function applyAccent(hex: string | null): void { export function applyAccent(hex: string | null): void {
@@ -126,18 +118,6 @@ export function applyAccent(hex: string | null): void {
root.style.setProperty("--accent", accent); root.style.setProperty("--accent", accent);
root.style.setProperty("--on-accent", readableOn(accent)); root.style.setProperty("--on-accent", readableOn(accent));
root.style.setProperty("--accent-weak", withAlpha(accent, 0.14)); root.style.setProperty("--accent-weak", withAlpha(accent, 0.14));
try {
if (hex) localStorage.setItem(ACCENT_KEY, hex);
else localStorage.removeItem(ACCENT_KEY);
} catch {}
}
export function storedAccent(): string | null {
try {
return localStorage.getItem(ACCENT_KEY);
} catch {
return null;
}
} }
function parseHex(hex: string): [number, number, number] | null { function parseHex(hex: string): [number, number, number] | null {
@@ -164,21 +144,11 @@ function readableOn(hex: string): string {
return luminance > 0.45 ? "#15151b" : "#ffffff"; return luminance > 0.45 ? "#15151b" : "#ffffff";
} }
export function initialTheme(registry: Theme[]): Theme { export function initialTheme(registry: Theme[], mode: SchemeMode): Theme {
const mode = storedScheme();
if (mode !== "auto") { if (mode !== "auto") {
const pick = registry.find((t) => t.scheme === mode); const pick = registry.find((t) => t.scheme === mode);
if (pick) return pick; if (pick) return pick;
} }
let storedId: string | null = null;
try {
storedId = localStorage.getItem(STORAGE_KEY);
} catch {}
if (mode !== "auto") {
const stored = registry.find((t) => t.id === storedId);
if (stored) return stored;
}
return systemScheme() === "light" ? lightTheme : darkTheme; return systemScheme() === "light" ? lightTheme : darkTheme;
} }
@@ -187,17 +157,3 @@ export function systemScheme(): "light" | "dark" {
typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: light)").matches; typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: light)").matches;
return prefersLight ? "light" : "dark"; return prefersLight ? "light" : "dark";
} }
export function storedScheme(): SchemeMode {
try {
const v = localStorage.getItem(SCHEME_KEY);
if (v === "light" || v === "dark" || v === "auto") return v;
} catch {}
return "auto";
}
export function persistScheme(mode: SchemeMode): void {
try {
localStorage.setItem(SCHEME_KEY, mode);
} catch {}
}
+14 -11
View File
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ThemeProvider } from "./lib/ThemeContext"; import { ThemeProvider } from "./lib/ThemeContext";
import { I18nProvider } from "./lib/i18n"; import { I18nProvider } from "./lib/i18n";
import { AppConfigProvider } from "./lib/AppConfigContext";
import { AuthProvider } from "./features/auth/AuthContext"; import { AuthProvider } from "./features/auth/AuthContext";
import { App } from "./App"; import { App } from "./App";
import { MAC_CONTENT_INSET } from "../../shared/window"; import { MAC_CONTENT_INSET } from "../../shared/window";
@@ -19,16 +20,18 @@ if (window.api.platform === "darwin") {
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <AppConfigProvider>
<I18nProvider> <ThemeProvider>
{isDebugWindow ? ( <I18nProvider>
<DebugWindow /> {isDebugWindow ? (
) : ( <DebugWindow />
<AuthProvider> ) : (
<App /> <AuthProvider>
</AuthProvider> <App />
)} </AuthProvider>
</I18nProvider> )}
</ThemeProvider> </I18nProvider>
</ThemeProvider>
</AppConfigProvider>
</StrictMode>, </StrictMode>,
); );
+2 -1
View File
@@ -29,7 +29,7 @@ import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from
import type { Group, GroupSnapshot } from "./types/group"; import type { Group, GroupSnapshot } from "./types/group";
import type { EnhancementId, EnhancementsSnapshot } from "./types/enhancements"; import type { EnhancementId, EnhancementsSnapshot } from "./types/enhancements";
import type { GallerySnapshot, Photo, ThumbCacheStats } from "./types/gallery"; import type { GallerySnapshot, Photo, ThumbCacheStats } from "./types/gallery";
import type { AppConfig, PreferredRegion, RegionPing } from "./types/appConfig"; import type { AppConfig, AppPreferences, PreferredRegion, RegionPing } from "./types/appConfig";
import type { GameStatus } from "./types/game"; import type { GameStatus } from "./types/game";
import type { IpcResult } from "./types/result"; import type { IpcResult } from "./types/result";
import type { CacheEntryInfo, CacheStats, DebugSnapshot, LogEntry, WsEvent } from "./types/debug"; import type { CacheEntryInfo, CacheStats, DebugSnapshot, LogEntry, WsEvent } from "./types/debug";
@@ -150,6 +150,7 @@ export interface IpcRequests {
"config:setGamePath": (p: { gamePath: string | null }) => IpcResult<AppConfig>; "config:setGamePath": (p: { gamePath: string | null }) => IpcResult<AppConfig>;
"config:pickGamePath": () => IpcResult<AppConfig>; "config:pickGamePath": () => IpcResult<AppConfig>;
"config:setPreferredRegion": (p: { region: PreferredRegion }) => IpcResult<AppConfig>; "config:setPreferredRegion": (p: { region: PreferredRegion }) => IpcResult<AppConfig>;
"config:setPreferences": (p: Partial<AppPreferences>) => IpcResult<AppConfig>;
"region:detect": () => IpcResult<InstanceRegion>; "region:detect": () => IpcResult<InstanceRegion>;
"region:ping": () => IpcResult<RegionPing[]>; "region:ping": () => IpcResult<RegionPing[]>;
+13
View File
@@ -0,0 +1,13 @@
export const DEFAULT_LOCALE = "en" as const;
export const LOCALES = [
{ code: "en", nativeName: "English", englishName: "English" },
{ code: "ja", nativeName: "日本語", englishName: "Japanese" },
{ code: "th", nativeName: "ไทย", englishName: "Thai" },
] as const;
export type AppLocale = (typeof LOCALES)[number]["code"];
export function isAppLocale(value: unknown): value is AppLocale {
return typeof value === "string" && LOCALES.some((locale) => locale.code === value);
}
+10
View File
@@ -1,12 +1,22 @@
import type { InstanceRegion } from "./instance"; import type { InstanceRegion } from "./instance";
import type { AppLocale } from "../locales";
export type PreferredRegion = InstanceRegion | "auto"; export type PreferredRegion = InstanceRegion | "auto";
export type AppSchemeMode = "light" | "dark" | "auto";
export interface AppPreferences {
schemeMode: AppSchemeMode;
accent: string | null;
locale: AppLocale;
showDebugNav: boolean;
}
export interface AppConfig { export interface AppConfig {
version: string; version: string;
gamePath: string | null; gamePath: string | null;
detectedGamePath: string | null; detectedGamePath: string | null;
preferredRegion: PreferredRegion; preferredRegion: PreferredRegion;
preferences: AppPreferences;
} }
export interface RegionPing { export interface RegionPing {