init: lolskin 第一期骨架(全球服/cslol:core+updater+Electron UI+LCU)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
Reference in New Issue
Block a user