dsh-plugin 1.1.0 → 1.1.1

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.
@@ -13,14 +13,18 @@ import { ProgressView } from './ProgressView.tsx'
13
13
  import { installCommandOf, pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
14
14
  import { classifyFailure } from '../lib/failures.ts'
15
15
 
16
- /** 完成结果视图:绿色对勾 + 标题/描述 + 「稍后重启 / 立即重启」按钮对(部分插件需重启后才会挂载) */
16
+ /** 完成结果视图:绿色对勾 + 标题/描述;
17
+ * needsRestart=true(插件需重启才生效)→ 「稍后重启 / 立即重启」按钮对,点稍后重启后
18
+ * 通知中心待重启条目常驻(服务端登记,内存态),直到用户点「立即重启」真正重启后才消失;
19
+ * needsRestart=false(卸载已即时生效)→ 仅「完成」关闭。 */
17
20
  function ResultView({
18
- title, desc, t, restarting, onRestart, onClose,
21
+ title, desc, t, restarting, needsRestart, onRestart, onClose,
19
22
  }: {
20
23
  title: string
21
24
  desc: string
22
25
  t: Translate
23
26
  restarting: boolean
27
+ needsRestart: boolean
24
28
  onRestart: () => void
25
29
  onClose: () => void
26
30
  }) {
@@ -43,12 +47,17 @@ function ResultView({
43
47
  ),
44
48
  h('div', { className: styles.resultTitle }, title),
45
49
  desc ? h('div', { className: styles.resultDesc }, desc) : null,
46
- h('div', { className: styles.resultRestarting }, restarting ? t('restarting') : t('restartHint')),
47
- h('div', { className: styles.modalActions },
48
- h('button', { className: styles.restartLater, onClick: onClose, disabled: restarting }, t('restartLater')),
49
- h('button', { className: styles.restartNow, onClick: onRestart, disabled: restarting },
50
- restarting ? t('restarting') : t('restartNow')),
51
- ),
50
+ needsRestart
51
+ ? h('div', null, [
52
+ h('div', { className: styles.resultRestarting }, restarting ? t('restarting') : t('restartHint')),
53
+ h('div', { className: styles.modalActions },
54
+ h('button', { className: styles.restartLater, onClick: onClose, disabled: restarting }, t('restartLater')),
55
+ h('button', { className: styles.restartNow, onClick: onRestart, disabled: restarting },
56
+ restarting ? t('restarting') : t('restartNow')),
57
+ ),
58
+ ])
59
+ : h('div', { className: styles.modalActions },
60
+ h('button', { className: styles.restartNow, onClick: onClose }, t('done'))),
52
61
  )
53
62
  }
54
63
 
@@ -61,8 +70,12 @@ export interface InstallModalProps {
61
70
  restarting: boolean
62
71
  /** 已安装插件的覆盖更新(走同一条 add 命令原位重装,文案区分安装/更新) */
63
72
  update: boolean
73
+ /** 仅命令行插件(webInstallable=false):不提供一键安装,只展示命令供复制到 dsh 终端 */
74
+ cliOnly: boolean
64
75
  /** 安装请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
65
76
  submitting: boolean
77
+ /** 完成结果是否需重启才生效:true → 「稍后重启 / 立即重启」;false → 仅「完成」 */
78
+ needsRestart: boolean
66
79
  onClose: () => void
67
80
  onCopy: () => void
68
81
  onInstall: () => void
@@ -74,7 +87,7 @@ export interface InstallModalProps {
74
87
  * 只在本任务执行中展示实时进度;完成后切换为结果视图,与卸载一致。
75
88
  */
76
89
  export function InstallModal(props: InstallModalProps) {
77
- const { plugin, done, task, t, langPath, restarting, submitting, update, onClose, onCopy, onInstall, onRestart } = props
90
+ const { plugin, done, task, t, langPath, restarting, submitting, update, cliOnly, needsRestart, onClose, onCopy, onInstall, onRestart } = props
78
91
  const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
79
92
  // 进行中标题带上插件名(中文「XX 插件安装中」;英文状态词在前更自然),并用状态色区分
80
93
  const name = plugin.displayName ?? plugin.slug
@@ -112,6 +125,7 @@ export function InstallModal(props: InstallModalProps) {
112
125
  desc: update ? t('updateResultDesc') : t('installResultDesc'),
113
126
  t,
114
127
  restarting,
128
+ needsRestart,
115
129
  onRestart,
116
130
  onClose,
117
131
  })
@@ -145,8 +159,12 @@ export function InstallModal(props: InstallModalProps) {
145
159
  h('span', { className: styles.modalCmdText }, installCommandOf(plugin)),
146
160
  h('span', { className: styles.modalCmdCopy }, h(CopyIcon), t('copyCmdLabel')),
147
161
  ),
162
+ // AI 识别可能需要命令行辅助的插件(webInstallable=false):提示但不拦截,仍允许尝试一键安装
163
+ cliOnly ? h('div', { className: styles.cliOnlyHint }, t('cliOnlyHint')) : null,
148
164
  task && task.status === 'pending' ? h('div', { className: styles.queuedHint }, t('queuedHint')) : null,
149
165
  task ? h(ProgressView, { task }) : null,
166
+ // 安装失败:引导用户复制上方命令到 dsh 终端手动安装(如 node-pty 等原生依赖构建脚本需人工放行)
167
+ task && task.status === 'failed' ? h('div', { className: styles.failedCopyHint }, t('failedCopyHint')) : null,
150
168
  h('div', { className: styles.modalActions },
151
169
  h('button', {
152
170
  className: styles.modalCopy,
@@ -177,6 +195,8 @@ export interface UninstallModalProps {
177
195
  restarting: boolean
178
196
  /** 卸载请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
179
197
  submitting: boolean
198
+ /** 完成结果是否需重启才生效:true → 「稍后重启 / 立即重启」;false(loader 已即时移除)→ 仅「完成」 */
199
+ needsRestart: boolean
180
200
  onClose: () => void
181
201
  onCancel: () => void
182
202
  onCopyCommand: () => void
@@ -186,7 +206,7 @@ export interface UninstallModalProps {
186
206
 
187
207
  /** 卸载确认弹窗:确认/进行中(后台队列,可关闭);完成后切换为结果视图(成功即生效,仅「完成」关闭)。 */
188
208
  export function UninstallModal(props: UninstallModalProps) {
189
- const { plugin, done, task, t, langPath, restarting, submitting, onClose, onCancel, onCopyCommand, onConfirm, onRestart } = props
209
+ const { plugin, done, task, t, langPath, restarting, submitting, needsRestart, onClose, onCancel, onCopyCommand, onConfirm, onRestart } = props
190
210
  const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
191
211
  // 进行中标题带上插件名,与安装弹窗一致,并用状态色区分
192
212
  const name = plugin.displayName ?? plugin.slug
@@ -221,6 +241,7 @@ export function UninstallModal(props: UninstallModalProps) {
221
241
  desc: t('uninstallResultDesc'),
222
242
  t,
223
243
  restarting,
244
+ needsRestart,
224
245
  onRestart,
225
246
  onClose,
226
247
  })
@@ -7,11 +7,14 @@
7
7
  */
8
8
  import { useEffect, useMemo, useState } from 'react'
9
9
  import type { HubPlugin, LocaleId } from '../types.ts'
10
- import { normalize } from '../lib/catalog.ts'
10
+ import { HUB_ABOUT_URL, HUB_REPO, HUB_UPDATE_URL, normalize, PLUGIN_VERSION } from '../lib/catalog.ts'
11
+ import type { HubAboutInfo, HubUpdateInfo } from '../lib/catalog.ts'
11
12
  import type { SortKey } from '../lib/catalog.ts'
12
13
 
13
14
  const PLUGINS_URL = (lang: LocaleId) => `https://dsh-plugin.org/api/plugins.${lang}.json`
14
15
  const STATS_URL = 'https://dsh-plugin.org/api/stats.json'
16
+ /** 插件市场自身仓库:DSH-Plugin Hub 不显示在目录里(自己不进自己的插件列表) */
17
+ const SELF_REPO = 'dshplugin/dsh-plugin-hub'
15
18
 
16
19
  export function useCatalog(lang: LocaleId) {
17
20
  /** 目录插件(仅保留人工验证通过的条目) */
@@ -30,6 +33,10 @@ export function useCatalog(lang: LocaleId) {
30
33
  /** 安装时记录的目录信号:repo(小写) -> { version, updatedAt }(来自宿主本地路由);
31
34
  * npmPackage 为 npm 优先通道反查命中的包名映射(目录数据未下发时客户端靠它把依赖 key 匹配回仓库) */
32
35
  const [versions, setVersions] = useState<Record<string, { version: string; updatedAt: string; npmPackage?: string }>>({})
36
+ /** Hub 自我更新信息:来自 CF Worker 版本控制中心(hub.dsh-plugin.org),与目录数据解耦 */
37
+ const [hubUpdateInfo, setHubUpdateInfo] = useState<HubUpdateInfo | null>(null)
38
+ /** 头部「关注我们」弹窗内容(平台介绍 + 反馈群二维码):来自同款 Worker /about,Markdown 推送非写死 */
39
+ const [hubAboutInfo, setHubAboutInfo] = useState<HubAboutInfo | null>(null)
33
40
 
34
41
  // 拉取目录 + 统计。在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
35
42
  useEffect(() => {
@@ -42,19 +49,29 @@ export function useCatalog(lang: LocaleId) {
42
49
  if (!res.ok) throw new Error(String(res.status))
43
50
  return res.json()
44
51
  })
45
- fetchData(PLUGINS_URL(lang)).then((data) => {
46
- if (cancelled) return
47
- const list = (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
48
- setPlugins(list.filter((p) => p.compatibility?.status === 'verified'))
49
- }).catch(() => {
50
- if (!cancelled) setFailed(true)
51
- })
52
- fetchData(STATS_URL).then((s) => {
53
- const stats = s as { total?: number; verified?: number }
54
- if (!cancelled && stats && typeof stats.total === 'number' && typeof stats.verified === 'number') {
55
- setStats({ total: stats.total, verified: stats.verified })
56
- }
57
- }).catch(() => {})
52
+ // 目录数据直拉:hub 自身版本由独立 Worker 版本控制中心管理,不再依赖主站版本号接口;
53
+ // 数据接口 CDN 缓存 1 小时,发布新数据后最长 1 小时全网生效。
54
+ const load = () =>
55
+ Promise.all([fetchData(PLUGINS_URL(lang)), fetchData(STATS_URL)])
56
+ load()
57
+ .then(([data, s]) => {
58
+ if (cancelled) return
59
+ const list = (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
60
+ // 插件市场不显示自己:DSH-Plugin Hub 从目录里排除自身条目,
61
+ // 防止「自己出现在自己的插件列表里、还能自己安装自己」;统计计数同步减 1 保持与列表一致。
62
+ // 主站数据构建侧已同步防止(registry/API 不再含自身),此处为已发布旧数据/CDN 缓存期的兜底。
63
+ const hadSelf = list.some((p) => p.source?.repo === SELF_REPO)
64
+ setPlugins(list.filter((p) => p.compatibility?.status === 'verified' && p.source?.repo !== SELF_REPO))
65
+ const stats = s as { total?: number; verified?: number }
66
+ if (stats && typeof stats.total === 'number' && typeof stats.verified === 'number') {
67
+ setStats(hadSelf
68
+ ? { total: stats.total - 1, verified: stats.verified - 1 }
69
+ : { total: stats.total, verified: stats.verified })
70
+ }
71
+ })
72
+ .catch(() => {
73
+ if (!cancelled) setFailed(true)
74
+ })
58
75
  return () => { cancelled = true }
59
76
  // lang 必须进依赖:切换界面语言要重新拉对应语言的数据文件,
60
77
  // 否则英文模式仍停留在 plugins.zh.json,描述/名称全是中文
@@ -76,6 +93,42 @@ export function useCatalog(lang: LocaleId) {
76
93
  // 首次进入拉取已安装表(依赖宿主 webServer 服务)
77
94
  useEffect(() => { refreshInstalled() }, [])
78
95
 
96
+ // Hub 自身版本检查走独立 Worker(版本控制中心):发新版后写一次 Worker KV,
97
+ // 所有已装用户立即看到「可更新」+ Markdown 变更记录,不再依赖主站目录数据管道。
98
+ // 失败静默降级为 null(徽标不出现,不影响目录本身加载)。
99
+ useEffect(() => {
100
+ let cancelled = false
101
+ fetch(HUB_UPDATE_URL, { cache: 'no-store' })
102
+ .then((res): Promise<HubUpdateInfo | null> => (res.ok ? res.json() : Promise.resolve(null)))
103
+ .then((data) => {
104
+ if (cancelled || !data) { if (!cancelled) setHubUpdateInfo(null); return }
105
+ const version = typeof data.version === 'string' && data.version.length > 0 ? data.version : null
106
+ setHubUpdateInfo(version === null
107
+ ? null
108
+ : { version, publishedAt: data.publishedAt ?? null, notes: data.notes ?? null })
109
+ })
110
+ .catch(() => { if (!cancelled) setHubUpdateInfo(null) })
111
+ return () => { cancelled = true }
112
+ }, [reloadKey])
113
+
114
+ // 「关注我们」内容走同一套 Worker:作者写好 Markdown(平台介绍 + 反馈群二维码)推送 /admin/about,
115
+ // 客户端下次进入拉 /about 即可看到最新,非写死。失败静默为 null(弹窗展示兜底文案,不阻塞)。
116
+ useEffect(() => {
117
+ let cancelled = false
118
+ fetch(HUB_ABOUT_URL, { cache: 'no-store' })
119
+ .then((res): Promise<HubAboutInfo | null> => (res.ok ? res.json() : Promise.resolve(null)))
120
+ .then((data) => {
121
+ if (cancelled) return
122
+ if (!data || data.content === null || data.content === undefined) { setHubAboutInfo(null); return }
123
+ const content = typeof data.content === 'string' || typeof data.content === 'object' ? data.content : null
124
+ setHubAboutInfo(content === null
125
+ ? null
126
+ : { content, updatedAt: data.updatedAt ?? null })
127
+ })
128
+ .catch(() => { if (!cancelled) setHubAboutInfo(null) })
129
+ return () => { cancelled = true }
130
+ }, [reloadKey])
131
+
79
132
  /** 插件是否已安装:匹配 installed spec 中的 `github:<owner>/<repo>`,或 npm 通道安装的依赖包名;命中返回 npm 包名。 */
80
133
  const installedName = (p: HubPlugin): string | null => {
81
134
  const repo = p.source?.repo
@@ -119,6 +172,18 @@ export function useCatalog(lang: LocaleId) {
119
172
  return Boolean(current && rec.updatedAt && current > rec.updatedAt)
120
173
  }
121
174
 
175
+ /**
176
+ * Hub 自身是否有可用更新:Hub 就是当前运行的应用(始终已安装,无需 installedName 命中)。
177
+ * 以 Worker 版本控制中心返回的「最新版本」为准,与当前运行的版本号比对——
178
+ * 构建时注入的 PLUGIN_VERSION 是运行 bundle 的真实版本;测试/异常场景缺失时
179
+ * 回退到安装时记录的版本。版本号不等即新版(版本号只在发版时变更,绝无降级场景)。
180
+ */
181
+ const hubHasUpdate = (() => {
182
+ if (!hubUpdateInfo) return false
183
+ const current = PLUGIN_VERSION || versions[HUB_REPO]?.version || null
184
+ return current !== null && hubUpdateInfo.version !== current
185
+ })()
186
+
122
187
  /** 当前分类下的插件(「全部」时为整个目录)。 */
123
188
  const categoryPlugins = useMemo(() => {
124
189
  if (!plugins) return []
@@ -184,6 +249,9 @@ export function useCatalog(lang: LocaleId) {
184
249
  installedName,
185
250
  installedVersion,
186
251
  hasUpdate,
252
+ hubHasUpdate,
253
+ hubUpdateInfo,
254
+ hubAboutInfo,
187
255
  refreshInstalled,
188
256
  category,
189
257
  setCategory,
@@ -33,6 +33,10 @@ export interface QueueTask {
33
33
  command?: string
34
34
  /** 尝试过的安装方式(npm registry 反查 + 实际执行命令,按先后顺序):失败时 issue 预填一并贴给作者 */
35
35
  attempts?: string[]
36
+ /** 完成结果是否需要重启才生效(服务端任务终态带出;默认 true 保持老行为:弹窗给重启选项)。
37
+ * 卸载时 loader 已即时移除 → false,结果视图只显示「完成」;true 时弹窗给「稍后重启/立即重启」,
38
+ * 通知中心待重启条目(服务端登记)同步常驻,直到用户点「立即重启」。 */
39
+ needsRestart: boolean
36
40
  }
37
41
 
38
42
  /** 客户端待重启项:镜像服务端内存列表 + 本地补齐的展示信息(简介/版本)。 */
@@ -49,9 +53,10 @@ export interface PendingRestart {
49
53
  export interface TaskQueueOptions {
50
54
  t: Translate
51
55
  refreshInstalled: () => void
52
- /** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo 供成功通知记录。 */
53
- onInstallDone: (viaModal: boolean, repo: string | null) => void
54
- onUninstallDone: (viaModal: boolean, repo: string | null) => void
56
+ /** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo 供成功通知记录;
57
+ * needsRestart 表示该任务完成后是否仍需宿主重启(结果视图据此给「重启 / 仅完成」)。 */
58
+ onInstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean) => void
59
+ onUninstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean) => void
55
60
  /** 任务失败:完整输出或兜底文案 + 所属插件仓库 + 操作类型(安装/卸载)+ 实际执行的安装命令(issue 预填用,可缺省)+ 尝试过的安装方式(npm 反查/执行命令,可缺省)。 */
56
61
  onError: (message: string, repo: string | null, kind: 'install' | 'uninstall', command?: string, attempts?: string[]) => void
57
62
  /** 当前打开的安装/卸载弹窗插件:用于在弹窗内匹配进行中的任务。 */
@@ -168,13 +173,13 @@ export function useTaskQueue(opts: TaskQueueOptions) {
168
173
  if (q.kind === 'uninstall') {
169
174
  // 卸载成功:清掉本地版本记录
170
175
  void syncInstalledVersion(q.repo, undefined, undefined)
171
- onUninstallDone(modalTaskRef.current === q.id, q.repo)
176
+ onUninstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart)
172
177
  } else {
173
178
  // 安装成功:记录安装时的目录信号(版本 + 仓库更新时间),供「有更新」比对;
174
179
  // 同时缓存展示信息,待重启列表(服务端已登记)合并时直接补齐简介/版本
175
180
  void syncInstalledVersion(q.repo, q.version, q.updatedAt)
176
181
  pendingInfoRef.current.set(q.target, { desc: q.desc, version: q.version })
177
- onInstallDone(modalTaskRef.current === q.id, q.repo)
182
+ onInstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart)
178
183
  }
179
184
  } else {
180
185
  // 失败:完整展示全部输出行(最新在前,逆序为日志阅读顺序),不裁剪
@@ -220,25 +225,31 @@ export function useTaskQueue(opts: TaskQueueOptions) {
220
225
  try {
221
226
  const res = await fetch(`/dsh-plugin-hub/status?task=${q.id}`, { cache: 'no-store' })
222
227
  if (res.ok) {
223
- const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[] } }
224
- status = data.task?.status ?? 'failed'
225
- lines = data.task?.lines ?? []
226
- attempts = Array.isArray(data.task?.attempts) ? data.task.attempts : undefined
228
+ const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[]; needsRestart?: unknown } }
229
+ const task = data.task
230
+ if (task !== undefined) {
231
+ status = task.status ?? 'failed'
232
+ lines = task.lines ?? []
233
+ attempts = Array.isArray(task.attempts) ? task.attempts : undefined
234
+ // 终态带出的「是否需要重启」覆盖本地(服务端 done 时按 loader 即时移除情况设置)
235
+ const needsRestart = typeof task.needsRestart === 'boolean' ? task.needsRestart : q.needsRestart
236
+ if (status === 'done') {
237
+ const settled = { ...q, needsRestart }
238
+ // 卸载成功走进度过渡动画;安装成功直接收尾
239
+ if (q.kind === 'uninstall') settleDone(settled, lines)
240
+ else finishQueueTask(true, settled, lines)
241
+ return
242
+ }
243
+ if (status === 'failed') {
244
+ // 服务端终态带出的尝试记录覆盖本地:以实际执行为准
245
+ finishQueueTask(false, { ...q, attempts }, lines)
246
+ return
247
+ }
248
+ }
227
249
  }
228
250
  } catch { /* 服务端不可达:按失败处理 */ }
229
- if (status === 'done') {
230
- // 卸载成功走进度过渡动画;安装成功直接收尾
231
- if (q.kind === 'uninstall') settleDone(q, lines)
232
- else finishQueueTask(true, q, lines)
233
- }
234
- else if (status === 'failed') {
235
- // 服务端终态带出的尝试记录覆盖本地:以实际执行为准
236
- finishQueueTask(false, { ...q, attempts }, lines)
237
- }
238
- else {
239
- applyQueue((prev) => prev.filter((x) => x.id !== q.id)) // cancelled / 未知
240
- maybeStopPoll()
241
- }
251
+ applyQueue((prev) => prev.filter((x) => x.id !== q.id)) // cancelled / 未知
252
+ maybeStopPoll()
242
253
  }
243
254
 
244
255
  /** 轮询整个队列:合并服务端 /active(running 在前、pending 在后),
@@ -250,7 +261,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
250
261
  const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
251
262
  if (!res.ok) throw new Error(`active ${res.status}`)
252
263
  const data = await res.json() as {
253
- tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown }[]
264
+ tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown; needsRestart?: unknown }[]
254
265
  pendingRestarts?: unknown
255
266
  }
256
267
  const active = (data.tasks ?? []).filter((a) => typeof a.id === 'number')
@@ -279,6 +290,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
279
290
  progress: typeof a.progress === 'number' ? a.progress : prevTask?.progress ?? 0,
280
291
  lines: Array.isArray(a.lines) ? (a.lines as string[]) : (prevTask?.lines ?? []),
281
292
  attempts: Array.isArray(a.attempts) ? (a.attempts as string[]) : (prevTask?.attempts ?? []),
293
+ needsRestart: typeof a.needsRestart === 'boolean' ? a.needsRestart : (prevTask?.needsRestart ?? true),
282
294
  })
283
295
  }
284
296
  // 保留乐观条目:服务端尚未登记(preflight 中),无真实 id,不能因 active 缺它就被合并吞掉
@@ -319,7 +331,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
319
331
  const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
320
332
  if (!res.ok) return
321
333
  const data = await res.json() as {
322
- tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown }[]
334
+ tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown; needsRestart?: unknown }[]
323
335
  pendingRestarts?: unknown
324
336
  }
325
337
  if (cancelled) return
@@ -343,6 +355,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
343
355
  progress: typeof a.progress === 'number' ? a.progress : 0,
344
356
  lines: Array.isArray(a.lines) ? (a.lines as string[]) : [],
345
357
  attempts: Array.isArray((a as { attempts?: unknown }).attempts) ? ((a as { attempts: string[] }).attempts) : [],
358
+ needsRestart: typeof (a as { needsRestart?: unknown }).needsRestart === 'boolean' ? (a as { needsRestart: boolean }).needsRestart : true,
346
359
  }
347
360
  })
348
361
  if (items.length > 0 || pendingRef.current.length > 0) {
@@ -382,7 +395,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
382
395
  status: 'running', progress: 0, lines: [], optimistic: true,
383
396
  command: installCommandOf(p, true),
384
397
  // 服务端登记后 /active 会带回真实尝试记录(npm 反查 + 执行命令),先占位空列表
385
- attempts: [],
398
+ attempts: [], needsRestart: true,
386
399
  }])
387
400
  setSubmitting(true)
388
401
  try {
@@ -442,7 +455,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
442
455
  }
443
456
  const taskId = data.task
444
457
  modalTaskRef.current = taskId
445
- applyQueue((prev) => [...prev, { id: taskId, kind: 'uninstall', target: name, desc: p.description, repo, status: 'pending', progress: 0, lines: [] }])
458
+ applyQueue((prev) => [...prev, { id: taskId, kind: 'uninstall', target: name, desc: p.description, repo, status: 'pending', progress: 0, lines: [], needsRestart: true }])
446
459
  // 摘要条常驻顶部(含实时进度),明细面板保持折叠,想看时再点开
447
460
  pollQueue()
448
461
  } catch {
@@ -11,6 +11,36 @@ export const SITE_URL = 'https://dsh-plugin.org/'
11
11
  /** 插件中心源码仓库:头部右上角 GitHub 图标的跳转地址 */
12
12
  export const GITHUB_URL = 'https://github.com/dshplugin/dsh-plugin-hub'
13
13
 
14
+ /** Hub 自身在目录数据里的仓库标识:头部版本号旁的「可更新」徽标按它定位自己。 */
15
+ export const HUB_REPO = 'dshplugin/dsh-plugin-hub'
16
+
17
+ /**
18
+ * Hub 自我更新版本控制中心(Cloudflare Worker:dsh-update)。
19
+ * 发新版后向 Worker 管理接口写入最新版本 + Markdown 变更记录(见
20
+ * dsh-update-worker 的 POST /admin/hub),所有已装用户的「可更新」徽标即时出现,
21
+ * 不再依赖主站目录数据管道(每日刷新 + 重建站点)。
22
+ * 响应:{ version: string | null, publishedAt: string | null, notes: string | {zh,en} | null }
23
+ */
24
+ export const HUB_UPDATE_URL = 'https://hub.dsh-plugin.org/hub'
25
+
26
+ /** Worker 版本控制中心返回的 Hub 自我更新信息(notes 为 Markdown 变更记录)。 */
27
+ export interface HubUpdateInfo {
28
+ version: string
29
+ publishedAt?: string | null
30
+ /** Markdown 变更记录;可传字符串,或 { zh, en } 双语言对象按界面语言取 */
31
+ notes?: string | Record<string, string> | null
32
+ }
33
+
34
+ /** Worker「关注我们」内容接口(同款 Worker,key = hub:about,Markdown 推送非写死)。 */
35
+ export const HUB_ABOUT_URL = 'https://hub.dsh-plugin.org/about'
36
+
37
+ /** Worker 返回的「关注我们」内容(content 为 Markdown,可字符串或 {zh,en} 双语言对象)。 */
38
+ export interface HubAboutInfo {
39
+ /** Markdown 平台介绍 + 反馈群二维码;可传字符串,或 { zh, en } 双语言对象按界面语言取 */
40
+ content?: string | Record<string, string> | null
41
+ updatedAt?: string | null
42
+ }
43
+
14
44
  /** 构建时由 tsdown 从 package.json 注入的插件版本号(见 tsdown.config.ts define)。 */
15
45
  declare const __PLUGIN_VERSION__: string
16
46
 
@@ -173,6 +203,8 @@ export function normalize(raw: Record<string, unknown>): HubPlugin {
173
203
  npmPackage: typeof (raw.r as { npmPackage?: unknown }).npmPackage === 'string' ? (raw.r as { npmPackage: string }).npmPackage : undefined,
174
204
  }
175
205
  : undefined,
206
+ // 能否网页一键安装:API 仅在 false 时下发 wi(缺省视为 true),据此禁用一键安装、只提示命令行
207
+ install: raw.wi === false ? { webInstallable: false } : undefined,
176
208
  compatibility: typeof raw.v === 'string' ? { status: raw.v } : undefined,
177
209
  dates: {
178
210
  repoUpdatedAt: typeof raw.u === 'string' ? raw.u : undefined,
@@ -0,0 +1,116 @@
1
+ /**
2
+ * 轻量 Markdown 渲染器:仅用于 Hub 自我更新的变更记录(Worker 下发的 notes)。
3
+ *
4
+ * 内容由插件作者自己编写、属可信内容,但渲染端仍先整体 HTML 转义,再按
5
+ * Markdown 语法输出结构,杜绝任何未转义的原始 HTML 注入;链接仅放行 http(s)。
6
+ *
7
+ * 支持:标题(#~####)、有序/无序列表、代码块(```)、行内代码、加粗、斜体、
8
+ * 链接、图片(![alt](url),如反馈群二维码)、引用(>)、段落;其余语法一律按
9
+ * 纯文本处理,不引入外部依赖。图片仅放行 http(s),限定最大宽度不撑破弹窗。
10
+ */
11
+
12
+ /** HTML 转义:所有文本先进这里,再进入结构变换,保证输出里没有未转义的用户输入。 */
13
+ function escapeHtml(s: string): string {
14
+ return s
15
+ .replace(/&/g, '&amp;')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&#39;')
20
+ }
21
+
22
+ /** 行内变换:链接 / 加粗 / 斜体 / 行内代码(代码先占位保护,最后还原)。 */
23
+ function inline(s: string): string {
24
+ let out = escapeHtml(s)
25
+ // 行内代码先占位:避免其中的 * [ ] 被后续链接/加粗/斜体规则破坏
26
+ const codeSpans: string[] = []
27
+ out = out.replace(/`([^`]+)`/g, (_m, code) => {
28
+ codeSpans.push(String(code))
29
+ return `\u0000${codeSpans.length - 1}\u0000`
30
+ })
31
+ // 图片 ![alt](url):仅放行 http(s),用于展示反馈群二维码等;limited by CSS
32
+ out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) =>
33
+ /^https?:\/\//i.test(String(url)) && !/[\u0000]/.test(String(url))
34
+ ? `<img src="${String(url)}" alt="${String(alt)}" loading="lazy">`
35
+ : m,
36
+ )
37
+ // 链接 [text](url):仅放行 http(s),新窗口打开
38
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, url) =>
39
+ /^https?:\/\//i.test(String(url)) && !/[\u0000]/.test(String(url))
40
+ ? `<a href="${String(url)}" target="_blank" rel="noopener noreferrer">${String(text)}</a>`
41
+ : m,
42
+ )
43
+ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
44
+ out = out.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
45
+ return out.replace(/\u0000(\d+)\u0000/g, (_m, idx) => `<code>${codeSpans[Number(idx)]}</code>`)
46
+ }
47
+
48
+ /** 把变更记录 Markdown 渲染为安全的 HTML 字符串(由弹窗用 dangerouslySetInnerHTML 注入)。 */
49
+ export function renderMarkdown(md: string): string {
50
+ const lines = md.replace(/\r\n?/g, '\n').split('\n')
51
+ const blocks: string[] = []
52
+ let i = 0
53
+ while (i < lines.length) {
54
+ const line = lines[i]
55
+ // 围栏代码块
56
+ if (/^\s*```/.test(line)) {
57
+ const buf: string[] = []
58
+ i++
59
+ while (i < lines.length && !/^\s*```/.test(lines[i])) {
60
+ buf.push(lines[i])
61
+ i++
62
+ }
63
+ i++ // 跳过闭合围栏
64
+ blocks.push(`<pre><code>${escapeHtml(buf.join('\n'))}</code></pre>`)
65
+ continue
66
+ }
67
+ // 空行:段落分隔符
68
+ if (line.trim() === '') {
69
+ i++
70
+ continue
71
+ }
72
+ // 标题:# 到 #### 映射为 h2~h5(弹窗标题用 modalTitle,不与其冲突)
73
+ const heading = line.match(/^(#{1,4})\s+(.*)/)
74
+ if (heading) {
75
+ const level = heading[1].length + 1
76
+ blocks.push(`<h${level}>${inline(heading[2])}</h${level}>`)
77
+ i++
78
+ continue
79
+ }
80
+ // 有序 / 无序列表(连续收集为一个列表块)
81
+ if (/^\s*[-*+]\s+/.test(line) || /^\s*\d+[.)]\s+/.test(line)) {
82
+ const ordered = /^\s*\d+[.)]\s+/.test(line)
83
+ const items: string[] = []
84
+ while (i < lines.length && (/^\s*[-*+]\s+/.test(lines[i]) || /^\s*\d+[.)]\s+/.test(lines[i]))) {
85
+ items.push(`<li>${inline(lines[i].replace(/^\s*[-*+]|\s*\d+[.)]\s*/, '').trim())}</li>`)
86
+ i++
87
+ }
88
+ blocks.push(`<${ordered ? 'ol' : 'ul'}>${items.join('')}</${ordered ? 'ol' : 'ul'}>`)
89
+ continue
90
+ }
91
+ // 引用
92
+ if (/^\s*>\s?/.test(line)) {
93
+ const buf: string[] = []
94
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
95
+ buf.push(inline(lines[i].replace(/^\s*>\s?/, '')))
96
+ i++
97
+ }
98
+ blocks.push(`<blockquote>${buf.join('<br>')}</blockquote>`)
99
+ continue
100
+ }
101
+ // 普通段落:收集连续非空、非特殊开头的行
102
+ const buf: string[] = []
103
+ while (
104
+ i < lines.length
105
+ && lines[i].trim() !== ''
106
+ && !/^\s*```/.test(lines[i])
107
+ && !/^#{1,4}\s+/.test(lines[i])
108
+ && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i])
109
+ ) {
110
+ buf.push(inline(lines[i]))
111
+ i++
112
+ }
113
+ blocks.push(`<p>${buf.join('<br>')}</p>`)
114
+ }
115
+ return blocks.join('')
116
+ }
@@ -21,6 +21,9 @@ export const zh = {
21
21
  failedDesc: '请稍后重试,或点击右上角按钮在浏览器中打开插件中心。',
22
22
  retry: '重试',
23
23
  install: '安装',
24
+ // AI 识别可能需要命令行辅助的插件(webInstallable=false):仍可尝试一键安装,失败时复制命令去 dsh 终端
25
+ cliOnlyHint: '此插件可能需命令行辅助,失败时复制上方命令到 dsh 终端',
26
+ failedCopyHint: '安装失败。请复制上方安装命令到 dsh 终端手动执行(原生依赖构建脚本可能需要人工放行,如 pnpm approve-builds)',
24
27
  toastCopied: '安装命令已复制,去 dsh 终端粘贴即可安装',
25
28
  installed: '已安装',
26
29
  notInstalled: '未安装',
@@ -65,6 +68,7 @@ export const zh = {
65
68
  restartLater: '稍后重启',
66
69
  restarting: '正在重启…',
67
70
  restartHint: '部分插件(如插件市场、顶层 bundle)需重启后才会生效。',
71
+ done: '完成',
68
72
  // 待重启:装完没重启的插件常驻提醒,直到宿主真正重启
69
73
  restartPendingHint: '安装成功,重启后生效',
70
74
  restartPendingHintUninstall: '卸载成功,重启后移除',
@@ -120,6 +124,22 @@ export const zh = {
120
124
  update: '可更新',
121
125
  updateAvailable: '有更新',
122
126
  updateAvailableHint: '检测到更新:有版本号的仓库比对版本号,无版本号的仓库比对仓库最近更新时间',
127
+ hubUpdateHint: '检测到 DSH-Plugin Hub 新版本,点击查看更新内容并更新',
128
+ hubUpdateTitle: 'DSH-Plugin Hub 有新版本',
129
+ hubUpdateDesc: 'v{version} 已发布,点击下方按钮即可更新,完成后需重启宿主生效。',
130
+ hubUpdatePublished: '发布于',
131
+ hubUpdateNotes: '更新记录',
132
+ hubUpdateLater: '稍后再说',
133
+ versionHint: '查看版本信息与更新记录',
134
+ hubCurrentTitle: '当前版本',
135
+ hubCurrentDesc: '你正在使用 v{version},已是最新版本。',
136
+ hubUpToDate: '已是最新版本',
137
+ // 头部「关注我们」弹窗:平台介绍 + 用户反馈群二维码(内容由 Worker /about Markdown 推送,非写死)
138
+ followUs: '关注我们',
139
+ aboutTitle: '关注我们',
140
+ aboutDesc: '了解 DSH-Plugin Hub 是做什么的,加入用户反馈群随时交流。',
141
+ aboutEmpty: '内容暂未发布,敬请期待。',
142
+ aboutUpdated: '更新于',
123
143
  empty: '该分类暂无插件',
124
144
  dataFrom: '数据源 dsh-plugin.org · 每日人工更新',
125
145
  }
@@ -146,6 +166,10 @@ export const en = {
146
166
  failedDesc: 'Please retry, or open the hub in your browser with the button above.',
147
167
  retry: 'Retry',
148
168
  install: 'Install',
169
+ // AI-flagged plugins that may need command-line help (webInstallable=false): one-click install still allowed,
170
+ // on failure the user is guided to copy the command into the dsh terminal
171
+ cliOnlyHint: 'This plugin may need command-line help; if it fails, copy the command above and run it in your dsh terminal',
172
+ failedCopyHint: 'Install failed. Copy the command above and run it manually in your dsh terminal (native build scripts may need manual approval, e.g. pnpm approve-builds)',
149
173
  toastCopied: 'Install command copied — paste it in your dsh terminal',
150
174
  installed: 'Installed',
151
175
  notInstalled: 'Not installed',
@@ -190,6 +214,7 @@ export const en = {
190
214
  restartLater: 'Later',
191
215
  restarting: 'Restarting…',
192
216
  restartHint: 'Some plugins (such as plugin markets / top-level bundles) only take effect after a restart.',
217
+ done: 'Done',
193
218
  restartPendingHint: 'Installed — takes effect after restart',
194
219
  restartPendingHintUninstall: 'Removed — cleaned up after restart',
195
220
  sectionPendingRestart: 'Pending restart',
@@ -243,6 +268,22 @@ export const en = {
243
268
  update: 'Update',
244
269
  updateAvailable: 'Update available',
245
270
  updateAvailableHint: 'An update was detected: repos with a release are compared by tag, repos without one by last push time',
271
+ hubUpdateHint: 'A new DSH-Plugin Hub version is available — click to view the changelog and update',
272
+ hubUpdateTitle: 'A new DSH-Plugin Hub version is available',
273
+ hubUpdateDesc: 'v{version} has been released — click below to update, then restart the host to apply it.',
274
+ hubUpdatePublished: 'Published',
275
+ hubUpdateNotes: "What's new",
276
+ hubUpdateLater: 'Later',
277
+ versionHint: 'View version info and changelog',
278
+ hubCurrentTitle: 'Current version',
279
+ hubCurrentDesc: 'You are on v{version} — this is the latest version.',
280
+ hubUpToDate: 'Up to date',
281
+ // Header "Follow us" modal: platform intro + user feedback group QR (content pushed via Worker /about Markdown)
282
+ followUs: 'Follow us',
283
+ aboutTitle: 'Follow us',
284
+ aboutDesc: 'Learn what DSH-Plugin Hub does and join our user feedback group.',
285
+ aboutEmpty: 'Content not published yet — stay tuned.',
286
+ aboutUpdated: 'Updated',
246
287
  empty: 'No plugins in this category yet',
247
288
  dataFrom: 'Data from dsh-plugin.org · curated daily',
248
289
  }