dshmarket 1.66.35 → 1.66.37
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.md +1 -1
- package/README.zh.md +1 -1
- package/client/client.js +19 -4
- package/lib/official-desktop.js +29 -0
- package/lib/restart.js +41 -0
- package/lib/routes.js +11 -3
- package/lib/types/restart.d.ts +13 -0
- package/package.json +1 -1
- package/src/client/MarketSection.tsx +21 -2
- package/src/client/SettingsCard.tsx +16 -7
- package/src/client/locales.ts +2 -0
- package/src/official-desktop.ts +30 -0
- package/src/restart.ts +42 -0
- package/src/routes.ts +11 -3
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ its own dsh: it may be older than the one `npm` would give you (#139).
|
|
|
46
46
|
- **Screenshots** — AppStore-style screenshots, auto-carousel when there's more than one, click to preview full-size: author-curated shots show right on the card (zero extra requests); plugins without curated shots fall back to automatic README extraction once you open the install dialog. Images load from GitHub hosting only
|
|
47
47
|
- **Comments** — every card opens the plugin's discussion thread in place. It is the same thread its pages on [dshmarket.com](https://dshmarket.com) and the [catalog](https://awesome-dsh-plugin.com) show, so a plugin has one conversation rather than three. Backed by GitHub Discussions through giscus: it loads when you open it, needs a GitHub account only to post, and the note above it says plainly that opening it contacts giscus.app and GitHub. On local dsh web, reading stays embedded while a dedicated GitHub action opens the exact discussion in a new tab for sign-in and posting, so the cross-site return never carries or depends on the host session
|
|
48
48
|
- **Favorites** — bookmark plugins and themes from Discover or the Themes tab; a dedicated Favorites tab lists them with search, sort, and install actions. Bookmarks persist in the profile's market state (`state.json`); entries that leave the catalog can be cleared in one click
|
|
49
|
-
- **Groups** — organise installed plugins into named groups (rename, delete, and per-group search); a plugin lives in one group and ungrouped is the default.
|
|
49
|
+
- **Groups** — organise installed plugins into named groups (rename, delete, and per-group search); a plugin lives in one group and ungrouped is the default. A group carries its own switch: all-on, all-off, or the mixed state its members are really in, so a working set can be switched as a unit without touching the rest. A group holds at most one theme. The grouping itself is local state in the profile's `state.json`
|
|
50
50
|
- **Notes** — write your own one-line description for any installed plugin and it replaces the author's on that row, so a shelf of forty plugins answers "why did I install this" in your words. Stored locally beside groups and favorites, never sent anywhere
|
|
51
51
|
- **Themes** — a dedicated tab for community themes and skins: install → active immediately, switch with one click (themes are mutually exclusive, your choice survives restarts), uninstall to revert
|
|
52
52
|
- **One-click install** — confirm the source, watch live progress; most plugins go live after a page refresh, no restart
|
package/README.zh.md
CHANGED
|
@@ -41,7 +41,7 @@ dsh plugin --profile web add dshmarket
|
|
|
41
41
|
- **截图展示**——App Store 式截图,多图自动轮播,点开还能看大图;作者在 registry 里策展的截图列表卡片就直接显示(零额外请求),没有策展的插件则在打开安装弹窗时自动从 README 抽取;图片仅从 GitHub 图床加载
|
|
42
42
|
- **评论**——每张卡片都能就地打开该插件的讨论。它和插件在 [dshmarket.com](https://dshmarket.com) 与[目录站](https://awesome-dsh-plugin.com)上的页面共用同一条讨论,一个插件只有一处对话,而不是三处。底层是 GitHub Discussions(经由 giscus):打开即加载,只有发表评论才需要 GitHub 账号;说明里也直说打开会连接 giscus.app 与 GitHub。本地 dsh web 继续内嵌评论供阅读,登录和发表则通过专用 GitHub 入口在新标签页打开当前插件的精确讨论,不让跨站回跳携带或依赖宿主会话
|
|
43
43
|
- **收藏**——在「发现」或「主题」页点书签即可收藏插件/主题;「收藏」Tab 集中展示,支持搜索、排序与安装。书签写入 profile 的市场状态(`state.json`);已下架条目可一键清除
|
|
44
|
-
-
|
|
44
|
+
- **分组**——把已安装插件整理进具名分组(可重命名、删除,分组内单独搜索);一个插件属于一个分组,默认是「未分组」。每个分组自带开关:全开、全关,或成员当前真实处于的「混合」状态——一组一起开合,不动其余插件。一个分组最多容纳一个主题。分组本身是 profile `state.json` 里的本地状态
|
|
45
45
|
- **备注**——给任何已安装插件写一行你自己的说明,它会在这行**代替作者简介**显示——四十个插件的列表因此能用你自己的话回答「我当初为什么装它」。和分组、收藏一样只存在本地,不上传
|
|
46
46
|
- **主题**——独立主题页:装完立即生效,点一下切换(主题互斥、选择跨重启保留),卸载即恢复
|
|
47
47
|
- **一键安装**——确认来源,实时进度;多数插件刷新页面即可用,无需重启
|
package/client/client.js
CHANGED
|
@@ -243,6 +243,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
243
243
|
setChannelSwitchHint: "该版本低于当前已安装版本。",
|
|
244
244
|
setSelfRemove: "卸载插件市场",
|
|
245
245
|
setSelfRemoveHint: "从这个 profile 卸载市场。已装的其它插件不受影响。",
|
|
246
|
+
setSelfRemoveRemote: "这个页面不是从本机打开的,市场没法从这里卸载自己。请到运行 DSH 的那台机器上移除:桌面端用「设置 → 插件」,其它环境用命令行。",
|
|
246
247
|
setSelfConfirm: "确定要卸载插件市场吗?卸载后需使用命令行重新安装。",
|
|
247
248
|
setSelfPurge: "同时清除市场自己的数据",
|
|
248
249
|
setSelfPurgeOn: "将删除:市场的停用清单与自定义分组,以及它写进配置文件的停用记录——被停用的插件将恢复运行。不涉及密码或密钥,市场从不把它们存在磁盘上。",
|
|
@@ -980,6 +981,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
980
981
|
setChannelSwitchHint: "This version is lower than the one installed.",
|
|
981
982
|
setSelfRemove: "Uninstall the plugin market",
|
|
982
983
|
setSelfRemoveHint: "Uninstall the market from this profile. Your other plugins are untouched.",
|
|
984
|
+
setSelfRemoveRemote: "This page was not opened on the machine running DSH, so the market cannot remove itself from here. Remove it there: Settings → Plugins on the desktop app, or the command line elsewhere.",
|
|
983
985
|
setSelfConfirm: "Uninstall the plugin market? Reinstalling it requires the command line.",
|
|
984
986
|
setSelfPurge: "Also clear the market's own data",
|
|
985
987
|
setSelfPurgeOn: "Will delete: the market's disable list and custom groups, plus the disable entries it wrote into your config file — plugins it switched off will run again. No passwords or tokens are involved; the market never stores those on disk.",
|
|
@@ -9418,7 +9420,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9418
9420
|
const statusInstalled = installedMap(status.installed);
|
|
9419
9421
|
if (!sameInstalledMap(installed, statusInstalled)) refreshInstalled();
|
|
9420
9422
|
if (readSession("dshm-pending") !== null && busyUrl !== null && status.busy !== true) {
|
|
9421
|
-
|
|
9423
|
+
const url = busyUrl;
|
|
9424
|
+
if (data !== null && data.plugins.some((p) => p.url === url && isInstalled(p, statusInstalled, repoIdentities, data.plugins, repoHints))) {
|
|
9422
9425
|
idleStrikes.current = 0;
|
|
9423
9426
|
sessionStorage.removeItem("dshm-pending");
|
|
9424
9427
|
const recovered = recoveredInstall.current;
|
|
@@ -9426,7 +9429,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9426
9429
|
setRecords((list) => drop(list, recovered.id));
|
|
9427
9430
|
recoveredInstall.current = null;
|
|
9428
9431
|
}
|
|
9429
|
-
|
|
9432
|
+
setRecords((list) => list.map((record) => record.kind === "install" && record.url === url && record.state === "running" ? {
|
|
9433
|
+
...record,
|
|
9434
|
+
state: "done",
|
|
9435
|
+
needsRefresh: true
|
|
9436
|
+
} : record));
|
|
9437
|
+
setDoneUrls((urls) => urls.includes(url) ? urls : urls.concat(url));
|
|
9430
9438
|
setBusyUrl(null);
|
|
9431
9439
|
} else if (++idleStrikes.current >= 2) {
|
|
9432
9440
|
idleStrikes.current = 0;
|
|
@@ -9436,6 +9444,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
9436
9444
|
setRecords((list) => drop(list, recovered.id));
|
|
9437
9445
|
recoveredInstall.current = null;
|
|
9438
9446
|
}
|
|
9447
|
+
setRecords((list) => list.map((record) => record.kind === "install" && record.url === url && record.state === "running" ? {
|
|
9448
|
+
...record,
|
|
9449
|
+
state: "failed",
|
|
9450
|
+
reason: t("installFail")
|
|
9451
|
+
} : record));
|
|
9439
9452
|
setBusyUrl(null);
|
|
9440
9453
|
setInstallError(t("installFail") + " — " + t("exportLog"));
|
|
9441
9454
|
}
|
|
@@ -15116,6 +15129,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
15116
15129
|
githubProxyCustom: typeof body.githubProxyCustom === "string" ? body.githubProxyCustom : null,
|
|
15117
15130
|
githubProxyManaged: body.githubProxyManaged === true,
|
|
15118
15131
|
selfManaged: body.selfManaged !== false,
|
|
15132
|
+
selfUninstallReachable: body.selfUninstallReachable !== false,
|
|
15119
15133
|
buildEnv: body.buildEnv ?? {}
|
|
15120
15134
|
};
|
|
15121
15135
|
}
|
|
@@ -15222,6 +15236,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
15222
15236
|
githubProxyCustom: null,
|
|
15223
15237
|
githubProxyManaged: false,
|
|
15224
15238
|
selfManaged: true,
|
|
15239
|
+
selfUninstallReachable: true,
|
|
15225
15240
|
buildEnv: {}
|
|
15226
15241
|
});
|
|
15227
15242
|
}
|
|
@@ -15548,7 +15563,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
15548
15563
|
onClick: () => {
|
|
15549
15564
|
onSaveEnv();
|
|
15550
15565
|
}
|
|
15551
|
-
}, t("setBuildEnvSave")))), status?.selfManaged === true ? row(t("setSelfRemove"), t("setSelfRemoveHint"), phase
|
|
15566
|
+
}, t("setBuildEnvSave")))), status?.selfManaged === true ? row(t("setSelfRemove"), status.selfUninstallReachable ? t("setSelfRemoveHint") : t("setSelfRemoveRemote"), status.selfUninstallReachable && phase !== "confirming" && !busy ? (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
15552
15567
|
variant: "outline",
|
|
15553
15568
|
size: "sm",
|
|
15554
15569
|
className: Market_module_css_default.setDanger,
|
|
@@ -15556,7 +15571,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
15556
15571
|
onClick: () => {
|
|
15557
15572
|
setPhase("confirming");
|
|
15558
15573
|
}
|
|
15559
|
-
}, t("setSelfRemove"))) : null, status?.selfManaged === true && (phase === "confirming" || busy) ? (0, react.createElement)("div", { className: Market_module_css_default.setConfirm }, (0, react.createElement)("div", { className: Market_module_css_default.setHint }, t("setSelfConfirm")), (0, react.createElement)("label", { className: Market_module_css_default.setCheck }, (0, react.createElement)("input", {
|
|
15574
|
+
}, t("setSelfRemove")) : null) : null, status?.selfManaged === true && status.selfUninstallReachable && (phase === "confirming" || busy) ? (0, react.createElement)("div", { className: Market_module_css_default.setConfirm }, (0, react.createElement)("div", { className: Market_module_css_default.setHint }, t("setSelfConfirm")), (0, react.createElement)("label", { className: Market_module_css_default.setCheck }, (0, react.createElement)("input", {
|
|
15560
15575
|
type: "checkbox",
|
|
15561
15576
|
checked: purge,
|
|
15562
15577
|
onChange: () => {
|
package/lib/official-desktop.js
CHANGED
|
@@ -23,6 +23,9 @@ function detail(error) {
|
|
|
23
23
|
// The code stays on the line: it is what the log and the issue report carry.
|
|
24
24
|
if (copy !== undefined)
|
|
25
25
|
return `${copy.zh} / ${copy.en} (${code})`;
|
|
26
|
+
const message = error.message;
|
|
27
|
+
if (typeof message === 'string' && message !== '')
|
|
28
|
+
return message;
|
|
26
29
|
return JSON.stringify(error);
|
|
27
30
|
}
|
|
28
31
|
/**
|
|
@@ -60,6 +63,27 @@ const REFUSAL_COPY = {
|
|
|
60
63
|
en: 'this version is not compatible with the running DSH, so the host refused it',
|
|
61
64
|
},
|
|
62
65
|
};
|
|
66
|
+
/**
|
|
67
|
+
* The market's own package names. A desktop profile installs it from npm as
|
|
68
|
+
* `dshmarket`; a fork or a local checkout can declare the repository's name.
|
|
69
|
+
*/
|
|
70
|
+
const SELF_NAMES = new Set(['dshmarket', 'dsh-market']);
|
|
71
|
+
/**
|
|
72
|
+
* Why the market will not remove itself to update, and what to do instead.
|
|
73
|
+
*
|
|
74
|
+
* The manager's `removeBundle` drops the bundle from the running composition
|
|
75
|
+
* before pnpm runs. For every other package that is a normal unload; for the
|
|
76
|
+
* market it unloads the very code that was going to install the new version,
|
|
77
|
+
* so the reinstall never happens and the profile is left with the bundle row
|
|
78
|
+
* gone and the dependency still declared (#900). The app's own Plugins page
|
|
79
|
+
* performs the same two steps from outside the market and survives.
|
|
80
|
+
*/
|
|
81
|
+
function selfUpdateMessage(spec) {
|
|
82
|
+
const at = spec.indexOf('@', 1);
|
|
83
|
+
const version = at === -1 ? '' : spec.slice(at + 1);
|
|
84
|
+
const target = version === '' ? spec : `${registryNameOf(spec) ?? spec}@${version}`;
|
|
85
|
+
return `市场不能在自己的请求里先卸载自己:桌面端的移除会先把市场从正在运行的组合里摘掉,补装那一步就再也执行不到了(#900)。请在桌面端的「设置 → 插件」里更新市场${version === '' ? '' : ` 到 ${version}`},或完全退出 DSH 后在终端执行 \`dsh plugin --profile desktop add ${target}\`。 / The market cannot remove itself to update: the desktop app unloads it from the running composition first, so the reinstall never runs (#900). Update it from the desktop app's Settings → Plugins${version === '' ? '' : ` (to ${version})`}, or quit DSH completely and run \`dsh plugin --profile desktop add ${target}\` in a terminal.`;
|
|
86
|
+
}
|
|
63
87
|
/** The manager's own refusal code, whether it was returned or thrown. */
|
|
64
88
|
function managementCode(error) {
|
|
65
89
|
const code = error?.code;
|
|
@@ -100,6 +124,11 @@ async function installOrReinstall(manager, spec, options) {
|
|
|
100
124
|
// A refusal we cannot turn into a name is the manager's answer to give.
|
|
101
125
|
if (name === undefined)
|
|
102
126
|
return first;
|
|
127
|
+
// Removing the market would unload this very code before the reinstall runs
|
|
128
|
+
// (#900). Say how to update it instead.
|
|
129
|
+
if (SELF_NAMES.has(name)) {
|
|
130
|
+
return { application: 'failed', error: { code: 'market-self-update', message: selfUpdateMessage(spec) } };
|
|
131
|
+
}
|
|
103
132
|
progress.lastLine = 'Removing the installed copy so the desktop app can install the new version';
|
|
104
133
|
const removed = await manager.removeBundle(name);
|
|
105
134
|
if (removed.application === 'failed' || removed.application === 'cancelled')
|
package/lib/restart.js
CHANGED
|
@@ -213,6 +213,47 @@ function directLoopbackRequest(request) {
|
|
|
213
213
|
export function restartReachableFrom(request) {
|
|
214
214
|
return directLoopbackRequest(request);
|
|
215
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Whether the request has the exact shape dsh Desktop's own document proxy
|
|
218
|
+
* forwards: a loopback peer, no forwarding headers, and **neither** `Host`
|
|
219
|
+
* nor `Origin`.
|
|
220
|
+
*
|
|
221
|
+
* The desktop app strips `origin`/`host` (and cookies) before forwarding to
|
|
222
|
+
* the internal host (#648), so that absence is the trusted desktop shape
|
|
223
|
+
* rather than a missing signal. It is also not something a browser can
|
|
224
|
+
* produce — every browser request carries `Host` — so a rebinding page or a
|
|
225
|
+
* cross-site POST still lands on the strict path below, carrying the
|
|
226
|
+
* attacker's own names (#678). A local non-browser client could omit both,
|
|
227
|
+
* but such a client can equally set them correctly, so this widens nothing
|
|
228
|
+
* against the threats the fence exists for.
|
|
229
|
+
*/
|
|
230
|
+
function desktopForwardedRequest(request) {
|
|
231
|
+
const address = request.socket.remoteAddress;
|
|
232
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
|
|
233
|
+
return false;
|
|
234
|
+
if (request.headers.forwarded !== undefined
|
|
235
|
+
|| request.headers['x-forwarded-for'] !== undefined
|
|
236
|
+
|| request.headers['x-real-ip'] !== undefined)
|
|
237
|
+
return false;
|
|
238
|
+
return request.headers.host === undefined && request.headers.origin === undefined;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Whether a self-uninstall request may proceed.
|
|
242
|
+
*
|
|
243
|
+
* Same door as the restart route, plus the desktop proxy's headerless shape
|
|
244
|
+
* (#903): on Desktop the market's own card is the place users go to remove it,
|
|
245
|
+
* and the fence was refusing every such click because the proxy had stripped
|
|
246
|
+
* the very headers it required. Restart keeps the stricter predicate — it
|
|
247
|
+
* spawns a replacement host, which is not something a desktop-managed profile
|
|
248
|
+
* should get from here.
|
|
249
|
+
*/
|
|
250
|
+
export function trustedSelfUninstallRequest(request) {
|
|
251
|
+
return trustedRestartRequest(request) || desktopForwardedRequest(request);
|
|
252
|
+
}
|
|
253
|
+
/** Whether the request that asked for status could uninstall the market (#903). */
|
|
254
|
+
export function selfUninstallReachableFrom(request) {
|
|
255
|
+
return trustedSelfUninstallRequest(request);
|
|
256
|
+
}
|
|
216
257
|
/** Whether a process-control request came from this Web host on loopback. */
|
|
217
258
|
export function trustedRestartRequest(request) {
|
|
218
259
|
if (!directLoopbackRequest(request))
|
package/lib/routes.js
CHANGED
|
@@ -43,7 +43,7 @@ import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, inval
|
|
|
43
43
|
import { createThemeManager } from './themes.js';
|
|
44
44
|
import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.js';
|
|
45
45
|
import { resolveDshHome } from './home-paths.js';
|
|
46
|
-
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, restartReachableFrom } from './restart.js';
|
|
46
|
+
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, trustedSelfUninstallRequest, selfUninstallReachableFrom, restartReachableFrom } from './restart.js';
|
|
47
47
|
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, findUnservedConfigNamespaces, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
|
|
48
48
|
import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
|
|
49
49
|
import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
|
|
@@ -3552,6 +3552,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3552
3552
|
// Deliberately NOT folded into `restart`, which is also what the
|
|
3553
3553
|
// settings page reads and writes back as the user's own choice.
|
|
3554
3554
|
restartReachable: restartReachableFrom(request),
|
|
3555
|
+
// Same door, one notch wider for the desktop proxy (#903): the
|
|
3556
|
+
// card hides a remove button that could only ever answer 403.
|
|
3557
|
+
selfUninstallReachable: selfUninstallReachableFrom(request),
|
|
3555
3558
|
selfManaged: installed.dshmarket !== undefined || installed['dsh-market'] !== undefined,
|
|
3556
3559
|
// Whether the host took the market's settings namespace (#677).
|
|
3557
3560
|
// `unsupported-by-host` is 0.1.7 and newer, where settings come from
|
|
@@ -5255,8 +5258,13 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
5255
5258
|
// read as an extra guard while testing nothing: origin-matches-host
|
|
5256
5259
|
// is already part of what this checks, so no request can fail one
|
|
5257
5260
|
// and pass the other.
|
|
5258
|
-
if (!
|
|
5259
|
-
|
|
5261
|
+
if (!trustedSelfUninstallRequest(request)) {
|
|
5262
|
+
// Says what to do, not only which policy refused it: this is the one
|
|
5263
|
+
// button whose failure leaves the user with no other way out of the
|
|
5264
|
+
// page they are looking at (#903).
|
|
5265
|
+
sendJson(response, 403, {
|
|
5266
|
+
error: `不能从这个地址卸载插件市场(只接受本机同源请求)。请在运行 DSH 的机器上执行 \`dsh plugin --profile ${config.profile} remove dshmarket\`;桌面端也可以在它自己的「设置 → 插件」里移除。 / The market can only be removed from the same machine it runs on. Run \`dsh plugin --profile ${config.profile} remove dshmarket\` there, or remove it from the desktop app's Settings → Plugins.`,
|
|
5267
|
+
});
|
|
5260
5268
|
return;
|
|
5261
5269
|
}
|
|
5262
5270
|
try {
|
package/lib/types/restart.d.ts
CHANGED
|
@@ -126,6 +126,19 @@ export declare function servingPort(request: Pick<IncomingMessage, 'headers'>):
|
|
|
126
126
|
* grant: the route still runs the full fence, including `Origin`, on the POST.
|
|
127
127
|
*/
|
|
128
128
|
export declare function restartReachableFrom(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Whether a self-uninstall request may proceed.
|
|
131
|
+
*
|
|
132
|
+
* Same door as the restart route, plus the desktop proxy's headerless shape
|
|
133
|
+
* (#903): on Desktop the market's own card is the place users go to remove it,
|
|
134
|
+
* and the fence was refusing every such click because the proxy had stripped
|
|
135
|
+
* the very headers it required. Restart keeps the stricter predicate — it
|
|
136
|
+
* spawns a replacement host, which is not something a desktop-managed profile
|
|
137
|
+
* should get from here.
|
|
138
|
+
*/
|
|
139
|
+
export declare function trustedSelfUninstallRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
|
|
140
|
+
/** Whether the request that asked for status could uninstall the market (#903). */
|
|
141
|
+
export declare function selfUninstallReachableFrom(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
|
|
129
142
|
/** Whether a process-control request came from this Web host on loopback. */
|
|
130
143
|
export declare function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
|
|
131
144
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.66.
|
|
4
|
+
"version": "1.66.37",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -2950,8 +2950,9 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2950
2950
|
// declare the install done nor count an idle strike yet — a
|
|
2951
2951
|
// premature banner here invited a restart click into a 409.
|
|
2952
2952
|
if (pending !== null && busyUrl !== null && status.busy !== true) {
|
|
2953
|
+
const url = busyUrl
|
|
2953
2954
|
const nowInstalled = data !== null && data.plugins.some(p =>
|
|
2954
|
-
p.url ===
|
|
2955
|
+
p.url === url && isInstalled(p, statusInstalled, repoIdentities, data.plugins, repoHints))
|
|
2955
2956
|
if (nowInstalled) {
|
|
2956
2957
|
idleStrikes.current = 0
|
|
2957
2958
|
sessionStorage.removeItem('dshm-pending')
|
|
@@ -2960,7 +2961,18 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2960
2961
|
setRecords(list => drop(list, recovered.id))
|
|
2961
2962
|
recoveredInstall.current = null
|
|
2962
2963
|
}
|
|
2963
|
-
|
|
2964
|
+
// This poll is the only witness when the install response was
|
|
2965
|
+
// lost (#100): the record `doInstall` created is still
|
|
2966
|
+
// `running`, and releasing only the card's busy state leaves
|
|
2967
|
+
// the Tasks entry claiming "Installing 1/1" for the rest of
|
|
2968
|
+
// the mount — until settings travel remounts the section and
|
|
2969
|
+
// drops `records` with it. The install landed, so the record
|
|
2970
|
+
// has to land with it (#904).
|
|
2971
|
+
setRecords(list => list.map(record =>
|
|
2972
|
+
record.kind === 'install' && record.url === url && record.state === 'running'
|
|
2973
|
+
? { ...record, state: 'done' as const, needsRefresh: true }
|
|
2974
|
+
: record))
|
|
2975
|
+
setDoneUrls(urls => urls.includes(url) ? urls : urls.concat(url))
|
|
2964
2976
|
setBusyUrl(null)
|
|
2965
2977
|
} else if (++idleStrikes.current >= 2) {
|
|
2966
2978
|
// Host is idle and the plugin never landed: the install died
|
|
@@ -2973,6 +2985,13 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2973
2985
|
setRecords(list => drop(list, recovered.id))
|
|
2974
2986
|
recoveredInstall.current = null
|
|
2975
2987
|
}
|
|
2988
|
+
// Same release for the record this mount created: an install
|
|
2989
|
+
// that died without a response must not leave the Tasks entry
|
|
2990
|
+
// "Installing 1/1" either (#904).
|
|
2991
|
+
setRecords(list => list.map(record =>
|
|
2992
|
+
record.kind === 'install' && record.url === url && record.state === 'running'
|
|
2993
|
+
? { ...record, state: 'failed' as const, reason: t('installFail') }
|
|
2994
|
+
: record))
|
|
2976
2995
|
setBusyUrl(null)
|
|
2977
2996
|
setInstallError(t('installFail') + ' — ' + t('exportLog'))
|
|
2978
2997
|
}
|
|
@@ -83,6 +83,12 @@ interface SelfStatus {
|
|
|
83
83
|
githubProxyManaged: boolean
|
|
84
84
|
/** Whether the profile package manager owns this market installation. */
|
|
85
85
|
selfManaged: boolean
|
|
86
|
+
/**
|
|
87
|
+
* Whether a remove request from this page could pass the market's own
|
|
88
|
+
* fence (#903). False behind a reverse proxy or a remote host, where the
|
|
89
|
+
* card must not offer a button that can only answer 403.
|
|
90
|
+
*/
|
|
91
|
+
selfUninstallReachable: boolean
|
|
86
92
|
/** The effective build environment (composition + card-saved override). */
|
|
87
93
|
buildEnv: Record<string, string>
|
|
88
94
|
}
|
|
@@ -91,6 +97,7 @@ interface SelfStatus {
|
|
|
91
97
|
interface StatusBody {
|
|
92
98
|
version?: string
|
|
93
99
|
restart?: boolean
|
|
100
|
+
selfUninstallReachable?: boolean
|
|
94
101
|
channel?: string
|
|
95
102
|
channels?: string[]
|
|
96
103
|
region?: string
|
|
@@ -168,6 +175,7 @@ function readStatus(body: StatusBody): SelfStatus {
|
|
|
168
175
|
githubProxyCustom: typeof body.githubProxyCustom === 'string' ? body.githubProxyCustom : null,
|
|
169
176
|
githubProxyManaged: body.githubProxyManaged === true,
|
|
170
177
|
selfManaged: body.selfManaged !== false,
|
|
178
|
+
selfUninstallReachable: body.selfUninstallReachable !== false,
|
|
171
179
|
buildEnv: body.buildEnv ?? {},
|
|
172
180
|
}
|
|
173
181
|
}
|
|
@@ -279,7 +287,7 @@ export function SettingsCard({ t, onRemoved, sidebar }: SettingsCardProps): Reac
|
|
|
279
287
|
setStatus({
|
|
280
288
|
version: null, restart: false, channel: 'stable', channels: ['stable', 'beta'],
|
|
281
289
|
region: 'global', regions: REGIONS, regionAuto: false,
|
|
282
|
-
githubProxyCustom: null, githubProxyManaged: false, selfManaged: true, buildEnv: {},
|
|
290
|
+
githubProxyCustom: null, githubProxyManaged: false, selfManaged: true, selfUninstallReachable: true, buildEnv: {},
|
|
283
291
|
})
|
|
284
292
|
}
|
|
285
293
|
}
|
|
@@ -669,17 +677,18 @@ export function SettingsCard({ t, onRemoved, sidebar }: SettingsCardProps): Reac
|
|
|
669
677
|
onClick: () => { void onSaveEnv() },
|
|
670
678
|
}, t('setBuildEnvSave'))),
|
|
671
679
|
),
|
|
672
|
-
status?.selfManaged === true ? row(t('setSelfRemove'),
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
680
|
+
status?.selfManaged === true ? row(t('setSelfRemove'),
|
|
681
|
+
status.selfUninstallReachable ? t('setSelfRemoveHint') : t('setSelfRemoveRemote'),
|
|
682
|
+
status.selfUninstallReachable && phase !== 'confirming' && !busy
|
|
683
|
+
? h(Button, {
|
|
676
684
|
variant: 'outline',
|
|
677
685
|
size: 'sm',
|
|
678
686
|
className: css.setDanger,
|
|
679
687
|
disabled: busy,
|
|
680
688
|
onClick: () => { setPhase('confirming') },
|
|
681
|
-
}, t('setSelfRemove'))
|
|
682
|
-
|
|
689
|
+
}, t('setSelfRemove'))
|
|
690
|
+
: null) : null,
|
|
691
|
+
status?.selfManaged === true && status.selfUninstallReachable && (phase === 'confirming' || busy)
|
|
683
692
|
? h('div', { className: css.setConfirm },
|
|
684
693
|
h('div', { className: css.setHint }, t('setSelfConfirm')),
|
|
685
694
|
h('label', { className: css.setCheck },
|
package/src/client/locales.ts
CHANGED
|
@@ -46,6 +46,7 @@ export const zh = {
|
|
|
46
46
|
setChannelSwitchHint: '该版本低于当前已安装版本。',
|
|
47
47
|
setSelfRemove: '卸载插件市场',
|
|
48
48
|
setSelfRemoveHint: '从这个 profile 卸载市场。已装的其它插件不受影响。',
|
|
49
|
+
setSelfRemoveRemote: '这个页面不是从本机打开的,市场没法从这里卸载自己。请到运行 DSH 的那台机器上移除:桌面端用「设置 → 插件」,其它环境用命令行。',
|
|
49
50
|
setSelfConfirm: '确定要卸载插件市场吗?卸载后需使用命令行重新安装。',
|
|
50
51
|
setSelfPurge: '同时清除市场自己的数据',
|
|
51
52
|
setSelfPurgeOn: '将删除:市场的停用清单与自定义分组,以及它写进配置文件的停用记录——被停用的插件将恢复运行。不涉及密码或密钥,市场从不把它们存在磁盘上。',
|
|
@@ -807,6 +808,7 @@ export const en: Record<MarketKey, string> = {
|
|
|
807
808
|
setChannelSwitchHint: 'This version is lower than the one installed.',
|
|
808
809
|
setSelfRemove: 'Uninstall the plugin market',
|
|
809
810
|
setSelfRemoveHint: 'Uninstall the market from this profile. Your other plugins are untouched.',
|
|
811
|
+
setSelfRemoveRemote: 'This page was not opened on the machine running DSH, so the market cannot remove itself from here. Remove it there: Settings → Plugins on the desktop app, or the command line elsewhere.',
|
|
810
812
|
setSelfConfirm: 'Uninstall the plugin market? Reinstalling it requires the command line.',
|
|
811
813
|
setSelfPurge: 'Also clear the market\'s own data',
|
|
812
814
|
setSelfPurgeOn: 'Will delete: the market\'s disable list and custom groups, plus the disable entries it wrote into your config file — plugins it switched off will run again. No passwords or tokens are involved; the market never stores those on disk.',
|
package/src/official-desktop.ts
CHANGED
|
@@ -35,6 +35,8 @@ function detail(error: unknown): string {
|
|
|
35
35
|
const copy = code === undefined ? undefined : REFUSAL_COPY[code]
|
|
36
36
|
// The code stays on the line: it is what the log and the issue report carry.
|
|
37
37
|
if (copy !== undefined) return `${copy.zh} / ${copy.en} (${code})`
|
|
38
|
+
const message = (error as { message?: unknown }).message
|
|
39
|
+
if (typeof message === 'string' && message !== '') return message
|
|
38
40
|
return JSON.stringify(error)
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -74,6 +76,29 @@ const REFUSAL_COPY: Record<string, { zh: string; en: string }> = {
|
|
|
74
76
|
},
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The market's own package names. A desktop profile installs it from npm as
|
|
81
|
+
* `dshmarket`; a fork or a local checkout can declare the repository's name.
|
|
82
|
+
*/
|
|
83
|
+
const SELF_NAMES = new Set(['dshmarket', 'dsh-market'])
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Why the market will not remove itself to update, and what to do instead.
|
|
87
|
+
*
|
|
88
|
+
* The manager's `removeBundle` drops the bundle from the running composition
|
|
89
|
+
* before pnpm runs. For every other package that is a normal unload; for the
|
|
90
|
+
* market it unloads the very code that was going to install the new version,
|
|
91
|
+
* so the reinstall never happens and the profile is left with the bundle row
|
|
92
|
+
* gone and the dependency still declared (#900). The app's own Plugins page
|
|
93
|
+
* performs the same two steps from outside the market and survives.
|
|
94
|
+
*/
|
|
95
|
+
function selfUpdateMessage(spec: string): string {
|
|
96
|
+
const at = spec.indexOf('@', 1)
|
|
97
|
+
const version = at === -1 ? '' : spec.slice(at + 1)
|
|
98
|
+
const target = version === '' ? spec : `${registryNameOf(spec) ?? spec}@${version}`
|
|
99
|
+
return `市场不能在自己的请求里先卸载自己:桌面端的移除会先把市场从正在运行的组合里摘掉,补装那一步就再也执行不到了(#900)。请在桌面端的「设置 → 插件」里更新市场${version === '' ? '' : ` 到 ${version}`},或完全退出 DSH 后在终端执行 \`dsh plugin --profile desktop add ${target}\`。 / The market cannot remove itself to update: the desktop app unloads it from the running composition first, so the reinstall never runs (#900). Update it from the desktop app's Settings → Plugins${version === '' ? '' : ` (to ${version})`}, or quit DSH completely and run \`dsh plugin --profile desktop add ${target}\` in a terminal.`
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
/** The manager's own refusal code, whether it was returned or thrown. */
|
|
78
103
|
function managementCode(error: unknown): string | undefined {
|
|
79
104
|
const code = (error as { code?: unknown } | null | undefined)?.code
|
|
@@ -116,6 +141,11 @@ async function installOrReinstall(
|
|
|
116
141
|
const name = registryNameOf(spec)
|
|
117
142
|
// A refusal we cannot turn into a name is the manager's answer to give.
|
|
118
143
|
if (name === undefined) return first
|
|
144
|
+
// Removing the market would unload this very code before the reinstall runs
|
|
145
|
+
// (#900). Say how to update it instead.
|
|
146
|
+
if (SELF_NAMES.has(name)) {
|
|
147
|
+
return { application: 'failed', error: { code: 'market-self-update', message: selfUpdateMessage(spec) } }
|
|
148
|
+
}
|
|
119
149
|
progress.lastLine = 'Removing the installed copy so the desktop app can install the new version'
|
|
120
150
|
const removed = await manager.removeBundle(name)
|
|
121
151
|
if (removed.application === 'failed' || removed.application === 'cancelled') return removed
|
package/src/restart.ts
CHANGED
|
@@ -233,6 +233,48 @@ export function restartReachableFrom(request: Pick<IncomingMessage, 'headers' |
|
|
|
233
233
|
return directLoopbackRequest(request)
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Whether the request has the exact shape dsh Desktop's own document proxy
|
|
238
|
+
* forwards: a loopback peer, no forwarding headers, and **neither** `Host`
|
|
239
|
+
* nor `Origin`.
|
|
240
|
+
*
|
|
241
|
+
* The desktop app strips `origin`/`host` (and cookies) before forwarding to
|
|
242
|
+
* the internal host (#648), so that absence is the trusted desktop shape
|
|
243
|
+
* rather than a missing signal. It is also not something a browser can
|
|
244
|
+
* produce — every browser request carries `Host` — so a rebinding page or a
|
|
245
|
+
* cross-site POST still lands on the strict path below, carrying the
|
|
246
|
+
* attacker's own names (#678). A local non-browser client could omit both,
|
|
247
|
+
* but such a client can equally set them correctly, so this widens nothing
|
|
248
|
+
* against the threats the fence exists for.
|
|
249
|
+
*/
|
|
250
|
+
function desktopForwardedRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
|
|
251
|
+
const address = request.socket.remoteAddress
|
|
252
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
253
|
+
if (request.headers.forwarded !== undefined
|
|
254
|
+
|| request.headers['x-forwarded-for'] !== undefined
|
|
255
|
+
|| request.headers['x-real-ip'] !== undefined) return false
|
|
256
|
+
return request.headers.host === undefined && request.headers.origin === undefined
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Whether a self-uninstall request may proceed.
|
|
261
|
+
*
|
|
262
|
+
* Same door as the restart route, plus the desktop proxy's headerless shape
|
|
263
|
+
* (#903): on Desktop the market's own card is the place users go to remove it,
|
|
264
|
+
* and the fence was refusing every such click because the proxy had stripped
|
|
265
|
+
* the very headers it required. Restart keeps the stricter predicate — it
|
|
266
|
+
* spawns a replacement host, which is not something a desktop-managed profile
|
|
267
|
+
* should get from here.
|
|
268
|
+
*/
|
|
269
|
+
export function trustedSelfUninstallRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
|
|
270
|
+
return trustedRestartRequest(request) || desktopForwardedRequest(request)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Whether the request that asked for status could uninstall the market (#903). */
|
|
274
|
+
export function selfUninstallReachableFrom(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
|
|
275
|
+
return trustedSelfUninstallRequest(request)
|
|
276
|
+
}
|
|
277
|
+
|
|
236
278
|
/** Whether a process-control request came from this Web host on loopback. */
|
|
237
279
|
export function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
|
|
238
280
|
if (!directLoopbackRequest(request)) return false
|
package/src/routes.ts
CHANGED
|
@@ -55,7 +55,7 @@ import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, inval
|
|
|
55
55
|
import { createThemeManager, type LoaderEntry } from './themes.ts'
|
|
56
56
|
import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.ts'
|
|
57
57
|
import { resolveDshHome } from './home-paths.ts'
|
|
58
|
-
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
|
|
58
|
+
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, trustedSelfUninstallRequest, selfUninstallReachableFrom, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
|
|
59
59
|
import type { RecoveryPlugin } from './recovery.ts'
|
|
60
60
|
import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, findUnservedConfigNamespaces, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
|
|
61
61
|
import {
|
|
@@ -3758,6 +3758,9 @@ export function mountMarketRoutes(
|
|
|
3758
3758
|
// Deliberately NOT folded into `restart`, which is also what the
|
|
3759
3759
|
// settings page reads and writes back as the user's own choice.
|
|
3760
3760
|
restartReachable: restartReachableFrom(request),
|
|
3761
|
+
// Same door, one notch wider for the desktop proxy (#903): the
|
|
3762
|
+
// card hides a remove button that could only ever answer 403.
|
|
3763
|
+
selfUninstallReachable: selfUninstallReachableFrom(request),
|
|
3761
3764
|
selfManaged: installed.dshmarket !== undefined || installed['dsh-market'] !== undefined,
|
|
3762
3765
|
// Whether the host took the market's settings namespace (#677).
|
|
3763
3766
|
// `unsupported-by-host` is 0.1.7 and newer, where settings come from
|
|
@@ -5484,8 +5487,13 @@ sendJson(response, 200, { updates })
|
|
|
5484
5487
|
// read as an extra guard while testing nothing: origin-matches-host
|
|
5485
5488
|
// is already part of what this checks, so no request can fail one
|
|
5486
5489
|
// and pass the other.
|
|
5487
|
-
if (!
|
|
5488
|
-
|
|
5490
|
+
if (!trustedSelfUninstallRequest(request)) {
|
|
5491
|
+
// Says what to do, not only which policy refused it: this is the one
|
|
5492
|
+
// button whose failure leaves the user with no other way out of the
|
|
5493
|
+
// page they are looking at (#903).
|
|
5494
|
+
sendJson(response, 403, {
|
|
5495
|
+
error: `不能从这个地址卸载插件市场(只接受本机同源请求)。请在运行 DSH 的机器上执行 \`dsh plugin --profile ${config.profile} remove dshmarket\`;桌面端也可以在它自己的「设置 → 插件」里移除。 / The market can only be removed from the same machine it runs on. Run \`dsh plugin --profile ${config.profile} remove dshmarket\` there, or remove it from the desktop app's Settings → Plugins.`,
|
|
5496
|
+
})
|
|
5489
5497
|
return
|
|
5490
5498
|
}
|
|
5491
5499
|
try {
|