dsh-oc-desktop 0.3.6 → 0.3.8
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/bin/dsh-desktop.cjs +23 -1
- package/launcher/dialog-preload.js +8 -0
- package/launcher/main.js +280 -47
- package/launcher/updater.js +152 -2
- package/package.json +3 -3
- package/scripts/set-exe-icon.ps1 +275 -0
- package/scripts/test-updater.mjs +100 -0
package/bin/dsh-desktop.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
// dsh-desktop: boot the desktop shell (Electron launcher).
|
|
4
|
-
const { spawn } = require('node:child_process');
|
|
4
|
+
const { spawn, execFileSync } = require('node:child_process');
|
|
5
5
|
const fs = require('node:fs');
|
|
6
6
|
const path = require('node:path');
|
|
7
7
|
|
|
@@ -37,6 +37,10 @@ function main() {
|
|
|
37
37
|
console.error('dsh-oc-desktop: launcher/main.js missing, reinstall the package.');
|
|
38
38
|
process.exit(1);
|
|
39
39
|
}
|
|
40
|
+
// electron.exe brand icon may be lost after `npm install` (electron re-downloads its
|
|
41
|
+
// exe). Re-inject before the shell starts — electron is not running yet, so the exe
|
|
42
|
+
// is writable. Taskbar/TaskManager icons come from the exe, so this keeps them right.
|
|
43
|
+
ensureInjected(electron);
|
|
40
44
|
const child = spawn(electron, [mainPath, ...args], { stdio: 'inherit', windowsHide: true });
|
|
41
45
|
child.on('error', (err) => { console.error(`dsh-oc-desktop: failed to start electron: ${err.message}`); process.exit(1); });
|
|
42
46
|
child.on('exit', (code, signal) => {
|
|
@@ -48,5 +52,23 @@ function main() {
|
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
// Re-inject the brand icon (and DSH Desktop version info) into electron.exe if it was
|
|
56
|
+
// re-downloaded. Probe ProductName first (fast); inject when it is not 'DSH Desktop'.
|
|
57
|
+
const injectScript = path.join(__dirname, '..', 'scripts', 'set-exe-icon.ps1');
|
|
58
|
+
function ensureInjected(electron) {
|
|
59
|
+
if (!fs.existsSync(injectScript)) return; // package missing the script — skip silently
|
|
60
|
+
try {
|
|
61
|
+
const probe = execFileSync('powershell', ['-NoProfile', '-Command', `(Get-Item '${electron}').VersionInfo.ProductName`], { encoding: 'utf8', windowsHide: true });
|
|
62
|
+
if (String(probe).trim() === 'DSH Desktop') return;
|
|
63
|
+
} catch { /* probe failed; try injecting anyway */ }
|
|
64
|
+
console.log('dsh-oc-desktop: electron brand icon missing, injecting ...');
|
|
65
|
+
try {
|
|
66
|
+
execFileSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', injectScript], { stdio: 'inherit', windowsHide: true });
|
|
67
|
+
console.log('dsh-oc-desktop: brand icon injected.');
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn(`dsh-oc-desktop: icon injection failed: ${e && e.message ? e.message : e}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
if (require.main === module) main();
|
|
52
74
|
module.exports = { resolveElectron };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// dialog-preload.js - 更新弹窗的 preload:把「执行更新」动作桥到主进程 IPC。
|
|
3
|
+
// 弹窗 sandbox:true,只暴露最小表面(dshUpdate.run),无 Node 能力。
|
|
4
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
5
|
+
|
|
6
|
+
contextBridge.exposeInMainWorld('dshUpdate', {
|
|
7
|
+
run: (action) => ipcRenderer.invoke('dialog-update', action),
|
|
8
|
+
});
|
package/launcher/main.js
CHANGED
|
@@ -9,7 +9,7 @@ const path = require('node:path');
|
|
|
9
9
|
const os = require('node:os');
|
|
10
10
|
const { connectEvents } = require('./events');
|
|
11
11
|
const { applyUpdate } = require('./apply-update');
|
|
12
|
-
const {
|
|
12
|
+
const { checkNpmUpdates, npmPackageInfo, npmPackageVersions, checkEngineCompat } = require('./updater');
|
|
13
13
|
const { parseCurrentSession, SESSION_ID_RE } = require('./session-window');
|
|
14
14
|
|
|
15
15
|
// ---------- 路径常量 ----------
|
|
@@ -18,11 +18,12 @@ const { parseCurrentSession, SESSION_ID_RE } = require('./session-window');
|
|
|
18
18
|
const PACKAGE_ROOT = path.join(__dirname, '..'); // plugins/dsh-desktop(包根,自带 assets)
|
|
19
19
|
|
|
20
20
|
app.setName('dsh-desktop');
|
|
21
|
-
// Windows
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
21
|
+
// 不设置 AppUserModelID:设置后 Windows 任务栏按钮图标走 AppID 关联解析,
|
|
22
|
+
// 无匹配快捷方式时显示空白默认图标(实测"无图标");移除后任务栏回退 exe 图标
|
|
23
|
+
// (已由 scripts/set-exe-icon.ps1 注入圆角白底黑鲸,任务管理器进程图标同源)。
|
|
24
|
+
// 通知:未打包场景下 Electron Notification 仍可弹出;若后续打包需恢复此调用并配套
|
|
25
|
+
// 同 AppID 的快捷方式(否则任务栏图标空白)。
|
|
26
|
+
// app.setAppUserModelId('dev.dsh.desktop');
|
|
26
27
|
|
|
27
28
|
// ---------- 更新应用模式 ----------
|
|
28
29
|
// 主进程以 --dsh-apply-update 重启:本进程只负责把 staging 文件树替换到位,
|
|
@@ -65,7 +66,8 @@ const DEFAULT_SETTINGS = {
|
|
|
65
66
|
shortcut: 'Control+Alt+D', // 全局显示/隐藏快捷键
|
|
66
67
|
startupTimeoutMs: 90000, // 引擎启动超时
|
|
67
68
|
dataHome: null, // 独立数据根(null = %APPDATA%\dsh-desktop\home,即独立的 DSH_HOME)
|
|
68
|
-
updateUrl: null, //
|
|
69
|
+
updateUrl: null, // (遗留)文件树更新服务器根地址;不再使用(发布改 npm 包)
|
|
70
|
+
updateRegistry: null, // npm registry 源;null = 官方 https://registry.npmjs.org,托盘「检查更新」查询 latest 对比
|
|
69
71
|
};
|
|
70
72
|
function loadJson(file, fallback) {
|
|
71
73
|
try { return { ...fallback, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; }
|
|
@@ -163,7 +165,7 @@ async function ensureDataHome() {
|
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
// ---------- 状态 ----------
|
|
166
|
-
let mainWin = null, splash = null, tray = null;
|
|
168
|
+
let mainWin = null, splash = null, tray = null, dialogWin = null;
|
|
167
169
|
let child = null, harnessUrl = null, quitting = false;
|
|
168
170
|
let restartTimer = null, startTimer = null, stateTimer = null;
|
|
169
171
|
let stdoutBuf = '', restartCount = 0;
|
|
@@ -579,6 +581,130 @@ function showError(msg) {
|
|
|
579
581
|
mainWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(errorHtml(msg)));
|
|
580
582
|
}
|
|
581
583
|
|
|
584
|
+
// ---------- 更新信息弹窗 ----------
|
|
585
|
+
// oc 组件包名 → 中文显示名(检查更新表格用)
|
|
586
|
+
const OC_DISPLAY = {
|
|
587
|
+
'dsh-oc-desktop': '桌面壳',
|
|
588
|
+
'dsh-oc-session-window': '会话新窗口',
|
|
589
|
+
'dsh-oc-change-review': '修改审查',
|
|
590
|
+
'dsh-oc-sidebar': '右侧工作台',
|
|
591
|
+
'dsh-oc-deeplink': '深链',
|
|
592
|
+
'@deepseek-ai/dsh': '引擎',
|
|
593
|
+
};
|
|
594
|
+
function updateHtml(title, rows, footer) {
|
|
595
|
+
const esc = escapeHtml;
|
|
596
|
+
const showAll = rows.some((r) => r.hasUpdate);
|
|
597
|
+
const cards = rows.map((r) => {
|
|
598
|
+
const st = r.error ? '检查失败' : r.hasUpdate ? '可更新' : '已最新';
|
|
599
|
+
const stCls = r.error ? 'err' : r.hasUpdate ? 'upd' : 'ok';
|
|
600
|
+
const compat = r.compatible == null ? '' : r.compatible
|
|
601
|
+
? `<span class="compat ok" title="该插件声明的依赖范围兼容最新引擎">兼容新引擎</span>`
|
|
602
|
+
: `<span class="compat err" title="该插件声明的依赖范围不满足最新引擎,将保留当前版本">不兼容·保留</span>`;
|
|
603
|
+
const desc = r.description ? `<div class="desc" title="${esc(r.description)}">${esc(r.description)}</div>` : '';
|
|
604
|
+
const ver = r.error
|
|
605
|
+
? ''
|
|
606
|
+
: `<div class="ver"><span class="lbl">当前</span> <b>${esc(r.current)}</b> <span class="arrow ${r.hasUpdate ? 'upd' : ''}">→</span> <span class="lbl">最新</span> <b>${esc(r.latest)}</b></div>`;
|
|
607
|
+
return `<div class="card">
|
|
608
|
+
<div class="top"><span class="pkg">${esc(r.display || r.name)}</span><span class="nm">${esc(r.name)}</span>${compat}<span class="st ${stCls}">${st}</span></div>
|
|
609
|
+
${desc}${ver}
|
|
610
|
+
</div>`;
|
|
611
|
+
}).join('');
|
|
612
|
+
const foot = footer ? `<div class="footnote">${esc(footer)}</div>` : '';
|
|
613
|
+
const allBtn = showAll ? `<button class="all-btn" onclick="doUpdate(this)">一键更新</button>` : '';
|
|
614
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
615
|
+
body{margin:0;height:100vh;display:flex;flex-direction:column;background:#101216;color:#e5e7eb;font-family:'Segoe UI',system-ui,sans-serif}
|
|
616
|
+
.head{padding:13px 18px;font-size:14px;font-weight:600;border-bottom:1px solid #23272f}
|
|
617
|
+
.body{flex:1;overflow:auto;padding:6px 18px}
|
|
618
|
+
.body::-webkit-scrollbar{width:8px}
|
|
619
|
+
.body::-webkit-scrollbar-track{background:transparent}
|
|
620
|
+
.body::-webkit-scrollbar-thumb{background:#2a2f3a;border-radius:4px}
|
|
621
|
+
.body::-webkit-scrollbar-thumb:hover{background:#3a4150}
|
|
622
|
+
.card{padding:10px 2px;border-bottom:1px solid #1d2129}
|
|
623
|
+
.card:last-child{border-bottom:none}
|
|
624
|
+
.top{display:flex;align-items:center;gap:8px;min-width:0}
|
|
625
|
+
.pkg{color:#e5e7eb;font-weight:600;font-size:13px}
|
|
626
|
+
.nm{color:#6b7280;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
627
|
+
.st{margin-left:auto;flex-shrink:0;font-size:11px;padding:2px 10px;border-radius:10px}
|
|
628
|
+
.ok{color:#34d399;background:#12251d}
|
|
629
|
+
.upd{color:#fbbf24;background:#2a2013}
|
|
630
|
+
.err{color:#f87171;background:#2a1414}
|
|
631
|
+
.compat{flex-shrink:0;font-size:11px;padding:2px 8px;border-radius:10px}
|
|
632
|
+
.compat.ok{color:#34d399;border:1px solid #1d3a2e}
|
|
633
|
+
.compat.err{color:#f87171;border:1px solid #3a2020}
|
|
634
|
+
.desc{color:#6b7280;font-size:11px;margin:3px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
|
635
|
+
.ver{font-size:12px;color:#9aa4b2;margin-top:2px}
|
|
636
|
+
.ver b{color:#e5e7eb;font-weight:600;font-size:13px}
|
|
637
|
+
.lbl{color:#6b7280;font-size:11px}
|
|
638
|
+
.arrow{color:#6b7280;margin:0 2px}
|
|
639
|
+
.arrow.upd{color:#fbbf24}
|
|
640
|
+
.all-btn{background:#4d6bfe;border:none;color:#fff;font-size:13px;padding:7px 22px;border-radius:8px;cursor:pointer;margin-right:8px}
|
|
641
|
+
.all-btn:hover{background:#3b5bfe}
|
|
642
|
+
.all-btn:disabled{background:#3a4150;cursor:default}
|
|
643
|
+
.footnote{margin-top:12px;font-size:12px;line-height:1.7;color:#9aa4b2;white-space:pre-wrap}
|
|
644
|
+
.res{font-size:16px;font-weight:600;margin-top:40px;text-align:center}
|
|
645
|
+
.res.ok{color:#34d399}
|
|
646
|
+
.res.err{color:#f87171}
|
|
647
|
+
.res-msg{margin-top:12px;font-size:13px;line-height:1.7;color:#c8cdd6;text-align:center;word-break:break-word}
|
|
648
|
+
.foot{padding:11px 18px;border-top:1px solid #23272f;display:flex;justify-content:flex-end}
|
|
649
|
+
button{background:#4d6bfe;border:none;color:#fff;font-size:13px;padding:7px 22px;border-radius:8px;cursor:pointer}
|
|
650
|
+
button:hover{background:#3b5bfe}</style></head><body>
|
|
651
|
+
<div class="head">${esc(title)}</div>
|
|
652
|
+
<div class="body">${cards}${foot}</div>
|
|
653
|
+
<div class="foot">${allBtn}<button onclick="window.close()">确定</button></div>
|
|
654
|
+
<script>
|
|
655
|
+
async function doUpdate(btn) {
|
|
656
|
+
btn.disabled = true;
|
|
657
|
+
btn.textContent = '更新中…';
|
|
658
|
+
let r;
|
|
659
|
+
try { r = await window.dshUpdate.run({ kind: 'all' }); }
|
|
660
|
+
catch (e) { r = { ok: false, message: String(e && e.message || e) }; }
|
|
661
|
+
const body = document.querySelector('.body');
|
|
662
|
+
body.textContent = '';
|
|
663
|
+
const d = document.createElement('div');
|
|
664
|
+
d.className = 'res ' + (r && r.ok ? 'ok' : 'err');
|
|
665
|
+
d.textContent = (r && r.ok) ? '✓ 更新完成' : '✗ 更新失败';
|
|
666
|
+
const m = document.createElement('div');
|
|
667
|
+
m.className = 'res-msg';
|
|
668
|
+
m.textContent = (r && r.message) || '';
|
|
669
|
+
body.appendChild(d);
|
|
670
|
+
body.appendChild(m);
|
|
671
|
+
}
|
|
672
|
+
</script>
|
|
673
|
+
</body></html>`;
|
|
674
|
+
}
|
|
675
|
+
// ---------- 更新弹窗窗口管理 ----------
|
|
676
|
+
function createDialogWindow() {
|
|
677
|
+
dialogWin = new BrowserWindow({
|
|
678
|
+
width: 680, height: 520, show: false, title: '检查更新',
|
|
679
|
+
icon: appIcon(), backgroundColor: '#101216',
|
|
680
|
+
autoHideMenuBar: true, resizable: false, maximizable: false,
|
|
681
|
+
webPreferences: { sandbox: true, contextIsolation: true, preload: path.join(__dirname, 'dialog-preload.js') },
|
|
682
|
+
});
|
|
683
|
+
dialogWin.once('ready-to-show', () => { dialogWin.show(); dialogWin.focus(); });
|
|
684
|
+
dialogWin.on('closed', () => { dialogWin = null; });
|
|
685
|
+
}
|
|
686
|
+
function loadDialog(html) {
|
|
687
|
+
if (!dialogWin || dialogWin.isDestroyed()) createDialogWindow();
|
|
688
|
+
dialogWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
|
|
689
|
+
dialogWin.focus();
|
|
690
|
+
}
|
|
691
|
+
// 检查前的加载动画页(先弹窗,给用户即时反馈,避免误以为没点到/卡住)
|
|
692
|
+
function loadingHtml() {
|
|
693
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
694
|
+
body{margin:0;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;background:#101216;color:#e5e7eb;font-family:'Segoe UI',system-ui,sans-serif}
|
|
695
|
+
.spinner{width:36px;height:36px;border:3px solid #2a2f3a;border-top-color:#4d6bfe;border-radius:50%;animation:spin .8s linear infinite}
|
|
696
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
697
|
+
.t{font-size:13px;color:#9aa4b2}</style></head><body>
|
|
698
|
+
<div class="spinner"></div><div class="t">正在检查更新…</div>
|
|
699
|
+
</body></html>`;
|
|
700
|
+
}
|
|
701
|
+
function showUpdateLoading() {
|
|
702
|
+
loadDialog(loadingHtml());
|
|
703
|
+
}
|
|
704
|
+
function showUpdateDialog(title, rows, footer) {
|
|
705
|
+
loadDialog(updateHtml(title, rows, footer));
|
|
706
|
+
}
|
|
707
|
+
|
|
582
708
|
// ---------- 通知 / 托盘 / 快捷键 / 自启 ----------
|
|
583
709
|
function notify(title, body) {
|
|
584
710
|
if (!Notification.isSupported()) return;
|
|
@@ -618,6 +744,7 @@ function refreshTrayMenu() {
|
|
|
618
744
|
{ type: 'separator' },
|
|
619
745
|
{ label: '当前会话在新窗口打开', click: () => { openSessionWindowFrom(null); } },
|
|
620
746
|
{ label: '检查更新…', click: () => { runCheckUpdates(); } },
|
|
747
|
+
{ label: '回滚引擎更新', click: () => { rollbackEngine(); } },
|
|
621
748
|
{ label: '开发者工具', click: () => { if (mainWin && !mainWin.isDestroyed()) mainWin.webContents.toggleDevTools(); } },
|
|
622
749
|
{ label: '退出', click: quitApp },
|
|
623
750
|
]));
|
|
@@ -722,52 +849,158 @@ ipcMain.on('set-window-bg', (_e, color) => {
|
|
|
722
849
|
try { win.setBackgroundColor(color); } catch (e) { log('warn', `setBackgroundColor failed: ${e.message}`); }
|
|
723
850
|
});
|
|
724
851
|
|
|
725
|
-
// ----------
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
852
|
+
// ---------- 弹窗一键更新执行(全部) ----------
|
|
853
|
+
// 弹窗「一键更新」经 dialog-preload → ipcMain.handle('dialog-update') → 这里执行。
|
|
854
|
+
// 无单独更新:引擎有新版时兼容插件随引擎一起更新,不兼容插件保留;更新前备份旧引擎,可回滚。
|
|
855
|
+
function runCommand(cmd, args) {
|
|
856
|
+
return new Promise((resolve, reject) => {
|
|
857
|
+
const p = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
858
|
+
let out = '';
|
|
859
|
+
p.stdout.on('data', (d) => { out += d; });
|
|
860
|
+
p.stderr.on('data', (d) => { out += d; });
|
|
861
|
+
p.on('error', (e) => reject(e));
|
|
862
|
+
p.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(out.trim() || `命令退出码 ${code}`))));
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
// 收集本地已装组件(壳 + oc 插件 + 引擎),检查与一键更新复用
|
|
866
|
+
function collectInstalled() {
|
|
867
|
+
let shellVersion = app.getVersion();
|
|
868
|
+
try { shellVersion = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version; } catch { /* fallback */ }
|
|
869
|
+
const list = [{ name: 'dsh-oc-desktop', current: String(shellVersion) }];
|
|
870
|
+
const nmRoot = path.join(dataHome, 'profiles', 'web', 'node_modules');
|
|
871
|
+
for (const name of ['dsh-oc-session-window', 'dsh-oc-change-review', 'dsh-oc-sidebar', 'dsh-oc-deeplink']) {
|
|
872
|
+
try { const v = JSON.parse(fs.readFileSync(path.join(nmRoot, name, 'package.json'), 'utf8')).version; list.push({ name, current: String(v) }); } catch { /* not installed */ }
|
|
734
873
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
874
|
+
try {
|
|
875
|
+
const v = JSON.parse(fs.readFileSync(path.join(dataHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'), 'utf8')).version;
|
|
876
|
+
list.push({ name: '@deepseek-ai/dsh', current: String(v) });
|
|
877
|
+
} catch { /* engine missing */ }
|
|
878
|
+
return list;
|
|
879
|
+
}
|
|
880
|
+
async function executeUpdate(action) {
|
|
881
|
+
log('info', `update action: ${JSON.stringify(action)}`);
|
|
882
|
+
if (!action || action.kind !== 'all') return { ok: false, message: '已取消单独更新,仅支持一键更新' };
|
|
883
|
+
const registry = (settings.updateRegistry || 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
884
|
+
const engineBin = path.join(dataHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
885
|
+
try {
|
|
886
|
+
const results = await checkNpmUpdates(collectInstalled(), registry);
|
|
887
|
+
const updates = results.filter((r) => r.hasUpdate);
|
|
888
|
+
if (updates.length === 0) return { ok: true, message: '所有组件已是最新,无需更新' };
|
|
889
|
+
const engineUpd = results.find((r) => r.name === '@deepseek-ai/dsh' && r.hasUpdate);
|
|
890
|
+
if (!engineUpd) {
|
|
891
|
+
// 引擎无更新:聚合器一键更新全部 oc 组件
|
|
892
|
+
await runCommand('npx', ['--yes', 'dsh-oc', 'install']);
|
|
893
|
+
return { ok: true, message: '全部 oc 组件已更新到最新。重启应用后生效。' };
|
|
894
|
+
}
|
|
895
|
+
// 引擎有新版:检测各插件兼容性,兼容且有更新的随引擎一起更新,不兼容保留
|
|
896
|
+
const engineInfo = await npmPackageInfo('@deepseek-ai/dsh', registry);
|
|
897
|
+
const compatToUpdate = [];
|
|
898
|
+
const kept = [];
|
|
899
|
+
for (const r of results) {
|
|
900
|
+
if (r.name === '@deepseek-ai/dsh' || r.error) continue;
|
|
901
|
+
const ok = await checkEngineCompat(r.peerDependencies, engineInfo.dependencies, async (pkg) => npmPackageVersions(pkg, registry));
|
|
902
|
+
if (ok) { if (r.hasUpdate) compatToUpdate.push(r.name); }
|
|
903
|
+
else kept.push(r.name);
|
|
904
|
+
}
|
|
905
|
+
// 备份旧引擎
|
|
906
|
+
const dshDir = path.join(dataHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh');
|
|
907
|
+
if (!fs.existsSync(dshDir)) return { ok: false, message: '共享数据根内未找到 dsh 引擎' };
|
|
908
|
+
const backup = path.join(dataHome, 'profiles', 'node_modules', '@deepseek-ai', `.dsh-backup-${Date.now()}`);
|
|
909
|
+
fs.cpSync(dshDir, backup, { recursive: true });
|
|
910
|
+
log('info', `engine update: backup to ${backup}`);
|
|
911
|
+
// 更新兼容插件(引擎删除前,用当前引擎 CLI)
|
|
912
|
+
for (const name of compatToUpdate) {
|
|
913
|
+
try { await runCommand(process.execPath, [engineBin, 'plugin', '--profile', 'web', 'add', name]); log('info', `plugin updated: ${name}`); }
|
|
914
|
+
catch (e) { log('warn', `plugin ${name} update skipped: ${e.message}`); }
|
|
915
|
+
}
|
|
916
|
+
// 停引擎 + 删 dsh + 重启壳(autoBootstrap 重装最新引擎)
|
|
917
|
+
killHarness();
|
|
918
|
+
killWebEngine();
|
|
919
|
+
fs.rmSync(dshDir, { recursive: true, force: true });
|
|
920
|
+
app.relaunch();
|
|
921
|
+
app.exit(0);
|
|
922
|
+
return {
|
|
923
|
+
ok: true,
|
|
924
|
+
message: '引擎已重置,应用重启后将自动重装最新版。'
|
|
925
|
+
+ (compatToUpdate.length ? `\n随引擎更新的插件:${compatToUpdate.join('、')}。` : '')
|
|
926
|
+
+ (kept.length ? `\n因不兼容保留当前版本:${kept.join('、')}。` : '')
|
|
927
|
+
+ '\n如更新后异常,可在托盘「回滚引擎更新」恢复。',
|
|
928
|
+
};
|
|
929
|
+
} catch (e) {
|
|
930
|
+
const msg = e && e.message ? e.message : String(e);
|
|
931
|
+
log('error', `update action failed: ${msg}`);
|
|
932
|
+
return { ok: false, message: `更新失败: ${msg}` };
|
|
739
933
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const
|
|
934
|
+
}
|
|
935
|
+
// 回滚引擎更新:恢复最新备份并重启
|
|
936
|
+
function rollbackEngine() {
|
|
937
|
+
const dir = path.join(dataHome, 'profiles', 'node_modules', '@deepseek-ai');
|
|
938
|
+
let backups = [];
|
|
939
|
+
try { backups = fs.readdirSync(dir).filter((n) => n.startsWith('.dsh-backup-')).sort(); } catch { /* none */ }
|
|
940
|
+
const latest = backups[backups.length - 1];
|
|
941
|
+
if (!latest) { notify('回滚引擎', '没有可回滚的引擎备份'); return; }
|
|
942
|
+
log('info', `engine rollback: restore ${latest}`);
|
|
943
|
+
killHarness();
|
|
944
|
+
killWebEngine();
|
|
945
|
+
fs.rmSync(path.join(dir, 'dsh'), { recursive: true, force: true });
|
|
946
|
+
try { fs.renameSync(path.join(dir, latest), path.join(dir, 'dsh')); }
|
|
947
|
+
catch (e) { log('error', `rollback failed: ${e.message}`); notify('回滚引擎', `回滚失败: ${e.message}`); return; }
|
|
948
|
+
app.relaunch();
|
|
949
|
+
app.exit(0);
|
|
950
|
+
}
|
|
951
|
+
ipcMain.handle('dialog-update', (event, action) => executeUpdate(action || {}));
|
|
952
|
+
|
|
953
|
+
// ---------- 检查更新(托盘入口, npm registry 模式) ----------
|
|
954
|
+
// 桌面壳与 oc 插件均为 npm 包:查询 registry 的 latest 与本地版本对比,只提示不自动下载,
|
|
955
|
+
// 更新动作由用户执行 `npx dsh-oc install`。
|
|
956
|
+
async function runCheckUpdates() {
|
|
957
|
+
showUpdateLoading(); // 先弹窗给加载反馈,检查完成后替换为结果
|
|
958
|
+
const registry = (settings.updateRegistry || 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
959
|
+
const installed = collectInstalled();
|
|
960
|
+
log('info', `check update: ${installed.length} components, registry=${registry}`);
|
|
961
|
+
// 2. 查询 npm latest 并对比
|
|
744
962
|
try {
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
963
|
+
const results = await checkNpmUpdates(installed, registry);
|
|
964
|
+
const updates = results.filter((r) => r.hasUpdate);
|
|
965
|
+
const failed = results.filter((r) => r.error);
|
|
966
|
+
if (failed.length) log('warn', 'check update: failed: ' + failed.map((f) => `${f.name}: ${f.error}`).join('; '));
|
|
967
|
+
// 引擎有新版时,检测各 oc 插件对新引擎的兼容性(一键更新的保留/更新决策)
|
|
968
|
+
const engineResult = results.find((r) => r.name === '@deepseek-ai/dsh');
|
|
969
|
+
let engineDeps = null;
|
|
970
|
+
if (engineResult && engineResult.hasUpdate && !engineResult.error) {
|
|
971
|
+
try { engineDeps = (await npmPackageInfo('@deepseek-ai/dsh', registry)).dependencies; } catch { /* 兼容检测跳过 */ }
|
|
752
972
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}
|
|
762
|
-
|
|
973
|
+
const rows = [];
|
|
974
|
+
for (const r of results) {
|
|
975
|
+
let compatible = null; // null=引擎无更新无需判定;true/false=是否兼容新引擎
|
|
976
|
+
if (engineDeps && r.name !== '@deepseek-ai/dsh' && !r.error) {
|
|
977
|
+
compatible = await checkEngineCompat(r.peerDependencies, engineDeps, async (pkg) => npmPackageVersions(pkg, registry));
|
|
978
|
+
}
|
|
979
|
+
rows.push({ name: r.name, display: OC_DISPLAY[r.name] || r.name, current: r.current,
|
|
980
|
+
latest: r.latest, hasUpdate: r.hasUpdate, error: r.error, description: r.description, compatible });
|
|
981
|
+
}
|
|
982
|
+
let footer = '';
|
|
983
|
+
if (updates.length === 0) {
|
|
984
|
+
footer = `所有组件已是最新(共 ${results.length} 个)`;
|
|
985
|
+
log('info', 'check update: all up to date');
|
|
986
|
+
} else {
|
|
987
|
+
const ocUpd = updates.some((u) => u.name !== '@deepseek-ai/dsh');
|
|
988
|
+
const engineUpd = updates.some((u) => u.name === '@deepseek-ai/dsh');
|
|
989
|
+
if (ocUpd) footer += '一键更新将升级全部 oc 组件';
|
|
990
|
+
if (engineUpd) {
|
|
991
|
+
const kept = rows.filter((r) => r.compatible === false);
|
|
992
|
+
footer += (footer ? '\n' : '') + '引擎有新版:兼容插件随引擎一起更新'
|
|
993
|
+
+ (kept.length ? `;不兼容插件保留(${kept.map((k) => k.display).join('、')})` : '');
|
|
994
|
+
footer += '\n引擎更新后若异常,可在托盘「回滚引擎更新」恢复';
|
|
995
|
+
}
|
|
996
|
+
log('info', 'check update: ' + updates.map((u) => `${u.name} ${u.current}→${u.latest}`).join(', '));
|
|
763
997
|
}
|
|
764
|
-
|
|
765
|
-
return {
|
|
998
|
+
showUpdateDialog('检查更新', rows, footer);
|
|
999
|
+
return { ok: true, results, rows };
|
|
766
1000
|
} catch (e) {
|
|
767
|
-
fs.rmSync(staging, { recursive: true, force: true });
|
|
768
1001
|
const msg = e && e.message ? e.message : String(e);
|
|
769
|
-
log('error', `update
|
|
770
|
-
|
|
1002
|
+
log('error', `check update: ${msg}`);
|
|
1003
|
+
showUpdateDialog('检查更新', [{ display: '检查失败', name: '', current: '—', latest: '—', error: msg, description: msg }], '');
|
|
771
1004
|
return { ok: false, error: msg };
|
|
772
1005
|
}
|
|
773
1006
|
}
|
package/launcher/updater.js
CHANGED
|
@@ -42,6 +42,86 @@ function semverCompare(a, b) {
|
|
|
42
42
|
return pa.pre < pb.pre ? -1 : 1; // 同为预发布,字符串序(骨架够用)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// ---------- 简化 semver 范围匹配(零依赖) ----------
|
|
46
|
+
// 覆盖实际范围:精确/^/~/>/>=/</<=/连字符区间/空格 and/|| or/x 通配。
|
|
47
|
+
// prerelease:范围无预发布标记时,预发布版本不匹配(npm 语义,^base 特判)。
|
|
48
|
+
function semverRangeMatch(range, version) {
|
|
49
|
+
if (range == null || range === '') return true;
|
|
50
|
+
const v = parseVersion(version);
|
|
51
|
+
if (!v) return false;
|
|
52
|
+
return String(range).trim().split(/\s*\|\|\s*/).some((alt) => {
|
|
53
|
+
alt = alt.trim();
|
|
54
|
+
if (!alt || alt === '*' || alt === 'x' || alt === 'X') return true;
|
|
55
|
+
const h = alt.match(/^(\S+)\s+-\s+(\S+)$/);
|
|
56
|
+
if (h) return semverRangeMatch(`>=${h[1]}`, version) && semverRangeMatch(`<=${h[2]}`, version);
|
|
57
|
+
return alt.split(/\s+/).every((p) => matchSingle(p, version));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function matchSingle(p, version) {
|
|
61
|
+
const m = p.match(/^(>=|<=|>|<|=|~|\^)?(.+)$/);
|
|
62
|
+
const op = m[1] || '=';
|
|
63
|
+
const rv = parseVersion(m[2]);
|
|
64
|
+
if (!rv) {
|
|
65
|
+
if (/[xX*]/.test(m[2])) return matchX(op, m[2], version);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const c = () => semverCompare(version, m[2]);
|
|
69
|
+
switch (op) {
|
|
70
|
+
case '=': return c() === 0;
|
|
71
|
+
case '>': return c() > 0;
|
|
72
|
+
case '>=': return c() >= 0;
|
|
73
|
+
case '<': return c() < 0;
|
|
74
|
+
case '<=': return c() <= 0;
|
|
75
|
+
case '~': {
|
|
76
|
+
const parts = m[2].split('.');
|
|
77
|
+
const upper = parts.length >= 2 ? `${rv.num[0]}.${rv.num[1] + 1}.0` : `${rv.num[0] + 1}.0.0`;
|
|
78
|
+
return semverCompare(version, m[2]) >= 0 && semverCompare(version, upper) < 0;
|
|
79
|
+
}
|
|
80
|
+
case '^': {
|
|
81
|
+
let upper;
|
|
82
|
+
if (rv.num[0] > 0) upper = `${rv.num[0] + 1}.0.0`;
|
|
83
|
+
else if (rv.num[1] > 0) upper = `0.${rv.num[1] + 1}.0`;
|
|
84
|
+
else upper = `0.0.${rv.num[2] + 1}`;
|
|
85
|
+
const ge = semverCompare(version, m[2]) >= 0;
|
|
86
|
+
const lt = semverCompare(version, upper) < 0;
|
|
87
|
+
if (ge && lt && !rv.pre) {
|
|
88
|
+
const vp = parseVersion(version);
|
|
89
|
+
if (vp && vp.pre && semverCompare(version, m[2]) !== 0) return false;
|
|
90
|
+
}
|
|
91
|
+
return ge && lt;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
function matchX(op, x, version) {
|
|
97
|
+
const parts = x.replace(/[xX*]/g, '0').split('.');
|
|
98
|
+
const lower = parts.join('.');
|
|
99
|
+
const upper = parts.length === 1 ? `${Number(parts[0]) + 1}.0.0` : `${parts[0]}.${Number(parts[1]) + 1}.0`;
|
|
100
|
+
return semverCompare(version, lower) >= 0 && semverCompare(version, upper) < 0;
|
|
101
|
+
}
|
|
102
|
+
// 范围的下界版本(实际安装的最小版本):^0.1.1-rc.2 → 0.1.1-rc.2;>=1.0.0 → 1.0.0;* → 0.0.0
|
|
103
|
+
function rangeFloor(range) {
|
|
104
|
+
const s = String(range).trim();
|
|
105
|
+
if (!s || s === '*' || s === 'x' || s === 'X') return '0.0.0';
|
|
106
|
+
let best = null;
|
|
107
|
+
for (const alt of s.split(/\s*\|\|\s*/)) {
|
|
108
|
+
let floor = null;
|
|
109
|
+
for (const p of alt.trim().split(/\s+/)) {
|
|
110
|
+
const m = p.match(/^(>=|>|=|~|\^)?(.+)$/);
|
|
111
|
+
const raw = m ? m[2] : p;
|
|
112
|
+
const v = raw.replace(/[xX*]/g, '0');
|
|
113
|
+
if (!parseVersion(v)) continue;
|
|
114
|
+
if (m && m[1] === '>') {
|
|
115
|
+
const pv = parseVersion(v);
|
|
116
|
+
if (pv) v = `${pv.num[0]}.${pv.num[1]}.${pv.num[2] + 1}`;
|
|
117
|
+
}
|
|
118
|
+
if (floor === null || semverCompare(v, floor) > 0) floor = v;
|
|
119
|
+
}
|
|
120
|
+
if (floor && (best === null || semverCompare(floor, best) > 0)) best = floor;
|
|
121
|
+
}
|
|
122
|
+
return best || '0.0.0';
|
|
123
|
+
}
|
|
124
|
+
|
|
45
125
|
// GET 一个 JSON(支持 http/https),带超时与体积上限。
|
|
46
126
|
function fetchJson(url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
47
127
|
return new Promise((resolve, reject) => {
|
|
@@ -182,7 +262,77 @@ async function downloadUpdate(baseUrl, needList, stagingDir, onProgress) {
|
|
|
182
262
|
return done;
|
|
183
263
|
}
|
|
184
264
|
|
|
265
|
+
// ---------- npm registry 检查(当前发布模式) ----------
|
|
266
|
+
// 桌面壳与各 oc 插件独立发布为 npm 包,「检查更新」改为查询 npm registry 的
|
|
267
|
+
// latest 版本并与本地已装版本对比,只提示不自动下载(npx dsh-oc install 负责更新)。
|
|
268
|
+
|
|
269
|
+
// 查询某包在 npm registry 上的 latest 版本号。registry 默认官方源。
|
|
270
|
+
async function npmLatestVersion(pkg, registry = 'https://registry.npmjs.org') {
|
|
271
|
+
const info = await npmPackageInfo(pkg, registry);
|
|
272
|
+
return info.version;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 查询某包 latest 的完整信息(版本 + 描述 + 依赖声明)。
|
|
276
|
+
async function npmPackageInfo(pkg, registry = 'https://registry.npmjs.org') {
|
|
277
|
+
const base = String(registry).replace(/\/+$/, '');
|
|
278
|
+
const url = base + '/' + encodeURIComponent(pkg) + '/latest';
|
|
279
|
+
const data = await fetchJson(url);
|
|
280
|
+
if (!data || !data.version) throw new Error(`npm \u672a\u8fd4\u56de ${pkg} \u7684 latest \u7248\u672c`);
|
|
281
|
+
return {
|
|
282
|
+
version: String(data.version),
|
|
283
|
+
description: (data.description || '').trim(),
|
|
284
|
+
dependencies: data.dependencies || {},
|
|
285
|
+
peerDependencies: data.peerDependencies || {},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 批量检查版本。installed: [{name,current}] → [{name,current,latest,description,hasUpdate,error?}]
|
|
290
|
+
// 单包查询失败不中断整体,error 字段标记。
|
|
291
|
+
async function checkNpmUpdates(installed, registry = 'https://registry.npmjs.org') {
|
|
292
|
+
const out = [];
|
|
293
|
+
for (const it of installed) {
|
|
294
|
+
try {
|
|
295
|
+
const info = await npmPackageInfo(it.name, registry);
|
|
296
|
+
out.push({ name: it.name, current: it.current, latest: info.version, description: info.description, peerDependencies: info.peerDependencies, hasUpdate: semverCompare(info.version, it.current) > 0 });
|
|
297
|
+
} catch (e) {
|
|
298
|
+
out.push({ name: it.name, current: it.current, latest: null, description: '', peerDependencies: {}, hasUpdate: false, error: e && e.message ? e.message : String(e) });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 查询某包在 npm registry 上的所有已发布版本号(数组)。
|
|
305
|
+
async function npmPackageVersions(pkg, registry = 'https://registry.npmjs.org') {
|
|
306
|
+
const base = String(registry).replace(/\/+$/, '');
|
|
307
|
+
const data = await fetchJson(base + '/' + encodeURIComponent(pkg));
|
|
308
|
+
return Object.keys(data.versions || {});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 判定插件(peerDependencies)是否兼容指定引擎。
|
|
312
|
+
// 对插件 peerDeps 中每个 @deepseek-ai/* 包:
|
|
313
|
+
// - 引擎 deps 含该包 → 用引擎 dep 范围的下界版本测试插件范围
|
|
314
|
+
// - 引擎不含 → 通过 fetchVersions(pkg) 查 npm 该包全量版本,存在满足插件范围的版本即兼容
|
|
315
|
+
// (这些 dsh-client-* 包用 next tag 发 rc 版,latest tag 常停在早期版,不能用 /latest 判断)
|
|
316
|
+
// 全部满足 → 兼容。fetchVersions: async (pkg) => [版本字符串,...]
|
|
317
|
+
async function checkEngineCompat(pluginPeerDeps, engineDeps, fetchVersions) {
|
|
318
|
+
const scope = Object.keys(pluginPeerDeps || {}).filter((k) => k.startsWith('@deepseek-ai/'));
|
|
319
|
+
if (scope.length === 0) return true;
|
|
320
|
+
for (const k of scope) {
|
|
321
|
+
const needRange = pluginPeerDeps[k];
|
|
322
|
+
if (engineDeps && engineDeps[k]) {
|
|
323
|
+
if (!semverRangeMatch(needRange, rangeFloor(engineDeps[k]))) return false;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (typeof fetchVersions !== 'function') return false;
|
|
327
|
+
let versions = [];
|
|
328
|
+
try { versions = await fetchVersions(k); } catch { return false; }
|
|
329
|
+
if (!versions.some((v) => semverRangeMatch(needRange, v))) return false;
|
|
330
|
+
}
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
185
334
|
module.exports = {
|
|
186
|
-
parseVersion, semverCompare,
|
|
187
|
-
downloadFile, downloadUpdate,
|
|
335
|
+
parseVersion, semverCompare, semverRangeMatch, rangeFloor, fetchJson, checkForUpdates,
|
|
336
|
+
hashFile, diffLocal, downloadFile, downloadUpdate, npmLatestVersion, npmPackageInfo,
|
|
337
|
+
npmPackageVersions, checkNpmUpdates, checkEngineCompat,
|
|
188
338
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-oc-desktop",
|
|
3
3
|
"productName": "DeepSeek Harness Desktop",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.8",
|
|
5
5
|
"description": "DeepSeek Harness 桌面端插件(Electron 启动器):自绘标题栏/托盘/通知/快捷键/崩溃自愈",
|
|
6
6
|
"main": "launcher/main.js",
|
|
7
7
|
"bin": {
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "electron .",
|
|
12
|
-
"test": "node --check launcher/main.js && node --check launcher/preload.js && node --check launcher/events.js && node --check launcher/session-window.js && node --check launcher/updater.js && node --check launcher/apply-update.js && node --check bin/dsh-desktop.cjs && node scripts/test.mjs"
|
|
12
|
+
"test": "node --check launcher/main.js && node --check launcher/preload.js && node --check launcher/dialog-preload.js && node --check launcher/events.js && node --check launcher/session-window.js && node --check launcher/updater.js && node --check launcher/apply-update.js && node --check bin/dsh-desktop.cjs && node scripts/test.mjs && node scripts/test-updater.mjs"
|
|
13
13
|
},
|
|
14
|
-
"
|
|
14
|
+
"dependencies": {
|
|
15
15
|
"electron": "^43.4.0"
|
|
16
16
|
},
|
|
17
17
|
"files": ["launcher", "bin", "assets", "scripts"],
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$Exe = (Join-Path (Split-Path $PSScriptRoot -Parent) 'node_modules\electron\dist\electron.exe'),
|
|
3
|
+
[string]$Ico = (Join-Path (Split-Path $PSScriptRoot -Parent) 'assets\icon.ico')
|
|
4
|
+
)
|
|
5
|
+
$ErrorActionPreference = 'Stop'
|
|
6
|
+
|
|
7
|
+
$cs = @"
|
|
8
|
+
using System;
|
|
9
|
+
using System.Collections.Generic;
|
|
10
|
+
using System.IO;
|
|
11
|
+
using System.Runtime.InteropServices;
|
|
12
|
+
|
|
13
|
+
public static class ExeIconSet {
|
|
14
|
+
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
|
15
|
+
static extern IntPtr BeginUpdateResource(string pFileName, bool bDeleteExistingResources);
|
|
16
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
17
|
+
static extern bool UpdateResource(IntPtr hUpdate, IntPtr lpType, IntPtr lpName, ushort wLanguage, IntPtr lpData, uint cbData);
|
|
18
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
19
|
+
static extern bool EndUpdateResource(IntPtr hUpdate, bool fDiscard);
|
|
20
|
+
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
|
21
|
+
static extern IntPtr LoadLibraryEx(string lpFileName, IntPtr hFile, uint dwFlags);
|
|
22
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
23
|
+
static extern bool FreeLibrary(IntPtr hModule);
|
|
24
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
25
|
+
static extern bool EnumResourceNames(IntPtr hModule, IntPtr lpType, EnumResNameProc lpEnumFunc, IntPtr lParam);
|
|
26
|
+
delegate bool EnumResNameProc(IntPtr hModule, IntPtr lpType, IntPtr lpName, IntPtr lParam);
|
|
27
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
28
|
+
static extern IntPtr FindResourceEx(IntPtr hModule, IntPtr lpType, IntPtr lpName, ushort wLanguage);
|
|
29
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
30
|
+
static extern IntPtr LoadResource(IntPtr hModule, IntPtr hResInfo);
|
|
31
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
32
|
+
static extern uint SizeofResource(IntPtr hModule, IntPtr hResInfo);
|
|
33
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
34
|
+
static extern IntPtr LockResource(IntPtr hResData);
|
|
35
|
+
|
|
36
|
+
const int RT_ICON = 3;
|
|
37
|
+
const int RT_GROUP_ICON = 14;
|
|
38
|
+
const int RT_VERSION = 16;
|
|
39
|
+
const int LOAD_LIBRARY_AS_DATAFILE = 0x00000002;
|
|
40
|
+
|
|
41
|
+
static IntPtr MakeIntResource(ushort w) { return new IntPtr(w); }
|
|
42
|
+
static bool IsInt(IntPtr p) { return ((long)p >> 16) == 0; }
|
|
43
|
+
|
|
44
|
+
static List<IntPtr> EnumType(IntPtr hMod, int type) {
|
|
45
|
+
var names = new List<IntPtr>();
|
|
46
|
+
EnumResourceNames(hMod, MakeIntResource((ushort)type),
|
|
47
|
+
(h, t, n, l) => { names.Add(n); return true; }, IntPtr.Zero);
|
|
48
|
+
return names;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public static int SetIcon(string exePath, string icoPath) {
|
|
52
|
+
var ico = File.ReadAllBytes(icoPath);
|
|
53
|
+
if (ico.Length < 6) return -1;
|
|
54
|
+
int count = BitConverter.ToUInt16(ico, 4);
|
|
55
|
+
if (count < 1 || count > 64) return -2;
|
|
56
|
+
int off = 6;
|
|
57
|
+
var entries = new List<byte[]>();
|
|
58
|
+
for (int i = 0; i < count; i++) {
|
|
59
|
+
if (off + 16 > ico.Length) return -3;
|
|
60
|
+
int dataOff = BitConverter.ToInt32(ico, off + 12);
|
|
61
|
+
int dataLen = BitConverter.ToInt32(ico, off + 8);
|
|
62
|
+
if (dataOff + dataLen > ico.Length) return -4;
|
|
63
|
+
entries.Add(ico.SubArray(dataOff, dataLen));
|
|
64
|
+
off += 16;
|
|
65
|
+
}
|
|
66
|
+
// collect existing icon resource names so we can delete them all
|
|
67
|
+
var hLib = LoadLibraryEx(exePath, IntPtr.Zero, LOAD_LIBRARY_AS_DATAFILE);
|
|
68
|
+
if (hLib == IntPtr.Zero) return -5;
|
|
69
|
+
var icons = EnumType(hLib, RT_ICON);
|
|
70
|
+
var groups = EnumType(hLib, RT_GROUP_ICON);
|
|
71
|
+
FreeLibrary(hLib);
|
|
72
|
+
|
|
73
|
+
var hUpd = BeginUpdateResource(exePath, false);
|
|
74
|
+
if (hUpd == IntPtr.Zero) return -6;
|
|
75
|
+
|
|
76
|
+
// OVERWRITE (no delete): UpdateResource replaces same-name+lang resources.
|
|
77
|
+
// The exe ships RT_ICON #1..#N and RT_GROUP_ICON #1, we reuse those ids so the
|
|
78
|
+
// group icon keeps resolving; extra frames get new ids.
|
|
79
|
+
|
|
80
|
+
// write new RT_ICON resources, ids 1..N
|
|
81
|
+
var ids = new List<int>();
|
|
82
|
+
for (int i = 0; i < entries.Count; i++) {
|
|
83
|
+
int id = i + 1;
|
|
84
|
+
ids.Add(id);
|
|
85
|
+
byte[] data = entries[i];
|
|
86
|
+
IntPtr pData = Marshal.AllocHGlobal(data.Length);
|
|
87
|
+
try {
|
|
88
|
+
Marshal.Copy(data, 0, pData, data.Length);
|
|
89
|
+
if (!UpdateResource(hUpd, MakeIntResource(RT_ICON), MakeIntResource((ushort)id), 1033, pData, (uint)data.Length))
|
|
90
|
+
return -1000 - Marshal.GetLastWin32Error();
|
|
91
|
+
} finally { Marshal.FreeHGlobal(pData); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// build GRPICONDIR
|
|
95
|
+
var grp = new List<byte>();
|
|
96
|
+
grp.AddRange(BitConverter.GetBytes((ushort)0)); // idReserved
|
|
97
|
+
grp.AddRange(BitConverter.GetBytes((ushort)1)); // idType = icon
|
|
98
|
+
grp.AddRange(BitConverter.GetBytes((ushort)count)); // idCount
|
|
99
|
+
off = 6;
|
|
100
|
+
for (int i = 0; i < count; i++) {
|
|
101
|
+
byte w = ico[off], h = ico[off + 1];
|
|
102
|
+
byte colorCount = ico[off + 2], res = ico[off + 3];
|
|
103
|
+
ushort planes = BitConverter.ToUInt16(ico, off + 4);
|
|
104
|
+
ushort bitCount = BitConverter.ToUInt16(ico, off + 6);
|
|
105
|
+
int dataLen = BitConverter.ToInt32(ico, off + 8);
|
|
106
|
+
grp.Add(w); grp.Add(h); grp.Add(colorCount); grp.Add(res);
|
|
107
|
+
grp.AddRange(BitConverter.GetBytes(planes));
|
|
108
|
+
grp.AddRange(BitConverter.GetBytes(bitCount));
|
|
109
|
+
grp.AddRange(BitConverter.GetBytes((uint)dataLen));
|
|
110
|
+
grp.AddRange(BitConverter.GetBytes((ushort)ids[i]));
|
|
111
|
+
off += 16;
|
|
112
|
+
}
|
|
113
|
+
byte[] gdata = grp.ToArray();
|
|
114
|
+
IntPtr pGrp = Marshal.AllocHGlobal(gdata.Length);
|
|
115
|
+
try {
|
|
116
|
+
Marshal.Copy(gdata, 0, pGrp, gdata.Length);
|
|
117
|
+
if (!UpdateResource(hUpd, MakeIntResource(RT_GROUP_ICON), MakeIntResource(1), 1033, pGrp, (uint)gdata.Length))
|
|
118
|
+
return -8;
|
|
119
|
+
} finally { Marshal.FreeHGlobal(pGrp); }
|
|
120
|
+
|
|
121
|
+
if (!EndUpdateResource(hUpd, false)) return -9;
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Patch ProductName / FileDescription in the RT_VERSION resource so the Task
|
|
126
|
+
// Manager shows "DSH Desktop" instead of "Electron".
|
|
127
|
+
public static int SetVersion(string exePath, string product, string desc) {
|
|
128
|
+
var hLib = LoadLibraryEx(exePath, IntPtr.Zero, LOAD_LIBRARY_AS_DATAFILE);
|
|
129
|
+
if (hLib == IntPtr.Zero) return -50;
|
|
130
|
+
IntPtr hRes = FindResourceEx(hLib, MakeIntResource(RT_VERSION), MakeIntResource(1), 1033);
|
|
131
|
+
if (hRes == IntPtr.Zero) { FreeLibrary(hLib); return -51; }
|
|
132
|
+
uint size = SizeofResource(hLib, hRes);
|
|
133
|
+
IntPtr pLocked = LockResource(LoadResource(hLib, hRes));
|
|
134
|
+
if (pLocked == IntPtr.Zero || size == 0) { FreeLibrary(hLib); return -52; }
|
|
135
|
+
byte[] data = new byte[size];
|
|
136
|
+
Marshal.Copy(pLocked, data, 0, (int)size);
|
|
137
|
+
FreeLibrary(hLib);
|
|
138
|
+
|
|
139
|
+
byte[] patched = ViPatch.Patch(data, product, desc);
|
|
140
|
+
var hUpd = BeginUpdateResource(exePath, false);
|
|
141
|
+
if (hUpd == IntPtr.Zero) return -53;
|
|
142
|
+
IntPtr p = Marshal.AllocHGlobal(patched.Length);
|
|
143
|
+
try {
|
|
144
|
+
Marshal.Copy(patched, 0, p, patched.Length);
|
|
145
|
+
if (!UpdateResource(hUpd, MakeIntResource(RT_VERSION), MakeIntResource(1), 1033, p, (uint)patched.Length))
|
|
146
|
+
return -1000 - Marshal.GetLastWin32Error();
|
|
147
|
+
} finally { Marshal.FreeHGlobal(p); }
|
|
148
|
+
if (!EndUpdateResource(hUpd, false)) return -2000 - Marshal.GetLastWin32Error();
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
static void DeleteRes(IntPtr hUpd, int type, IntPtr name) {
|
|
153
|
+
UpdateResource(hUpd, MakeIntResource((ushort)type), name, 0, IntPtr.Zero, 0);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// VERSIONINFO tree parser/rebuilder (keeps every other string, replaces
|
|
158
|
+
// ProductName / FileDescription). Layout per MS-VERSIONINFO.
|
|
159
|
+
public static class ViPatch {
|
|
160
|
+
public class Block {
|
|
161
|
+
public ushort wValueLength;
|
|
162
|
+
public ushort wType;
|
|
163
|
+
public string szKey = "";
|
|
164
|
+
public byte[] value;
|
|
165
|
+
public List<Block> children = new List<Block>();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
static int Align4(int v) { int r = v % 4; return r == 0 ? v : v + (4 - r); }
|
|
169
|
+
|
|
170
|
+
static int ReadBlock(byte[] d, int off, out Block b) {
|
|
171
|
+
b = new Block();
|
|
172
|
+
ushort wLength = BitConverter.ToUInt16(d, off);
|
|
173
|
+
b.wValueLength = BitConverter.ToUInt16(d, off + 2);
|
|
174
|
+
b.wType = BitConverter.ToUInt16(d, off + 4);
|
|
175
|
+
int p = off + 6;
|
|
176
|
+
int ks = p;
|
|
177
|
+
while (BitConverter.ToUInt16(d, p) != 0) p += 2;
|
|
178
|
+
b.szKey = System.Text.Encoding.Unicode.GetString(d, ks, p - ks);
|
|
179
|
+
p += 2;
|
|
180
|
+
while ((p - off) % 4 != 0) p++;
|
|
181
|
+
bool leaf = b.wValueLength > 0 && b.szKey != "VS_VERSION_INFO";
|
|
182
|
+
if (b.wValueLength > 0) {
|
|
183
|
+
// VS_FIXEDFILEINFO (root) stores bytes; String/Var leaves store the value
|
|
184
|
+
// up to the block end (their wValueLength counts UTF-16 units for Strings).
|
|
185
|
+
int vbytes = (b.szKey == "VS_VERSION_INFO") ? b.wValueLength : ((off + wLength) - p);
|
|
186
|
+
b.value = new byte[vbytes];
|
|
187
|
+
Array.Copy(d, p, b.value, 0, vbytes);
|
|
188
|
+
p += vbytes;
|
|
189
|
+
while ((p - off) % 4 != 0) p++;
|
|
190
|
+
}
|
|
191
|
+
// Sibling blocks are aligned to 4 bytes; the block's wLength ends at its
|
|
192
|
+
// content tail, so the next block starts at align4(off + wLength).
|
|
193
|
+
int next = Align4(off + wLength);
|
|
194
|
+
if (leaf) return next;
|
|
195
|
+
int guard = 0;
|
|
196
|
+
while (p < Align4(off + wLength) && guard++ < 256) {
|
|
197
|
+
Block c;
|
|
198
|
+
p = ReadBlock(d, p, out c);
|
|
199
|
+
b.children.Add(c);
|
|
200
|
+
}
|
|
201
|
+
return next;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
static int CalcLen(Block b) {
|
|
205
|
+
int len = 6 + (b.szKey.Length + 1) * 2;
|
|
206
|
+
while (len % 4 != 0) len++;
|
|
207
|
+
if (b.value != null && b.value.Length > 0) {
|
|
208
|
+
len += b.value.Length;
|
|
209
|
+
while (len % 4 != 0) len++;
|
|
210
|
+
}
|
|
211
|
+
foreach (var c in b.children) {
|
|
212
|
+
len += CalcLen(c);
|
|
213
|
+
while (len % 4 != 0) len++; // pad each child block end to a 4-byte boundary
|
|
214
|
+
}
|
|
215
|
+
return len;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
static void WriteBlock(BinaryWriter w, Block b) {
|
|
219
|
+
long start = w.BaseStream.Position;
|
|
220
|
+
w.Write((ushort)0); // wLength placeholder, patched after children are written
|
|
221
|
+
w.Write(b.wValueLength);
|
|
222
|
+
w.Write(b.wType);
|
|
223
|
+
w.Write(System.Text.Encoding.Unicode.GetBytes(b.szKey + "\0"));
|
|
224
|
+
while ((w.BaseStream.Position - start) % 4 != 0) w.Write((byte)0);
|
|
225
|
+
if (b.value != null && b.value.Length > 0) {
|
|
226
|
+
w.Write(b.value);
|
|
227
|
+
while ((w.BaseStream.Position - start) % 4 != 0) w.Write((byte)0);
|
|
228
|
+
}
|
|
229
|
+
foreach (var c in b.children) {
|
|
230
|
+
WriteBlock(w, c);
|
|
231
|
+
while ((w.BaseStream.Position - start) % 4 != 0) w.Write((byte)0);
|
|
232
|
+
}
|
|
233
|
+
long end = w.BaseStream.Position;
|
|
234
|
+
long pos = w.BaseStream.Position;
|
|
235
|
+
w.BaseStream.Position = start;
|
|
236
|
+
w.Write((ushort)(end - start));
|
|
237
|
+
w.BaseStream.Position = pos;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
public static byte[] Patch(byte[] data, string product, string desc) {
|
|
241
|
+
Block root;
|
|
242
|
+
ReadBlock(data, 0, out root);
|
|
243
|
+
foreach (var sfi in root.children) {
|
|
244
|
+
if (sfi.szKey != "StringFileInfo") continue;
|
|
245
|
+
foreach (var st in sfi.children) {
|
|
246
|
+
foreach (var s in st.children) {
|
|
247
|
+
if (s.szKey == "ProductName" && product != null) { s.value = System.Text.Encoding.Unicode.GetBytes(product + "\0"); s.wValueLength = (ushort)(s.value.Length / 2); }
|
|
248
|
+
else if (s.szKey == "FileDescription" && desc != null) { s.value = System.Text.Encoding.Unicode.GetBytes(desc + "\0"); s.wValueLength = (ushort)(s.value.Length / 2); }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
using (var ms = new MemoryStream()) {
|
|
253
|
+
var w = new BinaryWriter(ms);
|
|
254
|
+
WriteBlock(w, root);
|
|
255
|
+
w.Flush();
|
|
256
|
+
return ms.ToArray();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public static class Ext {
|
|
262
|
+
public static byte[] SubArray(this byte[] src, int start, int len) {
|
|
263
|
+
var d = new byte[len];
|
|
264
|
+
Array.Copy(src, start, d, 0, len);
|
|
265
|
+
return d;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
"@
|
|
269
|
+
|
|
270
|
+
Add-Type -TypeDefinition $cs -Language CSharp
|
|
271
|
+
Write-Host "processing $Exe with $Ico"
|
|
272
|
+
$code = [ExeIconSet]::SetIcon($Exe, $Ico)
|
|
273
|
+
Write-Host "SetIcon exit=$code (0=ok)"
|
|
274
|
+
$vcode = [ExeIconSet]::SetVersion($Exe, 'DSH Desktop', 'DSH Desktop')
|
|
275
|
+
Write-Host "SetVersion exit=$vcode (0=ok)"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// updater npm-registry checks: local http server mocking the npm /pkg/latest endpoint.
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { npmLatestVersion, npmPackageVersions, checkNpmUpdates, checkEngineCompat, semverRangeMatch, rangeFloor, semverCompare, parseVersion } = require("../launcher/updater.js");
|
|
7
|
+
|
|
8
|
+
// 1. pure version compare logic
|
|
9
|
+
assert.strictEqual(parseVersion("0.3.6").num.join("."), "0.3.6");
|
|
10
|
+
assert.strictEqual(semverCompare("0.3.7", "0.3.6"), 1);
|
|
11
|
+
assert.strictEqual(semverCompare("0.3.6", "0.3.6"), 0);
|
|
12
|
+
assert.strictEqual(semverCompare("0.3.6", "0.4.0"), -1);
|
|
13
|
+
assert.strictEqual(semverCompare("0.3.7-rc.1", "0.3.7"), -1); // prerelease < release
|
|
14
|
+
|
|
15
|
+
// 2. semver range matching (the compat checks rely on it)
|
|
16
|
+
assert.strictEqual(semverRangeMatch("^4.0.1", "4.0.1"), true);
|
|
17
|
+
assert.strictEqual(semverRangeMatch("^4.0.1", "4.2.0"), true);
|
|
18
|
+
assert.strictEqual(semverRangeMatch("^4.0.1", "5.0.0"), false);
|
|
19
|
+
assert.strictEqual(semverRangeMatch("^0.1.0-rc.6", "0.1.1-rc.2"), true); // caret w/ prerelease
|
|
20
|
+
assert.strictEqual(semverRangeMatch("^0.1.0-rc.6", "0.1.0-rc.6"), true);
|
|
21
|
+
assert.strictEqual(semverRangeMatch("^0.1.0-rc.6", "0.2.0"), false);
|
|
22
|
+
assert.strictEqual(semverRangeMatch("^1.0.0", "1.0.0-rc.1"), false); // release range vs prerelease
|
|
23
|
+
assert.strictEqual(semverRangeMatch(">=0.1.0", "0.1.5"), true);
|
|
24
|
+
assert.strictEqual(semverRangeMatch("1.2.x", "1.2.5"), true);
|
|
25
|
+
assert.strictEqual(semverRangeMatch("1.2.x", "1.3.0"), false);
|
|
26
|
+
assert.strictEqual(semverRangeMatch("*", "9.9.9"), true);
|
|
27
|
+
assert.strictEqual(rangeFloor("^0.1.1-rc.2"), "0.1.1-rc.2");
|
|
28
|
+
assert.strictEqual(rangeFloor(">=1.0.0"), "1.0.0");
|
|
29
|
+
|
|
30
|
+
// 3. local registry mock: /pkg/latest for metadata, /pkg for the full version list
|
|
31
|
+
const registry = http.createServer((req, res) => {
|
|
32
|
+
const url = req.url || "";
|
|
33
|
+
if (url.endsWith("/latest")) {
|
|
34
|
+
const m = url.match(/^\/([^/]+)\/latest$/);
|
|
35
|
+
if (!m) { res.writeHead(404); return res.end(); }
|
|
36
|
+
const pkg = decodeURIComponent(m[1]);
|
|
37
|
+
const meta = {
|
|
38
|
+
"dsh-oc-desktop": { v: "0.3.7", d: "mock desktop pkg", peer: { "@deepseek-ai/cordis": "^4.0.1" } },
|
|
39
|
+
"dsh-oc-sidebar": { v: "0.15.0", d: "mock sidebar pkg", peer: { "@deepseek-ai/cordis": "^4.0.1" } },
|
|
40
|
+
"@deepseek-ai/dsh": { v: "0.1.2", d: "mock engine" },
|
|
41
|
+
"@deepseek-ai/cordis": { v: "4.2.0", d: "cordis" },
|
|
42
|
+
"@deepseek-ai/dsh-client-locale": { v: "0.0.1-rc.1", d: "locale", peer: {} },
|
|
43
|
+
};
|
|
44
|
+
if (!meta[pkg]) { res.writeHead(404); return res.end("not found"); }
|
|
45
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
46
|
+
res.end(JSON.stringify({ name: pkg, version: meta[pkg].v, description: meta[pkg].d, peerDependencies: meta[pkg].peer || {} }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// full package doc: { versions: { "1.0.0": {...}, ... } }
|
|
50
|
+
const m = url.match(/^\/([^/]+)$/);
|
|
51
|
+
if (!m) { res.writeHead(404); return res.end(); }
|
|
52
|
+
const pkg = decodeURIComponent(m[1]);
|
|
53
|
+
const all = {
|
|
54
|
+
"@deepseek-ai/cordis": ["4.0.1", "4.2.0"],
|
|
55
|
+
"@deepseek-ai/dsh-client-locale": ["0.0.1-rc.1", "0.1.0-rc.6", "0.1.1-rc.2"],
|
|
56
|
+
"@deepseek-ai/missing-pkg": null,
|
|
57
|
+
};
|
|
58
|
+
if (!all[pkg]) { res.writeHead(404); return res.end("not found"); }
|
|
59
|
+
const versions = {};
|
|
60
|
+
for (const v of all[pkg]) versions[v] = { version: v };
|
|
61
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
62
|
+
res.end(JSON.stringify({ versions }));
|
|
63
|
+
});
|
|
64
|
+
await new Promise((r) => registry.listen(0, "127.0.0.1", r));
|
|
65
|
+
const base = `http://127.0.0.1:${registry.address().port}`;
|
|
66
|
+
|
|
67
|
+
// npmLatestVersion resolves the latest tag
|
|
68
|
+
assert.strictEqual(await npmLatestVersion("dsh-oc-desktop", base), "0.3.7");
|
|
69
|
+
// scoped package name is URL-encoded (encodeURIComponent keeps @ and / escaped)
|
|
70
|
+
assert.strictEqual(await npmLatestVersion("@deepseek-ai/dsh", base), "0.1.2");
|
|
71
|
+
// unknown package -> throws
|
|
72
|
+
await assert.rejects(npmLatestVersion("missing-pkg", base));
|
|
73
|
+
|
|
74
|
+
// checkNpmUpdates: marks hasUpdate, tolerates per-package errors
|
|
75
|
+
const results = await checkNpmUpdates(
|
|
76
|
+
[{ name: "dsh-oc-desktop", current: "0.3.6" }, { name: "dsh-oc-sidebar", current: "0.15.0" }, { name: "missing-pkg", current: "1.0.0" }],
|
|
77
|
+
base
|
|
78
|
+
);
|
|
79
|
+
const byName = Object.fromEntries(results.map((r) => [r.name, r]));
|
|
80
|
+
assert.strictEqual(byName["dsh-oc-desktop"].hasUpdate, true);
|
|
81
|
+
assert.strictEqual(byName["dsh-oc-desktop"].latest, "0.3.7");
|
|
82
|
+
assert.strictEqual(byName["dsh-oc-desktop"].description, "mock desktop pkg");
|
|
83
|
+
assert.strictEqual(byName["dsh-oc-sidebar"].hasUpdate, false);
|
|
84
|
+
assert.strictEqual(byName["missing-pkg"].error, "更新服务器返回 HTTP 404");
|
|
85
|
+
assert.strictEqual(byName["missing-pkg"].hasUpdate, false);
|
|
86
|
+
|
|
87
|
+
// 4. engine compat: plugin peerDeps vs engine deps (or full npm version list)
|
|
88
|
+
const fetchVersions = async (p) => (await npmPackageVersions(p, base));
|
|
89
|
+
const engineDeps = { "@deepseek-ai/cordis": "^4.0.1" };
|
|
90
|
+
assert.strictEqual(await checkEngineCompat({ "@deepseek-ai/cordis": "^4.0.1" }, engineDeps, fetchVersions), true);
|
|
91
|
+
assert.strictEqual(await checkEngineCompat({ "@deepseek-ai/cordis": "^3.0.0" }, engineDeps, fetchVersions), false);
|
|
92
|
+
// latest tag is 0.0.1-rc.1 but the full version list has 0.1.0-rc.6 (next tag) — compatible
|
|
93
|
+
assert.strictEqual(await checkEngineCompat({ "@deepseek-ai/cordis": "^4.0.1", "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6" }, engineDeps, fetchVersions), true);
|
|
94
|
+
// engine-independent package with no satisfying version -> incompatible
|
|
95
|
+
assert.strictEqual(await checkEngineCompat({ "@deepseek-ai/cordis": "^4.0.1", "@deepseek-ai/missing-pkg": "^1.0.0" }, engineDeps, fetchVersions), false);
|
|
96
|
+
// package not in engine deps but its versions satisfy the plugin range
|
|
97
|
+
assert.strictEqual(await checkEngineCompat({ "@deepseek-ai/cordis": "^4.0.1" }, {}, fetchVersions), true);
|
|
98
|
+
|
|
99
|
+
registry.close();
|
|
100
|
+
console.log("updater tests passed");
|