@linxin666/dsh-client-ui-skin-center 0.3.2 → 0.3.3
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.
- package/README.i18n.yaml +2 -2
- package/README.md +8 -3
- package/README.zh.md +8 -3
- package/contracts/README.md +18 -0
- package/contracts/semantic-attrs-v1.md +1 -1
- package/contracts/skin-manifest-v2.schema.json +1 -1
- package/lib/client.js +289 -14
- package/lib/client.js.map +1 -1
- package/lib/index.js +114 -14
- package/package.json +2 -2
- package/skins/blue-fantasy/skin.json +1 -1
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { chmodSync, closeSync, cpSync, createReadStream, existsSync, lstatSync,
|
|
|
4
4
|
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
7
8
|
import { transform } from "lightningcss";
|
|
8
9
|
import { execFile, execFileSync } from "node:child_process";
|
|
9
10
|
import { readFile, stat } from "node:fs/promises";
|
|
@@ -703,6 +704,65 @@ function resolveHarnessPaths(home, profile, fromUrl = import.meta.url) {
|
|
|
703
704
|
};
|
|
704
705
|
}
|
|
705
706
|
//#endregion
|
|
707
|
+
//#region src/provenance.ts
|
|
708
|
+
/**
|
|
709
|
+
* Official-market provenance verification (issue #1073).
|
|
710
|
+
*
|
|
711
|
+
* Skins installed one-click from the DSH Market carry a
|
|
712
|
+
* dsh-market.provenance.json written by the market installer at install
|
|
713
|
+
* time, pinning every installed file to its sha256 and to the market
|
|
714
|
+
* origin. The market's skin content is built from THIS repository (same
|
|
715
|
+
* review, same release), so when the on-disk skin.json and hooks entry
|
|
716
|
+
* hash-match the provenance, the hooks bytes are exactly the reviewed
|
|
717
|
+
* bytes and may run like a built-in skin's.
|
|
718
|
+
*
|
|
719
|
+
* Fail-closed: a missing/unparseable provenance, a foreign source, or any
|
|
720
|
+
* hash mismatch (post-install tampering, partial copy) keeps the
|
|
721
|
+
* hooks-refused behavior for user-directory skins. Forging the provenance
|
|
722
|
+
* requires write access to $DSH_HOME itself — an attacker with that access
|
|
723
|
+
* can already install full plugins, so the file is a provenance record,
|
|
724
|
+
* not a capability guard against the local user.
|
|
725
|
+
* @module @linxin666/dsh-client-ui-skin-center/provenance
|
|
726
|
+
*/
|
|
727
|
+
/** Provenance filename written by the market installer (mirrors PROVENANCE_FILENAME in @linxin666/dsh-client-ui-market; no cross-package runtime import). */
|
|
728
|
+
const MARKET_PROVENANCE_FILENAME = "dsh-market.provenance.json";
|
|
729
|
+
function sha256Hex(abs) {
|
|
730
|
+
try {
|
|
731
|
+
return createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
732
|
+
} catch {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Whether the skin directory at dir carries valid official-market
|
|
738
|
+
* provenance for skinId whose declared hooks entry (already validated as a
|
|
739
|
+
* safe relative path by the manifest validator) hash-matches the recorded
|
|
740
|
+
* bytes — skin.json included, so the facet entry path itself is pinned.
|
|
741
|
+
*/
|
|
742
|
+
function verifyMarketProvenance(dir, skinId, hooksEntry) {
|
|
743
|
+
let raw;
|
|
744
|
+
try {
|
|
745
|
+
raw = JSON.parse(readFileSync(join(dir, MARKET_PROVENANCE_FILENAME), "utf8"));
|
|
746
|
+
} catch {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
if (typeof raw !== "object" || raw === null) return false;
|
|
750
|
+
const prov = raw;
|
|
751
|
+
if (prov.version !== 1) return false;
|
|
752
|
+
if (prov.source !== "https://dsh-market.com") return false;
|
|
753
|
+
if (prov.id !== skinId) return false;
|
|
754
|
+
const files = prov.files;
|
|
755
|
+
if (typeof files !== "object" || files === null) return false;
|
|
756
|
+
const hashes = files;
|
|
757
|
+
for (const rel of ["skin.json", hooksEntry]) {
|
|
758
|
+
const expected = hashes[rel];
|
|
759
|
+
if (typeof expected !== "string" || !/^[0-9a-f]{64}$/.test(expected)) return false;
|
|
760
|
+
const actual = sha256Hex(join(dir, ...rel.split("/")));
|
|
761
|
+
if (actual === null || actual !== expected) return false;
|
|
762
|
+
}
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
//#endregion
|
|
706
766
|
//#region src/skin-repo.ts
|
|
707
767
|
/**
|
|
708
768
|
* Skin repository (issue #506, M2): dual-source discovery of v2 skin asset
|
|
@@ -789,6 +849,28 @@ function readManifest(dir) {
|
|
|
789
849
|
return null;
|
|
790
850
|
}
|
|
791
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* Hooks trust for one user-directory skin: official-market installs
|
|
854
|
+
* whose skin.json and hooks entry hash-match the recorded provenance
|
|
855
|
+
* run their hooks (same-review content); anything else keeps the
|
|
856
|
+
* refusal warning. Built-in skins never reach this — their origin
|
|
857
|
+
* is the trust signal.
|
|
858
|
+
*/
|
|
859
|
+
function marketHooksTrust(manifest, dir) {
|
|
860
|
+
const facet = manifest.facets?.client;
|
|
861
|
+
if (!facet) return {
|
|
862
|
+
trusted: false,
|
|
863
|
+
warning: null
|
|
864
|
+
};
|
|
865
|
+
if (verifyMarketProvenance(dir, manifest.id, facet.entry)) return {
|
|
866
|
+
trusted: true,
|
|
867
|
+
warning: null
|
|
868
|
+
};
|
|
869
|
+
return {
|
|
870
|
+
trusted: false,
|
|
871
|
+
warning: "declares hooks.mjs, but hooks only run for built-in or verified official-market (same-review) skins; the hooks facet will be refused"
|
|
872
|
+
};
|
|
873
|
+
}
|
|
792
874
|
function collectSource(spec, catalog, claimed) {
|
|
793
875
|
if (!existsSync(spec.root)) return;
|
|
794
876
|
let dirNames;
|
|
@@ -831,12 +913,14 @@ function collectSource(spec, catalog, claimed) {
|
|
|
831
913
|
if (spec.origin === "user" && existing.origin === "builtin") {
|
|
832
914
|
catalog.skins = catalog.skins.filter((s) => s !== existing);
|
|
833
915
|
const winnerWarnings = [...result.warnings, `shadows the built-in "${manifest.id}" skin`];
|
|
834
|
-
|
|
916
|
+
const trust = marketHooksTrust(manifest, dir);
|
|
917
|
+
if (trust.warning !== null) winnerWarnings.push(trust.warning);
|
|
835
918
|
const winner = {
|
|
836
919
|
manifest,
|
|
837
920
|
origin: "user",
|
|
838
921
|
dir,
|
|
839
|
-
warnings: winnerWarnings
|
|
922
|
+
warnings: winnerWarnings,
|
|
923
|
+
...trust.trusted ? { hooksTrusted: true } : {}
|
|
840
924
|
};
|
|
841
925
|
claimed.set(manifest.id, winner);
|
|
842
926
|
catalog.skins.push(winner);
|
|
@@ -844,14 +928,19 @@ function collectSource(spec, catalog, claimed) {
|
|
|
844
928
|
continue;
|
|
845
929
|
}
|
|
846
930
|
const warnings = [...result.warnings];
|
|
847
|
-
|
|
931
|
+
const trust = spec.origin === "user" ? marketHooksTrust(manifest, dir) : {
|
|
932
|
+
trusted: false,
|
|
933
|
+
warning: null
|
|
934
|
+
};
|
|
935
|
+
if (trust.warning !== null) warnings.push(trust.warning);
|
|
848
936
|
const contractWarnings = auditTokenContract(stylesheetEntries(manifest, dir));
|
|
849
937
|
warnings.push(...contractWarnings.warnings);
|
|
850
938
|
const entry = {
|
|
851
939
|
manifest,
|
|
852
940
|
origin: spec.origin,
|
|
853
941
|
dir,
|
|
854
|
-
warnings
|
|
942
|
+
warnings,
|
|
943
|
+
...trust.trusted ? { hooksTrusted: true } : {}
|
|
855
944
|
};
|
|
856
945
|
claimed.set(manifest.id, entry);
|
|
857
946
|
catalog.skins.push(entry);
|
|
@@ -974,18 +1063,21 @@ const DEFAULT_SKIN_ID = "blue-fantasy";
|
|
|
974
1063
|
function defaultActiveStatePath() {
|
|
975
1064
|
return join(userSkinsDir(), "..", "skin-center-active.json");
|
|
976
1065
|
}
|
|
977
|
-
/** Read the whole state document; unreadable data yields all-null fields. */
|
|
1066
|
+
/** Read the whole state document; unreadable data yields all-null fields and initialized=false. */
|
|
978
1067
|
function readActiveState(path) {
|
|
979
1068
|
try {
|
|
980
1069
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1070
|
+
const hasInitialized = typeof parsed.initialized === "boolean" ? parsed.initialized : typeof parsed === "object" && parsed !== null && ("active" in parsed || "background" in parsed);
|
|
981
1071
|
return {
|
|
982
1072
|
active: typeof parsed.active === "string" ? parsed.active : null,
|
|
983
|
-
background: parsed.background === void 0 || parsed.background === null ? null : normalizeSkinBackground(parsed.background)
|
|
1073
|
+
background: parsed.background === void 0 || parsed.background === null ? null : normalizeSkinBackground(parsed.background),
|
|
1074
|
+
initialized: hasInitialized
|
|
984
1075
|
};
|
|
985
1076
|
} catch {
|
|
986
1077
|
return {
|
|
987
1078
|
active: null,
|
|
988
|
-
background: null
|
|
1079
|
+
background: null,
|
|
1080
|
+
initialized: false
|
|
989
1081
|
};
|
|
990
1082
|
}
|
|
991
1083
|
}
|
|
@@ -1007,7 +1099,10 @@ function writeActiveState(path, update) {
|
|
|
1007
1099
|
mkdirSync(dir, { recursive: true });
|
|
1008
1100
|
const tmpDir = mkdtempSync(join(dir, `${basename(path)}.tmp-`));
|
|
1009
1101
|
const tmp = join(tmpDir, basename(path));
|
|
1010
|
-
const document = {
|
|
1102
|
+
const document = {
|
|
1103
|
+
active,
|
|
1104
|
+
initialized: true
|
|
1105
|
+
};
|
|
1011
1106
|
if (background !== null) document.background = background;
|
|
1012
1107
|
try {
|
|
1013
1108
|
writeFileSync(tmp, JSON.stringify(document, null, 2) + "\n", {
|
|
@@ -1037,7 +1132,8 @@ function writeActiveSelection(path, id) {
|
|
|
1037
1132
|
* @returns whether the seed wrote the selection.
|
|
1038
1133
|
*/
|
|
1039
1134
|
function seedDefaultActiveSkin(path, find) {
|
|
1040
|
-
|
|
1135
|
+
const state = readActiveState(path);
|
|
1136
|
+
if (state.initialized || state.active !== null) return false;
|
|
1041
1137
|
if (!find("blue-fantasy")) return false;
|
|
1042
1138
|
writeActiveSelection(path, DEFAULT_SKIN_ID);
|
|
1043
1139
|
return true;
|
|
@@ -1803,7 +1899,9 @@ function findCloseBrace(css, openBrace) {
|
|
|
1803
1899
|
* The stylesheet/patches responses pass through the CSS safety pipeline
|
|
1804
1900
|
* (force-scoped under html[data-dsh-skin="<id>"], whitelist fail-closed), so
|
|
1805
1901
|
* the browser can inject them blindly. hooks.mjs is served verbatim — it is
|
|
1806
|
-
* trusted, same-review same-release code (high sensitivity, see contracts/)
|
|
1902
|
+
* trusted, same-review same-release code (high sensitivity, see contracts/),
|
|
1903
|
+
* served for built-in skins and for user-directory skins whose install
|
|
1904
|
+
* provenance pins the bytes to the official DSH Market (issue #1073).
|
|
1807
1905
|
* @module @linxin666/dsh-client-ui-skin-center/routes-v2
|
|
1808
1906
|
*/
|
|
1809
1907
|
const SKIN_CENTER_V2_PREFIX = "/api/skin-center/v2";
|
|
@@ -1897,7 +1995,8 @@ function makeSkinCenterV2Routes(deps = {}) {
|
|
|
1897
1995
|
skins: catalog.skins.filter((s) => s.origin === "user" || shippedSet.has(s.manifest.id)).map((s) => ({
|
|
1898
1996
|
origin: s.origin,
|
|
1899
1997
|
warnings: s.warnings,
|
|
1900
|
-
manifest: s.manifest
|
|
1998
|
+
manifest: s.manifest,
|
|
1999
|
+
channel: s.origin === "user" ? existsSync(join(s.dir, "dsh-market.provenance.json")) ? "market" : "unknown" : "npm"
|
|
1901
2000
|
})),
|
|
1902
2001
|
diagnostics: catalog.diagnostics
|
|
1903
2002
|
});
|
|
@@ -1940,7 +2039,7 @@ function makeSkinCenterV2Routes(deps = {}) {
|
|
|
1940
2039
|
});
|
|
1941
2040
|
return;
|
|
1942
2041
|
}
|
|
1943
|
-
if (entry.origin !== "builtin") {
|
|
2042
|
+
if (entry.origin !== "builtin" && entry.hooksTrusted !== true) {
|
|
1944
2043
|
writeJson(res, 403, {
|
|
1945
2044
|
ok: false,
|
|
1946
2045
|
error: "hooks-require-review",
|
|
@@ -8589,7 +8688,7 @@ function makeWeRoutes(deps) {
|
|
|
8589
8688
|
//#region src/mount-once.ts
|
|
8590
8689
|
/**
|
|
8591
8690
|
* Host single-instance guard shared by the plugin family. The family bundle
|
|
8592
|
-
* (dsh-web-
|
|
8691
|
+
* (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
8593
8692
|
* the loader accepts a standalone install of the same package side by side;
|
|
8594
8693
|
* without this guard the second instance would still re-register the same
|
|
8595
8694
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
@@ -8602,7 +8701,7 @@ function makeWeRoutes(deps) {
|
|
|
8602
8701
|
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
8603
8702
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
8604
8703
|
*/
|
|
8605
|
-
const MOUNTED = Symbol.for("dsh-web
|
|
8704
|
+
const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
|
|
8606
8705
|
function mountedSet() {
|
|
8607
8706
|
const registry = globalThis;
|
|
8608
8707
|
return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
|
|
@@ -8706,6 +8805,7 @@ const SkinWallpaperConfigSchema = z.object({
|
|
|
8706
8805
|
pauseOnHidden: z.boolean().default(true),
|
|
8707
8806
|
dim: z.number().min(0).max(90).step(5).default(25),
|
|
8708
8807
|
wallpaperBlur: z.number().min(0).max(60).step(1).default(0),
|
|
8808
|
+
wallpaperOpacity: z.number().min(0).max(100).step(5).default(100),
|
|
8709
8809
|
fit: z.union([
|
|
8710
8810
|
"cover",
|
|
8711
8811
|
"contain",
|
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 — the single skin package: skins are pure asset directories (built-in plus $DSH_HOME/skins) loaded and rendered only by the skin center; live try-on and one-click apply switch atomically in the page (no reload, no cordis.patch.yml rewrite, no restart)",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
],
|
|
65
65
|
"repository": {
|
|
66
66
|
"type": "git",
|
|
67
|
-
"url": "https://github.com/zhu1090093659/dsh-web
|
|
67
|
+
"url": "https://github.com/zhu1090093659/dsh-web.git"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"build": "tsdown",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"id": "blue-fantasy",
|
|
5
5
|
"name": "蓝色幻想",
|
|
6
6
|
"nameEn": "Blue Fantasy",
|
|
7
|
-
"author": "powerdog996(DreamSkin 社区)· dsh-web
|
|
7
|
+
"author": "powerdog996(DreamSkin 社区)· dsh-web 适配",
|
|
8
8
|
"tagline": "鲸鱼插画背景 · periwinkle 靛蓝调色板 · 半透明面板",
|
|
9
9
|
"description": "DreamSkin「DeepSeek-鲸鱼娘」Codex 桌面主题的 dsh 适配:鲸鱼插画背景垫在半透明面板之下,遮罩随亮/暗主题实时切换,periwinkle 靛蓝色调重映射到全部 dsh token。",
|
|
10
10
|
"tags": [
|