diff --git a/src/main/cache/policies.ts b/src/main/cache/policies.ts index 2bb99dd..631446b 100644 --- a/src/main/cache/policies.ts +++ b/src/main/cache/policies.ts @@ -15,6 +15,8 @@ export const policies = { representedGroup: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, group: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 }, instance: { ttl: 30_000, staleWhileRevalidate: 60_000 }, + apiConfig: { ttl: 6 * 60 * 60_000, staleWhileRevalidate: 24 * 60 * 60_000 }, + unityChangeset: { ttl: 30 * 24 * 60 * 60_000 }, } satisfies Record; export const cacheKeys = { @@ -33,4 +35,6 @@ export const cacheKeys = { representedGroup: (id: string) => `user:group:represented:${id}`, group: (id: string) => `group:${id}`, instance: (location: string) => `instance:${location}`, + apiConfig: () => "api:config", + unityChangeset: (version: string) => `unity:changeset:${version}`, }; diff --git a/src/main/config/appConfig.ts b/src/main/config/appConfig.ts index 940212a..f7c637c 100644 --- a/src/main/config/appConfig.ts +++ b/src/main/config/appConfig.ts @@ -3,10 +3,11 @@ import { join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; import { writeFileAtomicSync } from "../lib/atomicFile"; import type { AppConfig } from "../../shared/types/appConfig"; +import { detectedGamePath } from "../game/steam"; const path = () => join(app.getPath("userData"), "app-config.json"); -type StoredConfig = Omit; +type StoredConfig = Omit; const DEFAULTS: StoredConfig = { gamePath: null }; function readStored(): StoredConfig { @@ -17,8 +18,16 @@ function readStored(): StoredConfig { } } +function withDerived(stored: StoredConfig): AppConfig { + return { version: app.getVersion(), detectedGamePath: detectedGamePath(), ...stored }; +} + +export function gamePathOverride(): string | null { + return readStored().gamePath; +} + export function getConfig(): AppConfig { - return { version: app.getVersion(), ...readStored() }; + return withDerived(readStored()); } export function setGamePath(gamePath: string | null): AppConfig { @@ -28,5 +37,5 @@ export function setGamePath(gamePath: string | null): AppConfig { } const next: StoredConfig = { ...readStored(), gamePath: trimmed }; writeFileAtomicSync(path(), JSON.stringify(next, null, 2)); - return { version: app.getVersion(), ...next }; + return withDerived(next); } diff --git a/src/main/game/steam.ts b/src/main/game/steam.ts index d503a9a..c4073e3 100644 --- a/src/main/game/steam.ts +++ b/src/main/game/steam.ts @@ -1,7 +1,7 @@ import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; -import { getConfig } from "../config/appConfig"; +import { gamePathOverride } from "../config/appConfig"; export const VRCHAT_APPID = "438100"; @@ -29,7 +29,7 @@ export function steamLibraries(): string[] { const bases = platformBases(); const libs = new Set(); - const override = getConfig().gamePath; + const override = gamePathOverride(); if (override) { if (existsSync(join(override, "steamapps"))) libs.add(join(override, "steamapps")); if (existsSync(join(override, "compatdata"))) libs.add(override); @@ -60,3 +60,11 @@ export function vrchatPrefix(): string | null { } return null; } + +export function detectedGamePath(): string | null { + for (const lib of steamLibraries()) { + const manifest = join(lib, `appmanifest_${VRCHAT_APPID}.acf`); + if (existsSync(manifest)) return dirname(lib); + } + return null; +} diff --git a/src/main/game/unity.ts b/src/main/game/unity.ts new file mode 100644 index 0000000..d72ebe1 --- /dev/null +++ b/src/main/game/unity.ts @@ -0,0 +1,95 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; + +function hubConfigDir(): string { + const home = homedir(); + switch (process.platform) { + case "win32": + return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "UnityHub"); + case "darwin": + return join(home, "Library", "Application Support", "UnityHub"); + default: + return join(home, ".config", "UnityHub"); + } +} + +function hubBinaries(): string[] { + const home = homedir(); + switch (process.platform) { + case "win32": + return [ + join(process.env.ProgramFiles ?? "C:\\Program Files", "Unity Hub", "Unity Hub.exe"), + join( + process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", + "Unity Hub", + "Unity Hub.exe", + ), + ]; + case "darwin": + return ["/Applications/Unity Hub.app"]; + default: + return ["/usr/bin/unityhub", "/opt/unityhub/unityhub", join(home, ".local", "bin", "unityhub")]; + } +} + +export function hubInstalled(): boolean { + return hubBinaries().some(existsSync); +} + +function defaultEditorRoot(): string { + const home = homedir(); + switch (process.platform) { + case "win32": + return join(process.env.ProgramFiles ?? "C:\\Program Files", "Unity", "Hub", "Editor"); + case "darwin": + return "/Applications/Unity/Hub/Editor"; + default: + return join(home, "Unity", "Hub", "Editor"); + } +} + +function customEditorRoot(): string | null { + const file = join(hubConfigDir(), "secondaryInstallPath.json"); + try { + const raw = JSON.parse(readFileSync(file, "utf8")) as unknown; + const path = typeof raw === "string" ? raw : null; + return path && path.trim() ? path.trim() : null; + } catch { + return null; + } +} + +export function editorRoot(): string | null { + for (const root of [customEditorRoot(), defaultEditorRoot()]) { + if (root && existsSync(root)) return root; + } + return null; +} + +function editorBinaryName(): string { + switch (process.platform) { + case "win32": + return join("Editor", "Unity.exe"); + case "darwin": + return join("Unity.app"); + default: + return join("Editor", "Unity"); + } +} + +export function installedVersions(): string[] { + const root = editorRoot(); + if (!root) return []; + const binary = editorBinaryName(); + const out: string[] = []; + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (existsSync(join(root, entry.name, binary))) out.push(entry.name); + } + } catch { + return []; + } + return out.sort(); +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 430bedc..19238ec 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -6,6 +6,7 @@ import * as users from "../vrchat/userService"; import * as friends from "../vrchat/friendsService"; import * as worlds from "../vrchat/worldService"; import * as instances from "../vrchat/instanceService"; +import * as unity from "../vrchat/unityService"; import * as avatars from "../vrchat/avatarService"; import * as groups from "../vrchat/groupService"; import * as settings from "../vrchat/settingsService"; @@ -90,6 +91,13 @@ const handlers = { return appConfig.setGamePath(res.filePaths[0]); }), + "unity:status": () => guard(() => unity.unityStatus()), + "unity:install": (url) => + guard(async () => { + if (!url.startsWith("unityhub://")) throw new Error("Invalid Unity Hub link"); + await shell.openExternal(url); + }), + "game:status": () => guard(() => game.status()), "game:launch": () => guard(() => game.launch()), diff --git a/src/main/vrchat/unityService.ts b/src/main/vrchat/unityService.ts new file mode 100644 index 0000000..d148a79 --- /dev/null +++ b/src/main/vrchat/unityService.ts @@ -0,0 +1,59 @@ +import type { UnityStatus } from "../../shared/types/unity"; +import { cachedRead } from "./cachedRead"; +import { cacheKeys, policies } from "../cache/policies"; +import { getActiveClient } from "./client"; +import { userCache } from "./userService"; +import { hubInstalled, editorRoot, installedVersions } from "../game/unity"; + +async function requiredUnityVersion(): Promise { + if (!getActiveClient()) return null; + try { + return await cachedRead(cacheKeys.apiConfig(), policies.apiConfig, async (vrc) => { + const { data } = await vrc.getConfig({ throwOnError: true }); + return data.sdkUnityVersion ?? null; + }); + } catch { + return null; + } +} + +async function installUrl(version: string): Promise { + try { + return await userCache.get( + cacheKeys.unityChangeset(version), + policies.unityChangeset, + async () => { + const res = await fetch( + `https://services.api.unity.com/unity/editor/release/v1/releases?version=${version}`, + ); + if (!res.ok) return null; + const body = (await res.json()) as { results?: { unityHubDeepLink?: string }[] }; + return body.results?.[0]?.unityHubDeepLink ?? null; + }, + ); + } catch { + return null; + } +} + +export async function unityStatus(): Promise { + const installed = installedVersions(); + const required = await requiredUnityVersion(); + + let match: UnityStatus["match"]; + if (!required) match = "unknown"; + else if (installed.length === 0) match = "no-editor"; + else if (installed.includes(required)) match = "ok"; + else match = "missing"; + + const url = required && match !== "ok" ? await installUrl(required) : null; + + return { + hubInstalled: hubInstalled(), + editorRoot: editorRoot(), + installedVersions: installed, + requiredVersion: required, + installUrl: url, + match, + }; +} diff --git a/src/renderer/src/features/settings/SettingsView.tsx b/src/renderer/src/features/settings/SettingsView.tsx index 3d2be63..d41fbc1 100644 --- a/src/renderer/src/features/settings/SettingsView.tsx +++ b/src/renderer/src/features/settings/SettingsView.tsx @@ -1,6 +1,9 @@ import { useEffect, useState, type ReactNode } from "react"; import { + AlertTriangle, + Boxes, Check, + Download, Droplet, FolderOpen, Gamepad2, @@ -9,19 +12,23 @@ import { Monitor, Moon, Palette, + Search, Sun, + X, } from "lucide-react"; import { useTheme } from "../../lib/ThemeContext"; import { useI18n } from "../../lib/i18n"; import { ACCENT_PRESETS, DEFAULT_ACCENT, type SchemeMode } from "../../lib/theme"; import type { AppConfig } from "../../../../shared/types/appConfig"; +import type { UnityStatus } from "../../../../shared/types/unity"; import { api, errorMessage } from "../../lib/api"; +import { useAsync } from "../../lib/useAsync"; import { Button, Field, Tabs } from "../../components/ui"; 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]"; -type SettingsTab = "appearance" | "game" | "about"; +type SettingsTab = "appearance" | "game" | "creator" | "about"; export function SettingsView() { const { t } = useI18n(); @@ -37,6 +44,7 @@ export function SettingsView() { tabs={[ { id: "appearance", label: t("settings:tabs.appearance") }, { id: "game", label: t("settings:tabs.game") }, + { id: "creator", label: t("settings:tabs.creator") }, { id: "about", label: t("settings:tabs.about") }, ]} active={tab} @@ -57,6 +65,12 @@ export function SettingsView() { ) : null} + {tab === "creator" ? ( +
+ +
+ ) : null} + {tab === "about" ? (
@@ -312,6 +326,29 @@ function GameSection() { {t("settings:game.browse")}
+ +
+ + {t("settings:game.detected")}: + {config?.detectedGamePath ? ( + <> + + {config.detectedGamePath} + + {config.detectedGamePath !== path.trim() ? ( + + ) : null} + + ) : ( + {t("settings:game.detectedNone")} + )} +
+
+ ) : null} + + ); +} + +const MATCH_META: Record< + UnityStatus["match"], + { key: string; icon: typeof Check; color: string } +> = { + ok: { key: "settings:unity.match.ok", icon: Check, color: "var(--status-active)" }, + missing: { key: "settings:unity.match.missing", icon: AlertTriangle, color: "var(--status-ask)" }, + "no-editor": { key: "settings:unity.match.noEditor", icon: X, color: "var(--danger)" }, + unknown: { key: "settings:unity.match.unknown", icon: AlertTriangle, color: "var(--muted)" }, +}; + function AboutSection() { const { t } = useI18n(); const [version, setVersion] = useState(""); diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index 2cb85ff..bb36dcf 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -100,6 +100,10 @@ export const api = { setGamePath: (gamePath: string | null) => call("config:setGamePath", { gamePath }), pickGamePath: () => call("config:pickGamePath"), }, + unity: { + status: () => call("unity:status"), + install: (url: string) => call("unity:install", url), + }, game: { status: () => call("game:status"), launch: () => call("game:launch"), diff --git a/src/renderer/src/lib/i18n/locales/en/settings.json b/src/renderer/src/lib/i18n/locales/en/settings.json index 7e17c85..3b4b6b4 100644 --- a/src/renderer/src/lib/i18n/locales/en/settings.json +++ b/src/renderer/src/lib/i18n/locales/en/settings.json @@ -4,6 +4,7 @@ "tabs": { "appearance": "Appearance", "game": "Game", + "creator": "Creator", "about": "About" }, "appearance": { @@ -41,16 +42,38 @@ "description": "The language used across the app." }, "game": { - "title": "Game Install Path", - "description": "Where Steam or VRChat is installed. Leave empty to auto-detect. Set this if an enhancement can't find your install.", - "label": "Install folder", + "title": "Steam Path", + "description": "Your Steam library folder that contains VRChat. Leave empty to auto-detect. Set this if an enhancement can't find your install.", + "label": "Steam folder", "placeholder": "Auto-detect", "browse": "Browse", "save": "Save", "reset": "Reset to auto-detect", "saved": "Path saved.", "resetDone": "Reset to auto-detect.", - "error": "Something went wrong." + "error": "Something went wrong.", + "detected": "Auto-detected", + "detectedNone": "No Steam library with VRChat found automatically.", + "useDetected": "Use this" + }, + "unity": { + "title": "Unity", + "description": "The Unity Editor version VRChat uses, and whether you have it installed via Unity Hub.", + "loading": "Checking your Unity install…", + "error": "Couldn't check your Unity install.", + "hub": "Unity Hub", + "installed": "Installed", + "notFound": "Not found", + "required": "VRChat's Unity version", + "installedVersions": "Installed editors", + "status": "Status", + "install": "Install {{version}} in Unity Hub", + "match": { + "ok": "Match", + "missing": "Version not installed", + "noEditor": "No editor installed", + "unknown": "Sign in to check" + } }, "about": { "title": "About", diff --git a/src/renderer/src/lib/i18n/locales/ja/settings.json b/src/renderer/src/lib/i18n/locales/ja/settings.json index c7b75ec..b1a2f5d 100644 --- a/src/renderer/src/lib/i18n/locales/ja/settings.json +++ b/src/renderer/src/lib/i18n/locales/ja/settings.json @@ -4,6 +4,7 @@ "tabs": { "appearance": "外観", "game": "ゲーム", + "creator": "クリエイター", "about": "情報" }, "appearance": { @@ -41,16 +42,38 @@ "description": "アプリ全体で使用される言語。" }, "game": { - "title": "ゲームのインストール先", - "description": "Steam または VRChat のインストール先です。空欄なら自動検出します。拡張機能がインストール先を見つけられないときに設定してください。", - "label": "インストールフォルダ", + "title": "Steam のパス", + "description": "VRChat が入っている Steam ライブラリフォルダです。空欄なら自動検出します。拡張機能がインストール先を見つけられないときに設定してください。", + "label": "Steam フォルダ", "placeholder": "自動検出", "browse": "参照", "save": "保存", "reset": "自動検出に戻す", "saved": "パスを保存しました。", "resetDone": "自動検出に戻しました。", - "error": "問題が発生しました。" + "error": "問題が発生しました。", + "detected": "自動検出", + "detectedNone": "VRChat を含む Steam ライブラリを自動で見つけられませんでした。", + "useDetected": "これを使う" + }, + "unity": { + "title": "Unity", + "description": "VRChat が使用している Unity エディターのバージョンと、Unity Hub でインストール済みかどうかを表示します。", + "loading": "Unity のインストールを確認中…", + "error": "Unity のインストールを確認できませんでした。", + "hub": "Unity Hub", + "installed": "インストール済み", + "notFound": "見つかりません", + "required": "VRChat の Unity バージョン", + "installedVersions": "インストール済みエディター", + "status": "状態", + "install": "Unity Hub で {{version}} をインストール", + "match": { + "ok": "一致", + "missing": "そのバージョンが未インストール", + "noEditor": "エディター未インストール", + "unknown": "確認するにはサインイン" + } }, "about": { "title": "情報", diff --git a/src/renderer/src/lib/i18n/locales/th/settings.json b/src/renderer/src/lib/i18n/locales/th/settings.json index c09da23..edbd45d 100644 --- a/src/renderer/src/lib/i18n/locales/th/settings.json +++ b/src/renderer/src/lib/i18n/locales/th/settings.json @@ -4,6 +4,7 @@ "tabs": { "appearance": "รูปลักษณ์", "game": "เกม", + "creator": "ครีเอเตอร์", "about": "เกี่ยวกับ" }, "appearance": { @@ -41,16 +42,38 @@ "description": "ภาษาที่ใช้ทั่วทั้งแอป" }, "game": { - "title": "ตำแหน่งติดตั้งเกม", - "description": "ตำแหน่งที่ติดตั้ง Steam หรือ VRChat เว้นว่างไว้เพื่อตรวจหาอัตโนมัติ ตั้งค่านี้หากส่วนเสริมหาตำแหน่งติดตั้งไม่พบ", - "label": "โฟลเดอร์ติดตั้ง", + "title": "ตำแหน่ง Steam", + "description": "โฟลเดอร์ไลบรารี Steam ที่มี VRChat อยู่ เว้นว่างไว้เพื่อตรวจหาอัตโนมัติ ตั้งค่านี้หากส่วนเสริมหาตำแหน่งติดตั้งไม่พบ", + "label": "โฟลเดอร์ Steam", "placeholder": "ตรวจหาอัตโนมัติ", "browse": "เรียกดู", "save": "บันทึก", "reset": "รีเซ็ตเป็นตรวจหาอัตโนมัติ", "saved": "บันทึกตำแหน่งแล้ว", "resetDone": "รีเซ็ตเป็นตรวจหาอัตโนมัติแล้ว", - "error": "เกิดข้อผิดพลาด" + "error": "เกิดข้อผิดพลาด", + "detected": "ตรวจพบอัตโนมัติ", + "detectedNone": "ไม่พบไลบรารี Steam ที่มี VRChat โดยอัตโนมัติ", + "useDetected": "ใช้ตำแหน่งนี้" + }, + "unity": { + "title": "Unity", + "description": "เวอร์ชัน Unity Editor ที่ VRChat ใช้ และคุณติดตั้งไว้ผ่าน Unity Hub หรือไม่", + "loading": "กำลังตรวจสอบการติดตั้ง Unity…", + "error": "ตรวจสอบการติดตั้ง Unity ไม่ได้", + "hub": "Unity Hub", + "installed": "ติดตั้งแล้ว", + "notFound": "ไม่พบ", + "required": "เวอร์ชัน Unity ของ VRChat", + "installedVersions": "เอดิเตอร์ที่ติดตั้ง", + "status": "สถานะ", + "install": "ติดตั้ง {{version}} ใน Unity Hub", + "match": { + "ok": "ตรงกัน", + "missing": "ยังไม่ได้ติดตั้งเวอร์ชันนี้", + "noEditor": "ยังไม่ได้ติดตั้งเอดิเตอร์", + "unknown": "ลงชื่อเข้าใช้เพื่อตรวจสอบ" + } }, "about": { "title": "เกี่ยวกับ", diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 6227565..a4fb3b3 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -8,6 +8,7 @@ import type { import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user"; import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world"; import type { Instance } from "./types/instance"; +import type { UnityStatus } from "./types/unity"; import type { Avatar } from "./types/avatar"; import type { RepoStats, StoredEntity } from "./types/repository"; import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings"; @@ -91,6 +92,9 @@ export interface IpcRequests { "config:setGamePath": (p: { gamePath: string | null }) => IpcResult; "config:pickGamePath": () => IpcResult; + "unity:status": () => IpcResult; + "unity:install": (url: string) => IpcResult; + "game:status": () => IpcResult; "game:launch": () => IpcResult; diff --git a/src/shared/types/appConfig.ts b/src/shared/types/appConfig.ts index 45c7402..d89d544 100644 --- a/src/shared/types/appConfig.ts +++ b/src/shared/types/appConfig.ts @@ -1,4 +1,5 @@ export interface AppConfig { version: string; gamePath: string | null; + detectedGamePath: string | null; } diff --git a/src/shared/types/unity.ts b/src/shared/types/unity.ts new file mode 100644 index 0000000..2a9f05a --- /dev/null +++ b/src/shared/types/unity.ts @@ -0,0 +1,8 @@ +export interface UnityStatus { + hubInstalled: boolean; + editorRoot: string | null; + installedVersions: string[]; + requiredVersion: string | null; + installUrl: string | null; + match: "ok" | "missing" | "no-editor" | "unknown"; +}