mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-14 03:10:01 +00:00
✨ feat: Unity creator settings
This commit is contained in:
Vendored
+4
@@ -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}`,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user