dsh-oc-desktop 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+ // dsh-desktop preload(沙箱内运行)
3
+ // 职责:暴露最小桥 dshNative;自绘标题栏(纯壳能力,不依赖 SPA 结构):
4
+ // 32px 高、固定深色背景、1px 底部分隔线;左侧 32x32 Logo 区(24px 鲸鱼)+ 应用名;
5
+ // 右侧 46x32 窗口按钮(10px 系统字形 Segoe Fluent Icons),仿 Win11 悬停,
6
+ // 关闭键红底白字;最大化/还原字形随窗口状态切换。
7
+ // 注意:不再注入任何 SPA 修改(无折叠按钮/卡片圆角/主题同步/更新按钮)——
8
+ // SPA 保持官方原生显示。
9
+
10
+ const { contextBridge, ipcRenderer } = require('electron');
11
+
12
+ const customTitleBar = process.argv.includes('--dsh-custom-titlebar=1');
13
+ const openSessionArg = process.argv.find((a) => a.startsWith('--dsh-open-session='));
14
+ const openSessionId = openSessionArg ? openSessionArg.slice('--dsh-open-session='.length) : null;
15
+ if (openSessionId && /^session-[A-Za-z0-9-]+$/.test(openSessionId)) {
16
+ try { history.replaceState(null, '', '/s/' + encodeURIComponent(openSessionId) + window.location.search); } catch { /* 失败不阻塞窗口 */ }
17
+ }
18
+
19
+ async function injectTitleBar() {
20
+ if (document.getElementById('dsh-titlebar')) return;
21
+ const style = document.createElement('style');
22
+ style.textContent = `
23
+ html{height:100%}
24
+ body{height:100%;box-sizing:border-box;margin:0;padding-top:32px}
25
+ /* 主题色由 applyDshTheme 依据 SPA 的 data-ds-dark-theme 设置(默认深色) */
26
+ :root{--tb-bg:#14161a;--tb-fg:#8a8a8a;--tb-icon:#c9c9c9;--tb-logo:#e6e6e6;
27
+ --tb-hover:rgba(255,255,255,.06);--tb-active:rgba(255,255,255,.10);--tb-line:rgba(255,255,255,.06)}
28
+ :root[data-dsh-theme="light"]{--tb-bg:#f3f3f3;--tb-fg:#1f1f1f;--tb-icon:#1f1f1f;--tb-logo:#111827;
29
+ --tb-hover:rgba(0,0,0,.05);--tb-active:rgba(0,0,0,.08);--tb-line:rgba(0,0,0,.08)}
30
+ #dsh-titlebar{position:fixed;top:0;left:0;right:0;height:32px;z-index:2147483647;display:flex;align-items:center;
31
+ -webkit-app-region:drag;user-select:none;font-family:'Segoe UI','Microsoft YaHei',system-ui,sans-serif;
32
+ background:var(--tb-bg);border-bottom:1px solid var(--tb-line)}
33
+ #dsh-titlebar .tb-brand{display:flex;align-items:center;gap:8px;padding-left:12px;height:32px;font-size:12px;font-weight:600;
34
+ color:var(--tb-fg);letter-spacing:.2px;white-space:nowrap}
35
+ #dsh-titlebar .tb-logo{width:32px;height:32px;display:flex;align-items:center;justify-content:center}
36
+ #dsh-titlebar .tb-logo svg{width:24px;height:24px;border-radius:6px;color:var(--tb-logo)}
37
+ #dsh-titlebar .tb-btns{margin-left:auto;display:flex;height:32px;-webkit-app-region:no-drag}
38
+ #dsh-titlebar .tb-btn{width:46px;height:32px;display:flex;align-items:center;justify-content:center;padding:0;
39
+ border:none;background:transparent;color:var(--tb-icon);outline:none;
40
+ font-family:'Segoe Fluent Icons','Segoe MDL2 Assets',sans-serif;font-size:10px}
41
+ /* Win11 原生悬停/按下:直角矩形;深色悬停白 6%、按下白 10%;浅色悬停黑 5%、按下黑 8%;关闭键红底白字 */
42
+ #dsh-titlebar .tb-btn:hover{background:var(--tb-hover)}
43
+ #dsh-titlebar .tb-btn:active{background:var(--tb-active)}
44
+ #dsh-titlebar .tb-btn:focus-visible{background:var(--tb-hover)}
45
+ #dsh-titlebar .tb-btn.tb-close:hover{background:#C42B1C;color:#fff}
46
+ #dsh-titlebar .tb-btn.tb-close:active{background:#A92B1D;color:#fff}
47
+ `;
48
+ document.head.appendChild(style);
49
+
50
+ // 标题栏跟随 DSH(SPA)外观:读 SPA 的 data-ds-dark-theme 属性,不修改 SPA 本身
51
+ const applyDshTheme = () => {
52
+ let dark = true;
53
+ try {
54
+ if (document.body.hasAttribute('data-ds-dark-theme')) dark = true;
55
+ else {
56
+ const bg = getComputedStyle(document.body).backgroundColor;
57
+ const m = bg && bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
58
+ if (m) dark = (Number(m[1]) * 299 + Number(m[2]) * 587 + Number(m[3]) * 114) / 1000 < 128;
59
+ }
60
+ } catch { /* 保持默认深色 */ }
61
+ document.documentElement.setAttribute('data-dsh-theme', dark ? 'dark' : 'light');
62
+ };
63
+ applyDshTheme();
64
+ // 监听 SPA 主题切换(data-ds-dark-theme 属性变化 / body 背景变化)
65
+ try {
66
+ const obs = new MutationObserver(applyDshTheme);
67
+ if (document.body) obs.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'style', 'class'] });
68
+ document.addEventListener('DOMContentLoaded', () => { applyDshTheme(); obs.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'style', 'class'] }); });
69
+ } catch { /* ignore */ }
70
+
71
+ const bar = document.createElement('div');
72
+ bar.id = 'dsh-titlebar';
73
+ // 窗口按钮字形:Segoe Fluent Icons / Segoe MDL2 Assets(系统字形,10px)
74
+ // E921 最小化 / E922 最大化 / E923 还原 / E8BB 关闭
75
+ bar.innerHTML = `
76
+ <div class="tb-brand"><span class="tb-logo"></span><span>DeepSeek Harness</span></div>
77
+ <div class="tb-btns">
78
+ <button class="tb-btn tb-open" title="在新窗口打开当前会话">&#x29C9;</button>
79
+ <button class="tb-btn tb-min" title="最小化">&#xE921;</button>
80
+ <button class="tb-btn tb-max" title="最大化"><span class="ic-max">&#xE922;</span><span class="ic-restore" style="display:none">&#xE923;</span></button>
81
+ <button class="tb-btn tb-close" title="关闭">&#xE8BB;</button>
82
+ </div>`;
83
+ document.documentElement.appendChild(bar);
84
+
85
+ bar.querySelector('.tb-open').addEventListener('click', () => ipcRenderer.send('open-session-window'));
86
+ bar.querySelector('.tb-min').addEventListener('click', () => ipcRenderer.send('win-minimize'));
87
+ bar.querySelector('.tb-max').addEventListener('click', () => ipcRenderer.send('win-maximize-toggle'));
88
+ bar.querySelector('.tb-close').addEventListener('click', () => ipcRenderer.send('win-close'));
89
+ // 最大化/还原字形随窗口状态切换(主进程推送)
90
+ ipcRenderer.on('win-maximized', (_e, isMax) => {
91
+ bar.querySelector('.ic-max').style.display = isMax ? 'none' : '';
92
+ bar.querySelector('.ic-restore').style.display = isMax ? '' : 'none';
93
+ });
94
+
95
+ // 鲸鱼 logo:主进程读 assets/whale.svg 注入(单一来源)
96
+ try {
97
+ const svg = await ipcRenderer.invoke('get-brand-svg');
98
+ if (svg) {
99
+ const tmp = document.createElement('div');
100
+ tmp.innerHTML = svg;
101
+ const s = tmp.querySelector('svg');
102
+ if (s) {
103
+ s.removeAttribute('width');
104
+ s.removeAttribute('height');
105
+ s.setAttribute('fill', 'currentColor');
106
+ s.querySelectorAll('path').forEach((p) => p.setAttribute('fill', 'currentColor'));
107
+ s.querySelectorAll('style').forEach((st) => st.remove());
108
+ bar.querySelector('.tb-logo').appendChild(s);
109
+ }
110
+ }
111
+ } catch { /* 图标缺失不阻塞标题栏 */ }
112
+ }
113
+
114
+ // 桌面壳标记镜像:把 ?dsh-desktop-* 查询参数映射到 <html data-dsh-desktop-*> 属性
115
+ // 与 --dsh-title-bar-strip CSS 变量,供 better-sidebar 等插件自动避让标题栏。
116
+ // 由 dsh-oc client 的 Cordis 逻辑迁移至此(壳标记完全属于壳,不依赖 Cordis)。
117
+ function applyDesktopMarkers() {
118
+ if (typeof document === 'undefined') return;
119
+ let params = null;
120
+ try { params = new URLSearchParams(window.location.search.replace(/^\?/, '')); } catch { return; }
121
+ const mode = params.get('dsh-desktop-mode');
122
+ const platform = params.get('dsh-desktop-platform');
123
+ const inset = params.get('dsh-desktop-titlebar-inset');
124
+ if (!mode && !platform && !inset) return;
125
+ const root = document.documentElement;
126
+ if (mode) root.setAttribute('data-dsh-desktop-mode', mode);
127
+ if (platform) root.setAttribute('data-dsh-desktop-platform', platform);
128
+ if (inset && /^\d+$/.test(inset)) root.style.setProperty('--dsh-title-bar-strip', inset + 'px');
129
+ }
130
+
131
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { applyDesktopMarkers(); });
132
+ else applyDesktopMarkers();
133
+
134
+ if (customTitleBar) {
135
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { injectTitleBar(); });
136
+ else injectTitleBar();
137
+ }
138
+
139
+ contextBridge.exposeInMainWorld('dshNative', {
140
+ platform: process.platform,
141
+ customTitleBar,
142
+ notify: (title, body) => ipcRenderer.send('notify', { title, body }),
143
+ retryHarness: () => ipcRenderer.send('retry-harness'),
144
+ openSessionWindow: (sessionId) => {
145
+ const sid = typeof sessionId === 'string' && /^session-[A-Za-z0-9-]+$/.test(sessionId) ? sessionId : undefined;
146
+ ipcRenderer.send('open-session-window', sid ? { sessionId: sid } : undefined);
147
+ },
148
+ });
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+ // 会话新窗口工具:从渲染层 localStorage 读取当前会话 id,并生成深链 URL。
3
+
4
+ const SESSION_ID_RE = /^session-[A-Za-z0-9-]+$/;
5
+
6
+ function parseCurrentSession(raw) {
7
+ if (typeof raw !== 'string' || raw === '') return null;
8
+ try {
9
+ const parsed = JSON.parse(raw);
10
+ if (parsed && typeof parsed.sessionId === 'string' && SESSION_ID_RE.test(parsed.sessionId)) {
11
+ return parsed.sessionId;
12
+ }
13
+ } catch { /* 非法 JSON 视为无会话 */ }
14
+ return null;
15
+ }
16
+
17
+ function sessionWindowUrl(baseUrl, sessionId) {
18
+ const base = String(baseUrl).replace(/\/+$/, '');
19
+ return `${base}/s/${encodeURIComponent(sessionId)}`;
20
+ }
21
+
22
+ module.exports = { parseCurrentSession, sessionWindowUrl, SESSION_ID_RE };
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>
6
+ html, body { margin: 0; height: 100%; background: #101216; color: #e5e7eb;
7
+ font-family: 'Segoe UI', system-ui, sans-serif; -webkit-user-select: none; user-select: none; overflow: hidden; }
8
+ .wrap { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
9
+ .logo { width: 72px; height: 72px; border-radius: 18px; }
10
+ h1 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: .3px; }
11
+ p { font-size: 12px; color: #8b93a3; margin: 0; }
12
+ .dot { display: flex; gap: 6px; margin-top: 4px; }
13
+ .dot i { width: 7px; height: 7px; border-radius: 50%; background: #4d6bfe; animation: p 1.2s infinite ease-in-out; }
14
+ .dot i:nth-child(2) { animation-delay: .15s; }
15
+ .dot i:nth-child(3) { animation-delay: .3s; }
16
+ @keyframes p { 0%,100% { opacity: .25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-3px); } }
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <div class="wrap">
21
+ <img class="logo" src="../assets/splash-whale.png" alt="dsh">
22
+ <h1>DeepSeek Harness</h1>
23
+ <p>正在启动本地引擎…</p>
24
+ <div class="dot"><i></i><i></i><i></i></div>
25
+ </div>
26
+ </body>
27
+ </html>
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+ // updater.js - 检查更新(骨架阶段)
3
+ //
4
+ // 只做「检查」闭环:请求更新服务器 version.json,与本地版本对比,返回结果。
5
+ // - 未配置 updateUrl → { ok:false, error:'未配置更新源...' }
6
+ // - 网络/解析失败 → { ok:false, error:<原因> }
7
+ // - 成功 → { ok:true, hasUpdate, current, latest, notes }
8
+ //
9
+ // 下载 / 校验 / 替换 / 重启 属于阶段 3 更新器核心,后续在此模块扩展
10
+ // (checkForUpdates 的返回结构保持稳定,下载逻辑接入时不用改调用方)。
11
+ // 依赖:仅 node 内置模块(http/https),零第三方依赖,与项目「零依赖」一致。
12
+
13
+ const http = require('node:http');
14
+ const https = require('node:https');
15
+ const crypto = require('node:crypto');
16
+ const fs = require('node:fs');
17
+ const path = require('node:path');
18
+
19
+ const REQUEST_TIMEOUT_MS = 10000;
20
+ const MAX_BODY_BYTES = 1 * 1024 * 1024; // version.json 极小,1MB 上限防异常
21
+
22
+ // "0.2.0" / "0.1.0-rc.7" → { num:[0,2,0], pre:'rc.7'|null }; 解析不了返回 null
23
+ function parseVersion(v) {
24
+ const s = String(v == null ? '' : v).trim();
25
+ const m = s.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
26
+ if (!m) return null;
27
+ return { num: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null };
28
+ }
29
+
30
+ // 语义化版本比较:a>b → 1,a<b → -1,相等 → 0。
31
+ // pre-release(如 rc.7)低于同号正式版;解析不了时按字符串兜底(不抛异常)。
32
+ function semverCompare(a, b) {
33
+ const pa = parseVersion(a);
34
+ const pb = parseVersion(b);
35
+ if (!pa || !pb) return String(a).localeCompare(String(b));
36
+ for (let i = 0; i < 3; i++) {
37
+ if (pa.num[i] !== pb.num[i]) return pa.num[i] > pb.num[i] ? 1 : -1;
38
+ }
39
+ if (pa.pre === pb.pre) return 0;
40
+ if (!pa.pre) return 1; // 正式版 > 预发布
41
+ if (!pb.pre) return -1;
42
+ return pa.pre < pb.pre ? -1 : 1; // 同为预发布,字符串序(骨架够用)
43
+ }
44
+
45
+ // GET 一个 JSON(支持 http/https),带超时与体积上限。
46
+ function fetchJson(url, timeoutMs = REQUEST_TIMEOUT_MS) {
47
+ return new Promise((resolve, reject) => {
48
+ let lib;
49
+ try { lib = new URL(url).protocol === 'https:' ? https : http; }
50
+ catch { return reject(new Error('无效的更新地址: ' + url)); }
51
+ const req = lib.get(url, { headers: { 'User-Agent': 'dsh-desktop-updater' } }, (res) => {
52
+ if (res.statusCode !== 200) {
53
+ res.resume();
54
+ return reject(new Error('更新服务器返回 HTTP ' + res.statusCode));
55
+ }
56
+ let body = '';
57
+ res.setEncoding('utf8');
58
+ res.on('data', (d) => {
59
+ body += d;
60
+ if (body.length > MAX_BODY_BYTES) { req.destroy(new Error('更新清单过大')); }
61
+ });
62
+ res.on('end', () => {
63
+ try { resolve(JSON.parse(body)); }
64
+ catch { reject(new Error('更新清单不是有效 JSON')); }
65
+ });
66
+ });
67
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('连接更新服务器超时')));
68
+ req.on('error', (e) => reject(e));
69
+ });
70
+ }
71
+
72
+ // 检查更新主入口。updateUrl: 更新服务器根地址(可含尾斜杠);currentVersion: 本地版本号。
73
+ async function checkForUpdates(updateUrl, currentVersion) {
74
+ if (!updateUrl) return { ok: false, error: '未配置更新源(settings.json → updateUrl)' };
75
+ const base = String(updateUrl).replace(/\/+$/, '');
76
+ try {
77
+ const manifest = await fetchJson(base + '/version.json');
78
+ const latest = manifest && manifest.version;
79
+ if (!latest) return { ok: false, error: '更新清单缺少 version 字段' };
80
+ const cmp = semverCompare(String(latest), String(currentVersion));
81
+ return {
82
+ ok: true,
83
+ hasUpdate: cmp > 0,
84
+ current: String(currentVersion),
85
+ latest: String(latest),
86
+ notes: (manifest.notes || '').trim(),
87
+ };
88
+ } catch (e) {
89
+ return { ok: false, error: e && e.message ? e.message : String(e) };
90
+ }
91
+ }
92
+
93
+ // sha256 单个文件(流式,不整读入内存)。
94
+ function hashFile(file) {
95
+ return new Promise((resolve, reject) => {
96
+ const h = crypto.createHash('sha256');
97
+ const s = fs.createReadStream(file);
98
+ s.on('data', (d) => h.update(d));
99
+ s.on('error', (e) => reject(e));
100
+ s.on('end', () => resolve(h.digest('hex')));
101
+ });
102
+ }
103
+
104
+ // 对比本地文件树与更新清单,产出「需要下载」的差异列表。
105
+ // rootDir 本地应用根目录(绿色版 = exe 所在目录,即 win-unpacked)
106
+ // files 更新清单 files.json 的 files 数组 [{path,size,sha256}]
107
+ // return [{path,size,sha256,reason}] reason: 'missing' | 'changed'
108
+ // 优化:先按 size 过滤(大小不同 = 必变,免读哈希),size 相同的才算 sha256。
109
+ async function diffLocal(rootDir, files) {
110
+ const need = [];
111
+ for (const f of files) {
112
+ const abs = path.join(rootDir, f.path);
113
+ let st = null;
114
+ try { st = fs.statSync(abs); } catch { /* missing */ }
115
+ if (!st || !st.isFile()) { need.push({ path: f.path, size: f.size, sha256: f.sha256, reason: 'missing' }); continue; }
116
+ if (st.size !== f.size) { need.push({ path: f.path, size: f.size, sha256: f.sha256, reason: 'changed' }); continue; }
117
+ let hash;
118
+ try { hash = await hashFile(abs); } catch { need.push({ path: f.path, size: f.size, sha256: f.sha256, reason: 'changed' }); continue; }
119
+ if (hash !== f.sha256) need.push({ path: f.path, size: f.size, sha256: f.sha256, reason: 'changed' });
120
+ }
121
+ return need;
122
+ }
123
+
124
+ // 下载单文件(流式写盘 + sha256 校验,校验通过才 rename 到目标)。
125
+ // baseUrl 更新服务器根地址(如 http://host/dsh-updates)
126
+ // relPath 清单中的相对路径(resources/app.asar 等)
127
+ // destFile 本地目标绝对路径
128
+ // expected 期望 sha256;不符则删除 .part 并抛错
129
+ // onProgress (loadedBytes, totalBytes) 可选;total 来自 Content-Length,可能为 0
130
+ function downloadFile(baseUrl, relPath, destFile, expected, onProgress) {
131
+ return new Promise((resolve, reject) => {
132
+ const enc = relPath.split('/').map(encodeURIComponent).join('/');
133
+ const url = baseUrl + '/files/' + enc;
134
+ let lib;
135
+ try { lib = new URL(url).protocol === 'https:' ? https : http; }
136
+ catch { return reject(new Error('无效下载地址: ' + url)); }
137
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
138
+ const tmp = destFile + '.part';
139
+ const cleanup = () => { try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } };
140
+ const out = fs.createWriteStream(tmp);
141
+ const h = crypto.createHash('sha256');
142
+ let got = 0, total = 0;
143
+ const req = lib.get(url, { headers: { 'User-Agent': 'dsh-desktop-updater' } }, (res) => {
144
+ if (res.statusCode !== 200) {
145
+ res.resume();
146
+ cleanup();
147
+ return reject(new Error('下载失败 HTTP ' + res.statusCode + ': ' + relPath));
148
+ }
149
+ total = Number(res.headers['content-length']) || 0;
150
+ res.on('data', (d) => { h.update(d); got += d.length; out.write(d); if (onProgress) onProgress(got, total); });
151
+ res.on('error', (e) => { cleanup(); reject(e); });
152
+ res.on('end', () => {
153
+ out.end();
154
+ out.on('finish', () => {
155
+ const hash = h.digest('hex');
156
+ if (expected && hash !== expected) {
157
+ cleanup();
158
+ return reject(new Error('sha256 校验失败: ' + relPath));
159
+ }
160
+ try { fs.renameSync(tmp, destFile); }
161
+ catch (e) { cleanup(); return reject(e); }
162
+ resolve(hash);
163
+ });
164
+ out.on('error', (e) => { cleanup(); reject(e); });
165
+ });
166
+ });
167
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('下载超时: ' + relPath)));
168
+ req.on('error', (e) => { cleanup(); reject(e); });
169
+ });
170
+ }
171
+
172
+ // 按差异列表逐个下载到 stagingDir(保留相对路径)。任一文件失败即抛错(staging 由调用方清理)。
173
+ // onProgress (doneCount, totalCount, currentPath)
174
+ async function downloadUpdate(baseUrl, needList, stagingDir, onProgress) {
175
+ const done = [];
176
+ for (let i = 0; i < needList.length; i++) {
177
+ const f = needList[i];
178
+ await downloadFile(baseUrl, f.path, path.join(stagingDir, f.path), f.sha256);
179
+ done.push(f.path);
180
+ if (onProgress) onProgress(i + 1, needList.length, f.path);
181
+ }
182
+ return done;
183
+ }
184
+
185
+ module.exports = {
186
+ parseVersion, semverCompare, fetchJson, checkForUpdates, hashFile, diffLocal,
187
+ downloadFile, downloadUpdate,
188
+ };
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "dsh-oc-desktop",
3
+ "productName": "DeepSeek Harness Desktop",
4
+ "version": "0.3.1",
5
+ "description": "DeepSeek Harness 桌面端插件(Electron 启动器):自绘标题栏/托盘/通知/快捷键/崩溃自愈",
6
+ "main": "launcher/main.js",
7
+ "bin": {
8
+ "dsh-desktop": "bin/dsh-desktop.cjs"
9
+ },
10
+ "scripts": {
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"
13
+ },
14
+ "devDependencies": {
15
+ "electron": "^43.4.0"
16
+ },
17
+ "files": ["launcher", "bin", "assets", "scripts"],
18
+ "license": "MIT"
19
+ }
@@ -0,0 +1,15 @@
1
+ // dsh-shell smoke test: pure-logic units from the shell launcher.
2
+ import assert from "node:assert";
3
+ import { parseFrame, classifyFrame } from "../launcher/events.js";
4
+ import { parseCurrentSession, sessionWindowUrl } from "../launcher/session-window.js";
5
+
6
+ assert.deepStrictEqual(parseFrame(JSON.stringify({ type: "server-request", rpcId: "r1", payload: { type: "approval/requested" } })), { type: "approval/requested" });
7
+ assert.strictEqual(parseFrame("not-json"), null);
8
+ assert.deepStrictEqual(classifyFrame({ type: "approval/requested", toolName: "bash", reason: "run test" }), { kind: "approval", title: "需要审批", body: "工具「bash」请求执行 (run test)" });
9
+ assert.deepStrictEqual(classifyFrame({ type: "session/event", sessionId: "s1", event: { type: "turn/end", data: { reason: { code: "done" } } } }), { kind: "turn-end", title: "任务完成", body: "对话轮次已结束({\"code\":\"done\"})" });
10
+
11
+ assert.strictEqual(parseCurrentSession('{"sessionId":"session-abc123"}'), "session-abc123");
12
+ assert.strictEqual(parseCurrentSession(""), null);
13
+ assert.strictEqual(sessionWindowUrl("http://127.0.0.1:3080", "session-abc"), "http://127.0.0.1:3080/s/session-abc");
14
+
15
+ console.log("shell tests passed");