dsh-design-studio 0.9.0 → 0.11.0
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.en.md +6 -4
- package/README.md +6 -4
- package/assets/demo.gif +0 -0
- package/assets/hero-sm.jpg +0 -0
- package/cordis.patch.yml +5 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/_shots/menu.jpg +0 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/app.css +64 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/cart.html +120 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/design.json +56 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/menu.html +200 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/order.html +135 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/tokens.css +43 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/_shots/email.jpg +0 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/design.json +20 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/email.html +268 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/_shots/courses.jpg +0 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/courses.html +364 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/design.json +49 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/lesson.html +196 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/tokens.css +55 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/ui.css +255 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/_shots/poster.jpg +0 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/design.json +21 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/poster.html +165 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/tokens.css +35 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/_shots/home.jpg +0 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/app.css +155 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/booking.html +232 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/design.json +57 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/health.html +221 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/home.html +225 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/tokens.css +54 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/_shots/overview.jpg +0 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/app.css +79 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/design.json +51 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/entries.html +161 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/icons.svg +30 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/overview.html +181 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/tokens.css +50 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/_shots/home.jpg +0 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/book.html +279 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/design.json +21 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/home.html +348 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/style.css +297 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/tokens.css +49 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/01-cover.html +133 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/02-highlights.html +67 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/03-growth.html +89 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/04-milestones.html +92 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/05-next.html +99 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/06-thanks.html +61 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/_shots/01-cover.jpg +0 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/deck.css +67 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/design.json +25 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/tokens.css +30 -0
- package/gallery-seed/index.json +1 -0
- package/gallery-seed/thumbs/orderg-x__alley-noodles.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__autumn-sale.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__little-painter.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__moon-fair.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__paw-diary.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__pocket-ledger.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__time-bookshop.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__year-review.webp +0 -0
- package/lib/browser.js +23 -9
- package/lib/bundle.js +12 -3
- package/lib/client.js +3106 -111
- package/lib/core.js +44 -11
- package/lib/gallery/css.js +230 -0
- package/lib/gallery/html.js +313 -0
- package/lib/gallery/index.js +22 -0
- package/lib/gallery/market-routes.js +306 -0
- package/lib/gallery/market.js +623 -0
- package/lib/gallery/pack.js +311 -0
- package/lib/gallery/policy.js +1560 -0
- package/lib/gallery/qrfind.js +181 -0
- package/lib/gallery/render.js +148 -0
- package/lib/gallery/routes.js +360 -0
- package/lib/gallery/tar.js +118 -0
- package/lib/gallery/use.js +125 -0
- package/lib/gallery/zip.js +212 -0
- package/lib/handoff.js +291 -29
- package/lib/history.js +3 -1
- package/lib/index.js +118 -24
- package/lib/inspire.js +142 -0
- package/lib/offline.js +17 -0
- package/lib/picker.js +425 -32
- package/lib/serve.js +88 -11
- package/lib/works.js +238 -0
- package/locale/en.json +2 -1
- package/locale/offline/en.json +6 -0
- package/locale/offline/zh.json +6 -0
- package/locale/zh.json +2 -1
- package/package.json +10 -3
- package/skills/ui-design/SKILL.md +9 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设计市场(插件这头):从 npm 镜像取目录、验签名、缓存;取内容批次、逐个文件核对;断网用缓存
|
|
3
|
+
*
|
|
4
|
+
* 目录包 dsh-design-gallery:index.json + index.json.sig(ed25519,签原始字节)+ thumbs/<id>.webp
|
|
5
|
+
* 批次包 dsh-design-gallery-packs@<v>:d/<id>/<rev>/ 下是设计文件和 _shots/ 每屏截图
|
|
6
|
+
*
|
|
7
|
+
* 信任只来自两样:钉在这里的公钥(验目录签名),和目录里每个文件的 sha256(验批次包里的文件、封面、截图)。
|
|
8
|
+
* 镜像、跳转、tarball 都按不可信处理:主机白名单、大小上限、超时;tarball 有 integrity 就核对。
|
|
9
|
+
*
|
|
10
|
+
* 本机:
|
|
11
|
+
* 缓存 ~/.dsh/cache/dsh-design-studio/gallery/ catalog/(index.json、.sig、thumbs/)、meta.json、packs/<v>.tgz、staging/<id>@<rev>/
|
|
12
|
+
* 状态 ~/.dsh/dsh-design-studio/gallery-state.json serial 下限、下架名单并集(不放缓存,清缓存也不丢,防旧目录让下架的复活)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import crypto from 'node:crypto'
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { readTar } from './tar.js'
|
|
20
|
+
import { cleanPath } from './policy.js'
|
|
21
|
+
import { LIBRARY_DIR } from '../works.js'
|
|
22
|
+
|
|
23
|
+
export const CATALOG_PKG = 'dsh-design-gallery'
|
|
24
|
+
export const PACKS_PKG = 'dsh-design-gallery-packs'
|
|
25
|
+
export const FORMAT = 1
|
|
26
|
+
export const POLICY = 1
|
|
27
|
+
export const REPO_URL = 'https://github.com/OrderG-X/dsh-design-gallery'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 钉死的公钥(base64 的 32 字节 ed25519 原始公钥):现用的一把 + 下一次换钥匙用的一把。
|
|
31
|
+
* 钥匙由维护者生成(内容仓库 scripts/sign.mjs keygen),生成后填在这里再发版。换钥匙的步骤见内容仓库 MAINTAINING.md。
|
|
32
|
+
*/
|
|
33
|
+
export const KEYS = [
|
|
34
|
+
'4c+zqZ75SRZTbA8U4T3Aeht7ksXL4408tq7LrM216rQ=', // 现用(2026-11-07)
|
|
35
|
+
'Z8IJpjKwdeM+S/9RIbc/2MCEEpqTOAqRmGdHp5TnPZI=', // 备用:下次换钥匙用
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
export const REGISTRIES = [
|
|
39
|
+
'https://registry.npmmirror.com',
|
|
40
|
+
'https://mirrors.cloud.tencent.com/npm',
|
|
41
|
+
'https://repo.huaweicloud.com/repository/npm',
|
|
42
|
+
'https://registry.npmjs.org',
|
|
43
|
+
]
|
|
44
|
+
// 跳转和 tarball 只认这些主机(npmmirror 的 tarball 会跳到 cdn.npmmirror.com)
|
|
45
|
+
const HOSTS = new Set(['registry.npmmirror.com', 'cdn.npmmirror.com', 'mirrors.cloud.tencent.com', 'repo.huaweicloud.com', 'registry.npmjs.org'])
|
|
46
|
+
|
|
47
|
+
export const CACHE_DIR = process.env.DSH_DESIGN_GALLERY_CACHE || path.join(os.homedir(), '.dsh', 'cache', 'dsh-design-studio', 'gallery')
|
|
48
|
+
export const STALE_MS = 3600e3
|
|
49
|
+
export const RETRY_MS = 300e3 // 自动刷新失败后,过这么久才再自动试(手动点「刷新」不受限)
|
|
50
|
+
const PACKS_CAP = 300 * 1024 * 1024
|
|
51
|
+
const MAX = { json: 8 * 1024 * 1024, catalog: 40 * 1024 * 1024, packs: 40 * 1024 * 1024 }
|
|
52
|
+
export const ID_RE = /^[a-z0-9-]{1,39}__[a-z0-9-]{3,40}$/
|
|
53
|
+
const REV_RE = /^[0-9a-f]{7,64}$/
|
|
54
|
+
const VER_RE = /^\d{4}\.\d{3,4}\.\d{1,4}$/
|
|
55
|
+
|
|
56
|
+
export class MarketError extends Error {
|
|
57
|
+
constructor(code, x = '') {
|
|
58
|
+
super(`${code}${x ? ': ' + x : ''}`)
|
|
59
|
+
this.code = code
|
|
60
|
+
this.x = x
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** a 比 b 新返回 1,旧 -1,一样 0(x.y.z,后缀不管) */
|
|
65
|
+
export function vcmp(a, b) {
|
|
66
|
+
const pa = String(a || '0').split(/[.+-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0)
|
|
67
|
+
const pb = String(b || '0').split(/[.+-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0)
|
|
68
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0) ? 1 : -1
|
|
69
|
+
return 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const SPKI = Buffer.from('302a300506032b6570032100', 'hex')
|
|
73
|
+
/** 目录签名对不对(任何一把钉死的公钥验过就行) */
|
|
74
|
+
export function verifyCatalog(bytes, sigB64, keys = KEYS) {
|
|
75
|
+
const sig = Buffer.from(String(sigB64 || '').trim(), 'base64')
|
|
76
|
+
if (sig.length !== 64) return false
|
|
77
|
+
return keys.some((k) => {
|
|
78
|
+
try {
|
|
79
|
+
const raw = Buffer.from(String(k), 'base64')
|
|
80
|
+
if (raw.length !== 32) return false
|
|
81
|
+
return crypto.verify(null, bytes, crypto.createPublicKey({ key: Buffer.concat([SPKI, raw]), format: 'der', type: 'spki' }), sig)
|
|
82
|
+
} catch { return false }
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex')
|
|
87
|
+
|
|
88
|
+
const SHOT_RE = /^_shots\/[^/]+\.jpg$/
|
|
89
|
+
const MEDIA_CAP = 2 * 1024 * 1024
|
|
90
|
+
/** 目录里登记的一张图:{ p, sha256, size } */
|
|
91
|
+
const mediaOk = (m, re) => !!m && typeof m === 'object' && typeof m.p === 'string' && re.test(m.p) && typeof m.sha256 === 'string' && /^[0-9a-f]{64}$/.test(m.sha256) && Number.isInteger(m.size) && m.size > 0 && m.size <= MEDIA_CAP
|
|
92
|
+
const sameMedia = (m, buf) => !!buf && buf.length === m.size && sha256(buf) === m.sha256
|
|
93
|
+
/** 一套登记了的截图(格式不对的不认) */
|
|
94
|
+
export const shotsOf = (e) => (e && Array.isArray(e.shots) ? e.shots : []).filter((x) => mediaOk(x, SHOT_RE))
|
|
95
|
+
/** 一套登记了的封面(路径只能是 thumbs/<id>.webp) */
|
|
96
|
+
export const thumbOf = (e) => (e && mediaOk(e.thumb, /^thumbs\/[a-z0-9_-]+\.webp$/) && e.thumb.p === `thumbs/${e.id}.webp` ? e.thumb : null)
|
|
97
|
+
|
|
98
|
+
/** 读 tgz;坏包(越界路径、链接、超限)整个拒 */
|
|
99
|
+
function untar(buf, limits) {
|
|
100
|
+
try { return readTar(buf, limits) } catch (e) { throw new MarketError('tar', e && e.code ? e.code : '') }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readJson(file, fallback) {
|
|
104
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback }
|
|
105
|
+
}
|
|
106
|
+
function writeAtomic(file, data) {
|
|
107
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
108
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
109
|
+
fs.writeFileSync(tmp, data)
|
|
110
|
+
fs.renameSync(tmp, file)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 读响应体,超过上限就停(声明的长度不可信) */
|
|
114
|
+
async function readCapped(res, cap) {
|
|
115
|
+
const len = Number(res.headers.get('content-length') || 0)
|
|
116
|
+
if (len > cap) throw new MarketError('big')
|
|
117
|
+
if (!res.body) return Buffer.alloc(0)
|
|
118
|
+
const chunks = []
|
|
119
|
+
let n = 0
|
|
120
|
+
for await (const c of res.body) {
|
|
121
|
+
n += c.length
|
|
122
|
+
if (n > cap) throw new MarketError('big')
|
|
123
|
+
chunks.push(Buffer.from(c))
|
|
124
|
+
}
|
|
125
|
+
return Buffer.concat(chunks)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 包名 + 镜像列表(环境变量 DSH_DESIGN_GALLERY_REGISTRY 可以换成自己的,逗号隔开;测试用) */
|
|
129
|
+
export function registriesFromEnv(env = process.env) {
|
|
130
|
+
const own = String(env.DSH_DESIGN_GALLERY_REGISTRY || '').split(',').map((s) => s.trim().replace(/\/+$/, '')).filter((s) => /^https?:\/\//.test(s))
|
|
131
|
+
return own.length ? own : REGISTRIES
|
|
132
|
+
}
|
|
133
|
+
/** 测试用:只有换了镜像时才认环境变量里多给的公钥 */
|
|
134
|
+
export function keysFromEnv(env = process.env) {
|
|
135
|
+
if (!String(env.DSH_DESIGN_GALLERY_REGISTRY || '').trim()) return KEYS
|
|
136
|
+
const extra = String(env.DSH_DESIGN_GALLERY_KEYS || '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
137
|
+
return [...KEYS, ...extra]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 依次试几个源:第一个马上发,后面的每隔 stagger 再发一个(前一个失败了就立刻发下一个);谁先成功用谁
|
|
142
|
+
* 每个请求 each 毫秒超时,整体 total 毫秒
|
|
143
|
+
*/
|
|
144
|
+
export function race(sources, task, { stagger = 3000, each = 10000, total = 20000 } = {}) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const all = new AbortController()
|
|
147
|
+
const errs = []
|
|
148
|
+
let next = 0
|
|
149
|
+
let running = 0
|
|
150
|
+
let done = false
|
|
151
|
+
let timer = null
|
|
152
|
+
const finish = (fn) => { if (done) return; done = true; clearTimeout(timer); clearTimeout(cap); all.abort(); fn() }
|
|
153
|
+
const cap = setTimeout(() => finish(() => reject(new MarketError('timeout'))), total)
|
|
154
|
+
const launch = () => {
|
|
155
|
+
if (done || next >= sources.length) return
|
|
156
|
+
const src = sources[next++]
|
|
157
|
+
running++
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
timer = setTimeout(launch, stagger)
|
|
160
|
+
const signal = AbortSignal.any([all.signal, AbortSignal.timeout(each)])
|
|
161
|
+
Promise.resolve().then(() => task(src, signal)).then(
|
|
162
|
+
(value) => finish(() => resolve({ value, source: src })),
|
|
163
|
+
(e) => {
|
|
164
|
+
errs.push(e)
|
|
165
|
+
running--
|
|
166
|
+
if (next < sources.length) launch()
|
|
167
|
+
else if (!running) finish(() => reject(errs.length === 1 || errs.every((x) => x && x.code && x.code === errs[0].code) ? errs[0] : new MarketError('offline', errs.map((x) => x && x.code ? x.code : String(x && x.message)).join(','))))
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
launch()
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 市场:目录(取、验、缓存、过滤)+ 批次包(取、核对、解到 staging)
|
|
177
|
+
* fetch 可注入(测试);registries / keys 默认从环境变量来
|
|
178
|
+
*/
|
|
179
|
+
export function createMarket({
|
|
180
|
+
cacheDir = CACHE_DIR,
|
|
181
|
+
stateDir = LIBRARY_DIR,
|
|
182
|
+
version = '0.0.0',
|
|
183
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
184
|
+
registries = registriesFromEnv(),
|
|
185
|
+
keys = keysFromEnv(),
|
|
186
|
+
seedDir = '',
|
|
187
|
+
log = () => {},
|
|
188
|
+
now = Date.now,
|
|
189
|
+
timing = {},
|
|
190
|
+
online = () => true,
|
|
191
|
+
} = {}) {
|
|
192
|
+
const catDir = path.join(cacheDir, 'catalog')
|
|
193
|
+
const packDir = path.join(cacheDir, 'packs')
|
|
194
|
+
const stageDir = path.join(cacheDir, 'staging')
|
|
195
|
+
const metaFile = path.join(cacheDir, 'meta.json')
|
|
196
|
+
const stateFile = path.join(stateDir, 'gallery-state.json')
|
|
197
|
+
const ownHosts = new Set(registries.map((r) => { try { return new URL(r).host } catch { return '' } }).filter(Boolean))
|
|
198
|
+
const hostOk = (u) => { try { const x = new URL(u); return (x.protocol === 'https:' || (x.protocol === 'http:' && ownHosts.has(x.host))) && (HOSTS.has(x.hostname) || ownHosts.has(x.host)) } catch { return false } }
|
|
199
|
+
|
|
200
|
+
let loaded = null // { index, bytes, source: 'cache' | 'seed', at }
|
|
201
|
+
let lastError = ''
|
|
202
|
+
let needUpdate = false
|
|
203
|
+
let inflight = null
|
|
204
|
+
let lastFail = 0
|
|
205
|
+
const live = new Set() // 正在进行的请求:打开「不联网」时全部掐掉
|
|
206
|
+
|
|
207
|
+
const state = () => {
|
|
208
|
+
const s = readJson(stateFile, null)
|
|
209
|
+
return { serial: Number(s && s.serial) || 0, revoked: Array.isArray(s && s.revoked) ? s.revoked.filter((r) => r && typeof r.id === 'string') : [] }
|
|
210
|
+
}
|
|
211
|
+
function remember(index) {
|
|
212
|
+
const s = state()
|
|
213
|
+
const key = (r) => `${r.id}@${r.rev || ''}`
|
|
214
|
+
const map = new Map(s.revoked.map((r) => [key(r), r]))
|
|
215
|
+
for (const r of index.revoked || []) if (r && typeof r.id === 'string' && ID_RE.test(r.id)) map.set(key(r), { id: r.id, ...(typeof r.rev === 'string' && REV_RE.test(r.rev) ? { rev: r.rev } : {}), reason: String(r.reason || '').slice(0, 20) })
|
|
216
|
+
// 撤销下架:本地的下架名单只认签了名的「撤销名单」才放出来(这一版又下架的不放)
|
|
217
|
+
const again = new Set((index.revoked || []).filter((r) => r && typeof r.id === 'string').map((r) => r.id))
|
|
218
|
+
for (const r of Array.isArray(index.restored) ? index.restored : []) {
|
|
219
|
+
if (!r || typeof r.id !== 'string' || !ID_RE.test(r.id) || again.has(r.id)) continue
|
|
220
|
+
for (const [k, v] of map) if (v.id === r.id) map.delete(k)
|
|
221
|
+
}
|
|
222
|
+
writeAtomic(stateFile, JSON.stringify({ serial: Math.max(s.serial, Number(index.serial) || 0), revoked: [...map.values()].slice(-5000) }, null, 1))
|
|
223
|
+
// 下架的:解开的那份也删掉(用户复制走的作品不动)
|
|
224
|
+
let names = []
|
|
225
|
+
try { names = fs.readdirSync(stageDir) } catch { return }
|
|
226
|
+
for (const r of index.revoked || []) {
|
|
227
|
+
if (!r || typeof r.id !== 'string' || !ID_RE.test(r.id)) continue
|
|
228
|
+
for (const n of names) if (n === `${r.id}@${r.rev}` || (!r.rev && n.startsWith(`${r.id}@`))) try { fs.rmSync(path.join(stageDir, n), { recursive: true, force: true }) } catch { /* 下次再删 */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const isRevoked = (e, s = state()) => s.revoked.some((r) => r.id === e.id && (!r.rev || r.rev === e.rev))
|
|
232
|
+
|
|
233
|
+
async function get(url, { signal, cap, accept }) {
|
|
234
|
+
const ctl = new AbortController()
|
|
235
|
+
live.add(ctl)
|
|
236
|
+
const sig = signal ? AbortSignal.any([signal, ctl.signal]) : ctl.signal
|
|
237
|
+
try {
|
|
238
|
+
let u = url
|
|
239
|
+
for (let hop = 0; hop < 4; hop++) {
|
|
240
|
+
// 每一跳之前都看一眼「不联网」开关(开着就一个网址都不访问)
|
|
241
|
+
if (!online()) throw new MarketError('off')
|
|
242
|
+
if (!hostOk(u)) throw new MarketError('host', (() => { try { return new URL(u).host } catch { return 'bad url' } })())
|
|
243
|
+
const res = await fetchImpl(u, { redirect: 'manual', signal: sig, headers: { 'User-Agent': `dsh-design-studio/${version}`, ...(accept ? { Accept: accept } : {}) } })
|
|
244
|
+
if (res.status >= 300 && res.status < 400) {
|
|
245
|
+
const loc = res.headers.get('location')
|
|
246
|
+
if (!loc) throw new MarketError('http', String(res.status))
|
|
247
|
+
u = new URL(loc, u).href
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
if (!res.ok) throw new MarketError('http', String(res.status))
|
|
251
|
+
return await readCapped(res, cap)
|
|
252
|
+
}
|
|
253
|
+
throw new MarketError('redirects')
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (ctl.signal.aborted || !online()) throw new MarketError('off')
|
|
256
|
+
throw e
|
|
257
|
+
} finally {
|
|
258
|
+
live.delete(ctl)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** 打开「不联网」:正在下的全部掐掉 */
|
|
262
|
+
function abort() {
|
|
263
|
+
for (const c of [...live]) c.abort()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 一个源上某包的 { version, tarball, integrity }(不给版本就是 latest) */
|
|
267
|
+
async function lookup(reg, name, want, signal) {
|
|
268
|
+
const raw = await get(`${reg}/${name}`, { signal, cap: MAX.json, accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8' })
|
|
269
|
+
let doc
|
|
270
|
+
try { doc = JSON.parse(raw.toString('utf8')) } catch { throw new MarketError('json') }
|
|
271
|
+
const v = want || (doc && doc['dist-tags'] && doc['dist-tags'].latest)
|
|
272
|
+
const d = v && doc.versions && doc.versions[v] && doc.versions[v].dist
|
|
273
|
+
if (!d || typeof d.tarball !== 'string') throw new MarketError('nover', String(v || ''))
|
|
274
|
+
return { version: v, tarball: d.tarball, integrity: typeof d.integrity === 'string' ? d.integrity : '' }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function download(info, cap, signal) {
|
|
278
|
+
const buf = await get(info.tarball, { signal, cap })
|
|
279
|
+
const m = /^sha512-([A-Za-z0-9+/=]+)$/.exec(info.integrity.split(/\s+/).find((x) => x.startsWith('sha512-')) || '')
|
|
280
|
+
if (m && crypto.createHash('sha512').update(buf).digest('base64') !== m[1]) throw new MarketError('integrity')
|
|
281
|
+
return buf
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 解析、验签名、查格式和 serial;过了返回 index(不写盘) */
|
|
285
|
+
function accept(bytes, sig, { floor = state().serial } = {}) {
|
|
286
|
+
if (!keys.length) throw new MarketError('nokeys')
|
|
287
|
+
if (!verifyCatalog(bytes, sig, keys)) throw new MarketError('signature')
|
|
288
|
+
let index
|
|
289
|
+
try { index = JSON.parse(bytes.toString('utf8')) } catch { throw new MarketError('json') }
|
|
290
|
+
if (!index || typeof index !== 'object') throw new MarketError('json')
|
|
291
|
+
if (Number(index.format) > FORMAT) { needUpdate = true; throw new MarketError('format', String(index.format)) }
|
|
292
|
+
if (Number(index.format) !== FORMAT) throw new MarketError('format', String(index.format))
|
|
293
|
+
if (!(Number(index.serial) >= floor)) throw new MarketError('old', String(index.serial))
|
|
294
|
+
return index
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function loadCache() {
|
|
298
|
+
try {
|
|
299
|
+
const bytes = fs.readFileSync(path.join(catDir, 'index.json'))
|
|
300
|
+
const sig = fs.readFileSync(path.join(catDir, 'index.json.sig'), 'utf8')
|
|
301
|
+
const meta = readJson(metaFile, {}) || {}
|
|
302
|
+
// 缓存也重新验一遍(在用户自己的目录里,但便宜);serial 下限也要过(缓存被换成旧的不认)
|
|
303
|
+
const index = accept(bytes, sig)
|
|
304
|
+
return { index, source: 'cache', at: Number(meta.at) || 0, version: meta.version || '', registry: meta.registry || '' }
|
|
305
|
+
} catch (e) {
|
|
306
|
+
if (e.code !== 'ENOENT' && !(e instanceof MarketError && e.code === 'nokeys')) log(e)
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function loadSeed() {
|
|
311
|
+
if (!seedDir) return null
|
|
312
|
+
const index = readJson(path.join(seedDir, 'index.json'), null)
|
|
313
|
+
return index && Number(index.format) === FORMAT ? { index, source: 'seed', at: 0, version: '', registry: '' } : null
|
|
314
|
+
}
|
|
315
|
+
function current() {
|
|
316
|
+
if (!loaded) loaded = loadCache() || loadSeed()
|
|
317
|
+
return loaded
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 去镜像上取最新目录;新就换掉缓存。同时只跑一个 */
|
|
321
|
+
function refresh() {
|
|
322
|
+
if (inflight) return inflight
|
|
323
|
+
const job = (async () => {
|
|
324
|
+
try {
|
|
325
|
+
if (!keys.length) throw new MarketError('nokeys')
|
|
326
|
+
if (!online()) throw new MarketError('off')
|
|
327
|
+
const have = current()
|
|
328
|
+
// 一个源要解开、验过签名、serial 也够新才算成功;坏包、旧目录的源算失败,接着等别的源
|
|
329
|
+
const { value, source } = await race(registries, async (reg, signal) => {
|
|
330
|
+
const info = await lookup(reg, CATALOG_PKG, '', signal)
|
|
331
|
+
if (have && have.source === 'cache' && have.version === info.version && fs.existsSync(path.join(catDir, 'index.json'))) return { info, same: true }
|
|
332
|
+
const buf = await download(info, MAX.catalog, signal)
|
|
333
|
+
const files = untar(buf, { files: 3000, file: 8 * 1024 * 1024, total: MAX.catalog * 3 })
|
|
334
|
+
const idx = files.find((f) => f.path === 'index.json')
|
|
335
|
+
const sig = files.find((f) => f.path === 'index.json.sig')
|
|
336
|
+
if (!idx || !sig) throw new MarketError('incomplete')
|
|
337
|
+
return { info, files, idx, sig, index: accept(idx.data, sig.data.toString('utf8')) }
|
|
338
|
+
}, timing)
|
|
339
|
+
if (!online()) throw new MarketError('off')
|
|
340
|
+
if (value.same) {
|
|
341
|
+
writeAtomic(metaFile, JSON.stringify({ ...(readJson(metaFile, {}) || {}), at: now(), registry: source }))
|
|
342
|
+
loaded = { ...have, at: now(), registry: source }
|
|
343
|
+
} else {
|
|
344
|
+
const { files, idx, sig, index } = value
|
|
345
|
+
// 新目录写到旁边,整个换上去(换的那一下中断了也不会半新半旧)
|
|
346
|
+
const fresh = `${catDir}.new`
|
|
347
|
+
fs.rmSync(fresh, { recursive: true, force: true })
|
|
348
|
+
fs.mkdirSync(path.join(fresh, 'thumbs'), { recursive: true })
|
|
349
|
+
fs.writeFileSync(path.join(fresh, 'index.json'), idx.data)
|
|
350
|
+
fs.writeFileSync(path.join(fresh, 'index.json.sig'), sig.data)
|
|
351
|
+
// 封面只收目录里登记过、sha256 和大小都对得上的(镜像换了图也不认)
|
|
352
|
+
const byId = new Map([...(index.designs || []), ...(index.systems || [])].filter((e) => e && typeof e.id === 'string').map((e) => [e.id, e]))
|
|
353
|
+
for (const f of files) {
|
|
354
|
+
const m = /^thumbs\/([a-z0-9_-]+)\.webp$/.exec(f.path)
|
|
355
|
+
const t = m && ID_RE.test(m[1]) && thumbOf(byId.get(m[1]))
|
|
356
|
+
if (t && sameMedia(t, f.data)) fs.writeFileSync(path.join(fresh, 'thumbs', `${m[1]}.webp`), f.data)
|
|
357
|
+
}
|
|
358
|
+
const old = `${catDir}.old`
|
|
359
|
+
fs.rmSync(old, { recursive: true, force: true })
|
|
360
|
+
if (fs.existsSync(catDir)) fs.renameSync(catDir, old)
|
|
361
|
+
fs.renameSync(fresh, catDir)
|
|
362
|
+
fs.rmSync(old, { recursive: true, force: true })
|
|
363
|
+
remember(index)
|
|
364
|
+
writeAtomic(metaFile, JSON.stringify({ version: value.info.version, at: now(), registry: source }))
|
|
365
|
+
loaded = { index, source: 'cache', at: now(), version: value.info.version, registry: source }
|
|
366
|
+
}
|
|
367
|
+
lastError = ''
|
|
368
|
+
lastFail = 0
|
|
369
|
+
return loaded
|
|
370
|
+
} catch (e) {
|
|
371
|
+
if (!(e instanceof MarketError && (e.code === 'off' || e.code === 'nokeys'))) lastFail = now()
|
|
372
|
+
lastError = e instanceof MarketError ? e.code : 'offline'
|
|
373
|
+
if (!(e instanceof MarketError)) log(e)
|
|
374
|
+
throw e
|
|
375
|
+
}
|
|
376
|
+
})()
|
|
377
|
+
// 在外面清:一开头就失败(不联网、没公钥)时 async 函数同步跑完,里面的 finally 会早于这次赋值
|
|
378
|
+
inflight = job
|
|
379
|
+
job.finally(() => { if (inflight === job) inflight = null }).catch(() => {})
|
|
380
|
+
return inflight
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 缓存超过一小时就在后台刷(不等它) */
|
|
384
|
+
function maybeRefresh() {
|
|
385
|
+
const c = current()
|
|
386
|
+
if (!keys.length || !online()) return
|
|
387
|
+
if (lastFail && now() - lastFail < RETRY_MS) return // 刚失败过:别一直重试(界面在刷新时会反复来问)
|
|
388
|
+
if (!c || c.source !== 'cache' || now() - c.at > STALE_MS) refresh().catch(() => {})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 能用的条目:去掉下架的、要新版插件的、规矩版本更高的、id 不对的 */
|
|
392
|
+
function usable(list, s) {
|
|
393
|
+
return (Array.isArray(list) ? list : []).filter((e) => e && typeof e === 'object'
|
|
394
|
+
&& ID_RE.test(String(e.id)) && REV_RE.test(String(e.rev || ''))
|
|
395
|
+
&& !(e.minPlugin && vcmp(e.minPlugin, version) > 0)
|
|
396
|
+
&& !(Number(e.policy) > POLICY)
|
|
397
|
+
&& !isRevoked(e, s))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 给界面的目录:三类条目 + 词表 + 状态 */
|
|
401
|
+
function view() {
|
|
402
|
+
const c = current()
|
|
403
|
+
const s = state()
|
|
404
|
+
// revoked:下架名单(收藏、我的作品里标「已下架」用)
|
|
405
|
+
const base = { source: c ? c.source : '', at: c ? c.at : 0, registry: c ? c.registry : '', error: lastError, needUpdate, keys: keys.length > 0, off: !online(), refreshing: !!inflight, revoked: s.revoked.map((r) => (r.rev ? { id: r.id, rev: r.rev } : { id: r.id })) }
|
|
406
|
+
if (!c) return { ...base, designs: [], systems: [], styles: [], metadata: {} }
|
|
407
|
+
const ix = c.index
|
|
408
|
+
const tooNew = ix.minPlugin && vcmp(ix.minPlugin, version) > 0
|
|
409
|
+
if (tooNew) return { ...base, needUpdate: true, designs: [], systems: [], styles: [], metadata: {} }
|
|
410
|
+
const hasThumb = (e) => !!thumbOf(e) && (c.source === 'cache' ? fs.existsSync(path.join(catDir, 'thumbs', `${e.id}.webp`)) : !!(seedDir && fs.existsSync(path.join(seedDir, 'thumbs', `${e.id}.webp`))))
|
|
411
|
+
const card = (e) => {
|
|
412
|
+
const out = {
|
|
413
|
+
id: e.id, type: e.type, rev: e.rev, title: e.title || {}, description: e.description || {}, kind: e.kind || '', style: e.type === 'style' ? e.style : e.style || '',
|
|
414
|
+
categories: e.categories || [], tags: e.tags || [], lang: e.lang || '', dark: !!e.dark, license: e.license || '', author: e.author || {},
|
|
415
|
+
ai: e.ai || null, derivedFrom: e.derivedFrom || null, featured: !!e.featured, added: e.added || '', updated: e.updated || '',
|
|
416
|
+
palette: e.palette || {}, disclosure: e.disclosure || null, check: e.check || null, bytes: Number(e.bytes) || 0, submission: e.submission || '',
|
|
417
|
+
shareHash: typeof e.shareHash === 'string' && /^[0-9a-f]{32}$/.test(e.shareHash) ? e.shareHash : '', // 「我的作品」认自己分享上架的
|
|
418
|
+
}
|
|
419
|
+
if (e.type !== 'style') {
|
|
420
|
+
out.screens = Array.isArray(e.screens) ? e.screens.map((x) => ({ file: x.file, title: x.title || {} })) : []
|
|
421
|
+
out.shots = shotsOf(e).length
|
|
422
|
+
out.thumb = hasThumb(e)
|
|
423
|
+
}
|
|
424
|
+
return out
|
|
425
|
+
}
|
|
426
|
+
// 同一个 id 在两类里都有:分不清,两边都不列(和 entry() 一致)
|
|
427
|
+
const seen = new Map()
|
|
428
|
+
for (const e of [...usable(ix.designs, s), ...usable(ix.systems, s), ...usable(ix.styles, s)]) seen.set(e.id, (seen.get(e.id) || 0) + 1)
|
|
429
|
+
const one = (e) => seen.get(e.id) === 1
|
|
430
|
+
return {
|
|
431
|
+
...base,
|
|
432
|
+
serial: ix.serial,
|
|
433
|
+
metadata: ix.metadata || {},
|
|
434
|
+
designs: usable(ix.designs, s).filter((e) => e.type === 'template' && one(e)).map(card),
|
|
435
|
+
systems: usable(ix.systems, s).filter((e) => e.type === 'system' && one(e)).map(card),
|
|
436
|
+
styles: usable(ix.styles, s).filter((e) => e.type === 'style' && e.style && typeof e.style === 'object' && one(e)).map(card),
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 按 id 找一条(能用的才算) */
|
|
441
|
+
function entry(id) {
|
|
442
|
+
const c = current()
|
|
443
|
+
if (!c) return null
|
|
444
|
+
const s = state()
|
|
445
|
+
const ix = c.index
|
|
446
|
+
if (ix.minPlugin && vcmp(ix.minPlugin, version) > 0) return null
|
|
447
|
+
// 同一个 id 出现在两类里(目录不该这样):分不清是哪一套,当没有
|
|
448
|
+
const hit = [...usable(ix.designs, s), ...usable(ix.systems, s), ...usable(ix.styles, s)].filter((e) => e.id === id)
|
|
449
|
+
return hit.length === 1 ? hit[0] : null
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 封面:和目录里登记的 sha256、大小对得上才给(返回核对过的那份字节) */
|
|
453
|
+
function thumbFile(id) {
|
|
454
|
+
const e = ID_RE.test(id) ? entry(id) : null
|
|
455
|
+
const t = thumbOf(e)
|
|
456
|
+
if (!t) return null
|
|
457
|
+
const c = current()
|
|
458
|
+
const f = c.source === 'cache' ? path.join(catDir, 'thumbs', `${id}.webp`) : path.join(seedDir, 'thumbs', `${id}.webp`)
|
|
459
|
+
try { const buf = fs.readFileSync(f); return sameMedia(t, buf) ? buf : null } catch { return null }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** 这些文件和目录里登记的一模一样吗(个数、路径、大小、sha256) */
|
|
463
|
+
function matches(e, files) {
|
|
464
|
+
const want = Array.isArray(e.files) ? e.files : []
|
|
465
|
+
if (!want.length || want.length !== files.length) return false
|
|
466
|
+
const by = new Map(files.map((f) => [f.path, f.data]))
|
|
467
|
+
return want.every((w) => {
|
|
468
|
+
const d = by.get(w.p)
|
|
469
|
+
return d && d.length === w.size && sha256(d) === w.sha256
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 批次包(缓存命中就不下载;fresh 时不用缓存)→ 这一套的文件和截图 */
|
|
474
|
+
async function packEntries(e, fresh = false) {
|
|
475
|
+
if (!e.pack || !VER_RE.test(String(e.pack.v || '')) || e.pack.path !== `d/${e.id}/${e.rev}/`) throw new MarketError('pack')
|
|
476
|
+
const c = current()
|
|
477
|
+
if (c.source === 'seed') {
|
|
478
|
+
const root = path.join(seedDir, 'd', e.id, e.rev)
|
|
479
|
+
const out = []
|
|
480
|
+
const walk = (dir, rel) => {
|
|
481
|
+
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
482
|
+
const r = rel ? `${rel}/${d.name}` : d.name
|
|
483
|
+
if (d.isDirectory()) walk(path.join(dir, d.name), r)
|
|
484
|
+
else if (d.isFile()) out.push({ path: r, data: fs.readFileSync(path.join(dir, d.name)) })
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
walk(root, '')
|
|
488
|
+
return out
|
|
489
|
+
}
|
|
490
|
+
const file = path.join(packDir, `${e.pack.v}.tgz`)
|
|
491
|
+
let buf = null
|
|
492
|
+
if (!fresh) try { buf = fs.readFileSync(file) } catch { buf = null }
|
|
493
|
+
const prefix = e.pack.path
|
|
494
|
+
const pick = (b) => untar(b).filter((f) => f.path.startsWith(prefix)).map((f) => ({ path: f.path.slice(prefix.length), data: f.data }))
|
|
495
|
+
if (!buf) {
|
|
496
|
+
if (!online()) throw new MarketError('off')
|
|
497
|
+
// 这一套的文件逐个核对过才算这个源成功(最快的源给了坏包,接着等别的源)
|
|
498
|
+
const { value } = await race(registries, async (reg, signal) => {
|
|
499
|
+
const b = await download(await lookup(reg, PACKS_PKG, e.pack.v, signal), MAX.packs, signal)
|
|
500
|
+
const all = pick(b)
|
|
501
|
+
if (!matches(e, all.filter((f) => !f.path.startsWith('_shots/')))) throw new MarketError('mismatch')
|
|
502
|
+
return { b, all }
|
|
503
|
+
}, { ...timing, total: Math.max(timing.total || 0, 60000), each: Math.max(timing.each || 0, 45000) })
|
|
504
|
+
writeAtomic(file, value.b)
|
|
505
|
+
prunePacks(file)
|
|
506
|
+
return value.all
|
|
507
|
+
}
|
|
508
|
+
try { const t = new Date(); fs.utimesSync(file, t, t) } catch { /* 只是 LRU 记号 */ }
|
|
509
|
+
return pick(buf)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** 批次包缓存超过 300MB:按最近用过的时间删旧的(刚用的那个不删) */
|
|
513
|
+
function prunePacks(keep) {
|
|
514
|
+
let list = []
|
|
515
|
+
try { list = fs.readdirSync(packDir).filter((f) => f.endsWith('.tgz')).map((f) => { const p = path.join(packDir, f); const st = fs.statSync(p); return { p, size: st.size, t: st.mtimeMs } }) } catch { return }
|
|
516
|
+
let total = list.reduce((n, x) => n + x.size, 0)
|
|
517
|
+
for (const x of list.sort((a, b) => a.t - b.t)) {
|
|
518
|
+
if (total <= PACKS_CAP) break
|
|
519
|
+
if (x.p === keep) continue
|
|
520
|
+
try { fs.rmSync(x.p, { force: true }); total -= x.size } catch { /* 下次再删 */ }
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* 准备一套(模板 / 设计系统):下载批次、逐个文件核对、解到 staging/<id>@<rev>/(截图在 _shots/)
|
|
526
|
+
* 已经解过、标记还对就直接用。返回 { entry, dir, files, shots }
|
|
527
|
+
*/
|
|
528
|
+
const staging = new Map()
|
|
529
|
+
function stage(id) {
|
|
530
|
+
const e = entry(id)
|
|
531
|
+
if (!e || e.type === 'style') return Promise.reject(new MarketError('gone'))
|
|
532
|
+
const key = `${e.id}@${e.rev}`
|
|
533
|
+
if (staging.has(key)) return staging.get(key)
|
|
534
|
+
const job = (async () => {
|
|
535
|
+
const dir = path.join(stageDir, key)
|
|
536
|
+
const mark = path.join(dir, '.ok')
|
|
537
|
+
const digest = String(e.digest || '')
|
|
538
|
+
const want = (Array.isArray(e.files) ? e.files : []).map((f) => f.p)
|
|
539
|
+
const read = () => want.map((p) => ({ path: p, data: fs.readFileSync(path.join(dir, p)) }))
|
|
540
|
+
if (fs.existsSync(mark) && fs.readFileSync(mark, 'utf8') === digest) {
|
|
541
|
+
try {
|
|
542
|
+
const files = read()
|
|
543
|
+
if (matches(e, files)) return { entry: e, dir, files, shots: shotList(dir, e) }
|
|
544
|
+
} catch { /* 坏了就重解 */ }
|
|
545
|
+
}
|
|
546
|
+
// 核对不过:缓存的那份删掉重下一次(坏包别一直留着);新下的也不对就报错,也不留
|
|
547
|
+
const seed = current().source === 'seed'
|
|
548
|
+
const cached = path.join(packDir, `${e.pack.v}.tgz`)
|
|
549
|
+
const hadCache = !seed && fs.existsSync(cached)
|
|
550
|
+
const take = async (fresh) => {
|
|
551
|
+
let all = null
|
|
552
|
+
try { all = await packEntries(e, fresh) } catch (er) { if (fresh || !hadCache || !(er instanceof MarketError) || er.code !== 'tar') throw er }
|
|
553
|
+
const files = all && all.filter((f) => !f.path.startsWith('_shots/'))
|
|
554
|
+
if (files && matches(e, files)) return { all, files }
|
|
555
|
+
if (!seed) try { fs.rmSync(cached, { force: true }) } catch { /* 算了 */ }
|
|
556
|
+
return null
|
|
557
|
+
}
|
|
558
|
+
const got = (await take(false)) || (hadCache ? await take(true) : null)
|
|
559
|
+
if (!got) throw new MarketError('mismatch')
|
|
560
|
+
// 下载的这会儿目录刷新了、这一套被下架或换了新版:不落地(下架时清掉的暂存也不再写回来)
|
|
561
|
+
const still = entry(id)
|
|
562
|
+
if (!still || still.rev !== e.rev) throw new MarketError('gone')
|
|
563
|
+
const { all, files } = got
|
|
564
|
+
// 截图也逐张核对(目录里登记了 sha256 和大小)
|
|
565
|
+
const wantShots = new Map(shotsOf(e).map((x) => [x.p, x]))
|
|
566
|
+
const shots = all.filter((f) => wantShots.has(f.path) && sameMedia(wantShots.get(f.path), f.data))
|
|
567
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
568
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
569
|
+
const real = fs.realpathSync(dir)
|
|
570
|
+
for (const f of [...files, ...shots]) {
|
|
571
|
+
const rel = cleanPath(f.path)
|
|
572
|
+
if (!rel) throw new MarketError('path')
|
|
573
|
+
const abs = path.resolve(real, rel)
|
|
574
|
+
if (!abs.startsWith(real + path.sep)) throw new MarketError('path')
|
|
575
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
576
|
+
fs.writeFileSync(abs, f.data)
|
|
577
|
+
}
|
|
578
|
+
fs.writeFileSync(mark, digest)
|
|
579
|
+
return { entry: e, dir: real, files, shots: shotList(real, e) }
|
|
580
|
+
})()
|
|
581
|
+
staging.set(key, job)
|
|
582
|
+
job.finally(() => staging.delete(key)).catch(() => {})
|
|
583
|
+
return job
|
|
584
|
+
}
|
|
585
|
+
// 暂存里的截图每次都按目录登记的大小、sha256 再核对(暂存目录被改过的不认)
|
|
586
|
+
const shotOk = (dir, x) => { try { return sameMedia(x, fs.readFileSync(path.join(dir, x.p))) } catch { return false } }
|
|
587
|
+
const shotList = (dir, e) => shotsOf(e).filter((x) => shotOk(dir, x)).map((x) => x.p)
|
|
588
|
+
|
|
589
|
+
/** 已经核对过的某张截图的字节(没解过、对不上返回 null) */
|
|
590
|
+
function shotFile(id, name) {
|
|
591
|
+
const e = entry(id)
|
|
592
|
+
const want = e && shotsOf(e).find((x) => x.p === name)
|
|
593
|
+
if (!want) return null
|
|
594
|
+
const dir = path.join(stageDir, `${e.id}@${e.rev}`)
|
|
595
|
+
try {
|
|
596
|
+
if (fs.readFileSync(path.join(dir, '.ok'), 'utf8') !== String(e.digest || '')) return null
|
|
597
|
+
const buf = fs.readFileSync(path.join(dir, name))
|
|
598
|
+
return sameMedia(want, buf) ? buf : null
|
|
599
|
+
} catch { return null }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** 这份 HTML 是市场上某一套的原样吗(预览时加硬规矩) */
|
|
603
|
+
let htmlSet = null
|
|
604
|
+
let htmlFor = null
|
|
605
|
+
function strictHtml(buf) {
|
|
606
|
+
const c = current()
|
|
607
|
+
if (!c) return false
|
|
608
|
+
if (htmlFor !== c) {
|
|
609
|
+
htmlSet = new Set()
|
|
610
|
+
for (const e of [...(c.index.designs || []), ...(c.index.systems || [])]) for (const f of (e && Array.isArray(e.files) ? e.files : [])) if (/\.html?$/i.test(String(f.p)) && typeof f.sha256 === 'string') htmlSet.add(f.sha256)
|
|
611
|
+
htmlFor = c
|
|
612
|
+
}
|
|
613
|
+
return htmlSet.size > 0 && htmlSet.has(sha256(buf))
|
|
614
|
+
}
|
|
615
|
+
const isStaging = (dir) => {
|
|
616
|
+
const r = path.resolve(String(dir || ''))
|
|
617
|
+
let base = stageDir
|
|
618
|
+
try { base = fs.realpathSync(stageDir) } catch { /* 还没有 */ }
|
|
619
|
+
return r === base || r.startsWith(base + path.sep) || r.startsWith(path.resolve(stageDir) + path.sep)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return { view, refresh, maybeRefresh, abort, entry, thumbFile, stage, shotFile, strictHtml, isStaging, state, url: (e) => e.submission || `${REPO_URL}/tree/main/${e.type === 'system' ? 'systems' : e.type === 'style' ? 'styles' : 'templates'}/${e.id}` }
|
|
623
|
+
}
|