feat: Unity creator settings

This commit is contained in:
2026-06-28 16:48:27 +07:00
parent 387fa18927
commit 27389914c6
14 changed files with 392 additions and 19 deletions
+4
View File
@@ -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<string, CachePolicy>;
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}`,
};
+12 -3
View File
@@ -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<AppConfig, "version">;
type StoredConfig = Omit<AppConfig, "version" | "detectedGamePath">;
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);
}
+11 -3
View File
@@ -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<string>();
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;
}
+95
View File
@@ -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();
}
+8
View File
@@ -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()),
+59
View File
@@ -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<string | null> {
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<string | null> {
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<UnityStatus> {
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,
};
}