init: lolskin 第一期骨架(全球服/cslol:core+updater+Electron UI+LCU)

This commit is contained in:
fenghp
2026-10-02 00:03:35 +08:00
commit 8d78d83a3a
47 changed files with 3085 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import * as fs from "node:fs";
import * as path from "node:path";
/** 确保父目录存在。 */
export function ensureDirFor(file: string): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
}
/**
* 原子写文件:写临时文件 -> fsync -> rename 覆盖。
* 适用于 200MB 级 hashes 覆盖,避免留下半套。
*/
export function atomicWriteFile(dest: string, data: Buffer | string): void {
ensureDirFor(dest);
const tmp = `${dest}.part`;
const fd = fs.openSync(tmp, "w");
try {
fs.writeFileSync(fd, data);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, dest);
}
/** 用流把 srcPath 原子替换到 dest(先复制为 .part 再 rename)。 */
export function atomicReplaceFrom(srcPath: string, dest: string): void {
ensureDirFor(dest);
const tmp = `${dest}.part`;
fs.copyFileSync(srcPath, tmp);
fs.renameSync(tmp, dest);
}
export function fileExists(p: string): boolean {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
export function readJsonSafe<T>(p: string, fallback: T): T {
try {
return JSON.parse(fs.readFileSync(p, "utf8")) as T;
} catch {
return fallback;
}
}
+34
View File
@@ -0,0 +1,34 @@
import * as path from "node:path";
import { SKINS_LIB_DIR } from "./paths";
/**
* ID 对齐(命门一):
* LCU skin.id = championId * 1000 + num
* LeagueSkins 目录 = skins/{heroId}/{skinId}/{skinId}.fantome
* 两者 heroId == championId,直接对应。
*/
export interface SkinIdParts {
heroId: number;
num: number;
skinId: number;
}
export function makeSkinId(heroId: number, num: number): number {
return heroId * 1000 + num;
}
export function parseSkinId(skinId: number): SkinIdParts {
const heroId = Math.floor(skinId / 1000);
const num = skinId % 1000;
return { heroId, num, skinId };
}
/** 校验 heroId/skinId 是否互相自洽。 */
export function isConsistent(heroId: number, skinId: number): boolean {
return Math.floor(skinId / 1000) === heroId;
}
/** 本地皮肤包落盘路径:runtime/skins_lib/{heroId}/{skinId}.fantome */
export function skinLibPath(heroId: number, skinId: number): string {
return path.join(SKINS_LIB_DIR, String(heroId), `${skinId}.fantome`);
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./paths";
export * from "./logger";
export * from "./atomic";
export * from "./lock";
export * from "./manifest";
export * from "./ids";
export * from "./sources";
+45
View File
@@ -0,0 +1,45 @@
import * as fs from "node:fs";
import { LOCK_PATH, ensureRuntimeDirs } from "./paths";
export interface LockInfo {
pid: number;
startedAt: number;
}
export interface LockHandle {
path: string;
info: LockInfo;
release(): void;
}
/** 尝试获取单实例锁(exclusive create)。拿不到返回 null。 */
export function acquireLock(): LockHandle | null {
ensureRuntimeDirs();
try {
const fd = fs.openSync(LOCK_PATH, "wx");
const info: LockInfo = { pid: process.pid, startedAt: Date.now() };
fs.writeFileSync(fd, JSON.stringify(info));
fs.closeSync(fd);
return {
path: LOCK_PATH,
info,
release() {
try {
fs.unlinkSync(LOCK_PATH);
} catch {
/* ignore */
}
},
};
} catch {
return null;
}
}
export function readLock(): LockInfo | null {
try {
return JSON.parse(fs.readFileSync(LOCK_PATH, "utf8")) as LockInfo;
} catch {
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
import * as fs from "node:fs";
import { LOG_PATH, ensureRuntimeDirs } from "./paths";
function stamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return (
`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
);
}
function line(level: string, msg: string): string {
return `[${stamp()}] [${level}] ${msg}\n`;
}
export function logInfo(msg: string): void {
process.stdout.write(line("INFO", msg));
}
export function logWarn(msg: string): void {
process.stdout.write(line("WARN", msg));
}
export function logError(msg: string): void {
process.stderr.write(line("ERROR", msg));
}
/** 追加一行到 runtime/state/updater.log(失败不抛出)。 */
export function appendLog(msg: string): void {
appendFile(LOG_PATH, msg);
}
/** 追加一行到任意日志文件(失败不抛出)。 */
export function appendFile(file: string, msg: string): void {
try {
ensureRuntimeDirs();
fs.appendFileSync(file, `[${stamp()}] ${msg}\n`, "utf8");
} catch {
/* ignore */
}
}
+83
View File
@@ -0,0 +1,83 @@
import { atomicWriteFile, readJsonSafe } from "./atomic";
import { MANIFEST_PATH } from "./paths";
export type ResourceStatus =
| "ok"
| "outdated"
| "missing"
| "unknown"
| "partial";
export interface HashResource {
sourceCommit: string | null;
files: Record<string, { bytes: number; sha256: string }>;
status: ResourceStatus;
}
export interface SkinEntry {
bytes: number;
sha256: string;
fetchedAt: string;
}
export interface ToolchainResource {
present: boolean;
injectorSha256?: string;
autoUpdate: boolean;
}
export interface ProfileEntry {
lolPath: string;
resourcesReady: boolean;
}
export interface Manifest {
schema: 1;
updaterVersion: string;
updatedAt: string;
resources: {
hashes?: HashResource;
skins: Record<string, SkinEntry>;
toolchain: ToolchainResource;
};
profiles: Record<string, ProfileEntry>;
lastCheck: {
at: string;
network: "ok" | "offline" | "unknown";
note: string | null;
} | null;
}
export const UPDATER_VERSION = "0.1.0";
export function defaultManifest(): Manifest {
return {
schema: 1,
updaterVersion: UPDATER_VERSION,
updatedAt: new Date().toISOString(),
resources: {
skins: {},
toolchain: { present: false, autoUpdate: false },
},
profiles: {},
lastCheck: null,
};
}
export function readManifest(): Manifest {
return readJsonSafe<Manifest>(MANIFEST_PATH, defaultManifest());
}
/** 原子写清单。updater 是唯一写者。 */
export function writeManifest(m: Manifest): void {
m.updatedAt = new Date().toISOString();
atomicWriteFile(MANIFEST_PATH, JSON.stringify(m, null, 2));
}
/** 读-改-写。 */
export function updateManifest(mutate: (m: Manifest) => void): Manifest {
const m = readManifest();
mutate(m);
writeManifest(m);
return m;
}
+46
View File
@@ -0,0 +1,46 @@
import * as path from "node:path";
import * as fs from "node:fs";
/** 项目根目录(lolskin/)。dist 位于 packages/core/dist,向上 3 级即根。 */
export const ROOT = path.resolve(__dirname, "..", "..", "..");
export const TOOLS_DIR = path.join(ROOT, "tools", "cslol-tools");
export const MOD_TOOLS_EXE = path.join(TOOLS_DIR, "mod-tools.exe");
export const CSLOL_DLL = path.join(TOOLS_DIR, "cslol-dll.dll");
export const WAD_EXTRACT_EXE = path.join(TOOLS_DIR, "wad-extract.exe");
export const WAD_MAKE_EXE = path.join(TOOLS_DIR, "wad-make.exe");
/** 全部本地状态(git 忽略)。更新程序是唯一写者,主程序只读。 */
export const RUNTIME_DIR = process.env.LOLSKIN_RUNTIME
? path.resolve(process.env.LOLSKIN_RUNTIME)
: path.join(ROOT, "runtime");
export const HASH_DIR = path.join(RUNTIME_DIR, "hash");
export const SKINS_LIB_DIR = path.join(RUNTIME_DIR, "skins_lib");
export const MODS_DIR = path.join(RUNTIME_DIR, "mods");
export const OVERLAY_DIR = path.join(RUNTIME_DIR, "overlay");
export const CFG_DIR = path.join(RUNTIME_DIR, "cfg");
export const PROFILES_DIR = path.join(RUNTIME_DIR, "profiles");
export const STATE_DIR = path.join(RUNTIME_DIR, "state");
export const MANIFEST_PATH = path.join(STATE_DIR, "manifest.json");
export const LOCK_PATH = path.join(STATE_DIR, "update.lock");
export const LOG_PATH = path.join(STATE_DIR, "updater.log");
export const APP_LOG_PATH = path.join(STATE_DIR, "app.log");
export const OVERLAY_LOG_PATH = path.join(STATE_DIR, "overlay.log");
/** 确保运行时目录存在。 */
export function ensureRuntimeDirs(): void {
for (const d of [
RUNTIME_DIR,
HASH_DIR,
SKINS_LIB_DIR,
MODS_DIR,
OVERLAY_DIR,
CFG_DIR,
PROFILES_DIR,
STATE_DIR,
]) {
fs.mkdirSync(d, { recursive: true });
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* 所有「公网」地址的唯一真源。只有 updater 允许引用这些。
* 主程序 lolskin 不得导入公网地址(只访问本地 LCU)。
*/
export const HTTP = {
userAgent: "lolskin/0.1 (+local)",
/** 单次请求超时(ms)。 */
timeoutMs: 30_000,
/** 大文件下载超时(ms)。 */
downloadTimeoutMs: 10 * 60_000,
};
/** 皮肤包(.fantome)源:LeagueSkins(全球服格式,位于 skins/{heroId}/{skinId}/{skinId}.fantome)。 */
export const LEAGUE_SKINS = {
raw: (heroId: number, skinId: number) =>
`https://raw.githubusercontent.com/Alban1911/LeagueSkins/main/skins/${heroId}/${skinId}/${skinId}.fantome`,
/** GitHub API 列目录(用于 prefetch,注意未认证 60 req/h)。 */
contents: (p = "skins") =>
`https://api.github.com/repos/Alban1911/LeagueSkins/contents/${p}`,
/** gitee 互备镜像(可作 raw 不可达时兜底)。 */
giteeRaw: (heroId: number, skinId: number) =>
`https://gitee.com/q9264570/creaker/raw/master/LOLSkin/${heroId}/${skinId}.zip`,
};
/** hashes 库源:CommunityDragon/Data(可选,仅 wad-extract 需要)。 */
export const CDRAGON = {
repo: "https://github.com/CommunityDragon/Data",
rawBase: "https://raw.githubusercontent.com/CommunityDragon/Data/master/hashes/lol/",
/** hashes.game.txt 被拆成 .0 .1 ... 分片。 */
gameShard: (n: number) =>
`https://raw.githubusercontent.com/CommunityDragon/Data/master/hashes/lol/hashes.game.txt.${n}`,
};
/** 注入器 cslol-dll.dll 的上游文件(唯一权威来源,release 无单独 DLL)。 */
export const INJECTOR = {
upstreamDll:
"https://raw.githubusercontent.com/LeagueToolkit/cslol-manager/master/cslol-tools/vendor/cslol-patcher/cslol-dll.dll",
};