@linxin666/dsh-client-ui-skin-center 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/index.js +61 -17
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
2
  import z from "schemastery";
3
- import { lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
@@ -130,6 +130,7 @@ function listSkinDirCandidates(skinsDir) {
130
130
  const isDir = (p) => statSync(p, { throwIfNoEntry: false })?.isDirectory() === true;
131
131
  for (const dir of entries) {
132
132
  const candidate = join(skinsDir, dir);
133
+ if (lstatSync(candidate, { throwIfNoEntry: false })?.isSymbolicLink() === true) continue;
133
134
  if (!isDir(candidate)) continue;
134
135
  if (statSync(join(candidate, "skin.json"), { throwIfNoEntry: false })) out.push(candidate);
135
136
  }
@@ -159,7 +160,19 @@ function listSkinDirCandidates(skinsDir) {
159
160
  */
160
161
  function loadRegistry(skinsDir = SKINS_DIR) {
161
162
  const out = {};
163
+ const seenReal = /* @__PURE__ */ new Set();
162
164
  for (const dir of listSkinDirCandidates(skinsDir)) {
165
+ let real;
166
+ try {
167
+ real = realpathSync(dir);
168
+ } catch {
169
+ real = dir;
170
+ }
171
+ if (seenReal.has(real)) {
172
+ console.warn("[skin-center] duplicate skin dir (realpath) \"" + real + "\": keeping the real directory, ignoring " + dir);
173
+ continue;
174
+ }
175
+ seenReal.add(real);
163
176
  const meta = readSkinMeta(dir);
164
177
  if (meta === null || meta.wiring === void 0 || meta.package === void 0) continue;
165
178
  if (out[meta.id] !== void 0) {
@@ -296,14 +309,30 @@ function writePatchAtomic(filePath, next) {
296
309
  * @param entry - the skin switch entry.
297
310
  * @param profileModulesDir - the profile's node_modules dir.
298
311
  */
312
+ /** Canonical path a symlink resolves to, tolerant of a degraded link (a
313
+ * self-referential link whose realpath would throw ELOOP); '' when absent. */
314
+ function resolveLinkReal(linkPath) {
315
+ try {
316
+ return realpathSync(linkPath);
317
+ } catch {
318
+ return "";
319
+ }
320
+ }
299
321
  function ensureSymlink(entry, profileModulesDir) {
300
322
  const target = join(profileModulesDir, entry.pkg);
323
+ let entryReal;
324
+ try {
325
+ entryReal = realpathSync(entry.dir);
326
+ } catch {
327
+ entryReal = entry.dir;
328
+ }
329
+ if (entry.dir === target || entryReal === target) return false;
301
330
  let stat = null;
302
331
  try {
303
332
  stat = lstatSync(target);
304
333
  } catch {}
305
334
  if (stat) if (stat.isSymbolicLink()) {
306
- if (readlinkSync(target) === entry.dir) return false;
335
+ if (resolveLinkReal(target) === entryReal) return false;
307
336
  if (process.platform === "win32" && stat.isDirectory()) rmdirSync(target);
308
337
  else unlinkSync(target);
309
338
  } else if (stat.isDirectory()) {
@@ -360,20 +389,37 @@ function symlinkFriendly(caller, fn) {
360
389
  }
361
390
  }
362
391
  /**
363
- * Optional soft warning when a skin is not resolvable from the web profile
364
- * (the CLI prints it; surfaced as a soft hint, not a failure — the patch
365
- * stays authoritative).
392
+ * Whether the skin package is actually resolvable as a plugin from the web
393
+ * profile - the same directory contract the boot graph relies on when it
394
+ * loads the `useSkin` insert row. Unlike the old soft warning, this is a
395
+ * hard gate: the skin-center /apply endpoint must not report ok:true for a
396
+ * skin the host cannot load. The npm aggregate layout shipped skin dirs
397
+ * without a package.json + host entry, so /apply wrote the patch, reported
398
+ * success, and the boot then died on MODULE_NOT_FOUND .../package.json.
399
+ *
400
+ * The check is structural and deterministic (pure fs): resolves what node
401
+ * would - the profile-target package dir must carry a package.json whose
402
+ * name is this skin's package, and a host entry (main, else index.js) that
403
+ * actually exists. That is exactly the resolution that failed before.
366
404
  * @param entry - the skin switch entry.
367
405
  * @param profileModulesDir - the profile's node_modules dir.
406
+ * @returns an error message when the skin is not resolvable, else null.
368
407
  */
369
- function checkInstalled(entry, profileModulesDir) {
370
- let ok = false;
408
+ function checkResolvable(entry, profileModulesDir) {
409
+ const target = join(profileModulesDir, entry.pkg);
410
+ if (!statSync(target, { throwIfNoEntry: false })?.isDirectory()) return `${entry.pkg} 未安装到 profile(profile 中无 ${target})。请先用 dsh-skin install ${entry.id.replace(/^ui-skin-/, "")} 安装,否则宿主无法加载。`;
411
+ const pkgPath = join(target, "package.json");
412
+ if (!statSync(pkgPath, { throwIfNoEntry: false })) return `${entry.pkg} 在 profile 中缺少 package.json(${pkgPath})——聚合包皮肤目录未带可解析包元数据。`;
413
+ let parsed;
371
414
  try {
372
- const target = join(profileModulesDir, entry.pkg);
373
- const stat = lstatSync(target);
374
- ok = stat.isSymbolicLink() || stat.isDirectory() && isSkinPackageDir(target, entry);
375
- } catch {}
376
- return ok ? null : `${entry.pkg} 未安装到 profile;先用 dsh-skin install ${entry.id.replace(/^ui-skin-/, "")}(或 dsh plugin --profile ${DEFAULT_PROFILE} add ${entry.dir})安装,否则加载会失败。`;
415
+ parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
416
+ } catch {
417
+ parsed = {};
418
+ }
419
+ if (parsed.name !== entry.pkg) return `${entry.pkg} 解析到的 package.json 名为 ${String(parsed.name)},不是本皮肤(${pkgPath})。`;
420
+ const mainPath = join(target, typeof parsed.main === "string" ? parsed.main : "index.js");
421
+ if (!statSync(mainPath, { throwIfNoEntry: false })) return `${entry.pkg} 缺少 host 入口 ${mainPath}(package.json main 未指到可加载文件)。`;
422
+ return null;
377
423
  }
378
424
  /**
379
425
  * Switch the active skin. Equivalent to `dsh-skin use <name>`:
@@ -389,19 +435,17 @@ function useSkin(name, opts = {}) {
389
435
  const registry = opts.registry ?? loadRegistry();
390
436
  if (!official && registry[name] === void 0) throw new Error(`unknown skin "${name}". Known: ${Object.keys(registry).join(", ")} (or "official" for the stock look)`);
391
437
  const paths = resolvePaths(opts.home, opts.profile);
392
- const notices = [];
393
438
  if (!official) {
394
439
  const entry = registry[name];
395
440
  symlinkFriendly(`switching to "${name}"`, () => {
396
441
  ensureSymlink(entry, paths.profileModulesDir);
397
442
  });
398
- const warn = checkInstalled(entry, paths.profileModulesDir);
399
- if (warn !== null) notices.push(warn);
443
+ const problem = checkResolvable(entry, paths.profileModulesDir);
444
+ if (problem !== null) throw new Error(problem);
400
445
  }
401
446
  const next = `${stripLegacySkinRows(stripManaged(readPatch(paths.patchPath))).replace(/\s+$/, "")}\n\n${renderManaged(official ? null : name, registry)}\n`;
402
447
  writePatchAtomic(paths.patchPath, next);
403
- const core = official ? "restored the official stock look — the config watcher applies it within seconds; refresh the page to see it." : `skin switched to "${name}" — the config watcher applies it within seconds; refresh the page (or the manifest re-fetches) to see it.`;
404
- return notices.length ? `${core}\n${notices.join("\n")}` : core;
448
+ return official ? "restored the official stock look — the config watcher applies it within seconds; refresh the page to see it." : `skin switched to "${name}" — the config watcher applies it within seconds; refresh the page (or the manifest re-fetches) to see it.`;
405
449
  }
406
450
  /**
407
451
  * Read the active skin, mirroring `dsh-skin current` (prints the name or
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-skin-center",
3
3
  "description": "In-GUI skin center for the dsh web GUI: lists the official default plus every installed skin (qq98 / ths / xp / blue-fantasy / dragon-heir / minecraft), tries it on live inside the real GUI (executes the actual client bundle, light/dark preview), exits with a full restore, and applies in one click through the host /api/skin-center API (dsh-skin use, hot-reloaded, no restart)",
4
- "version": "0.1.6",
4
+ "version": "0.1.7",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {