@cowwo/dsh-lookout 0.5.2 → 0.5.3

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/client/client.js CHANGED
@@ -7293,6 +7293,11 @@ window.__ModuleLoader__.load({
7293
7293
  const ROW_MENU_BODY_SELECTOR = "[data-files-body]";
7294
7294
  const ROW_MENU_ROWS_SELECTOR = 'li[data-files-entry="file"],li[data-files-entry="directory"]';
7295
7295
  const ROW_MENU_PATH_ATTR = "data-files-path";
7296
+ /** Same-origin download route: HEAD is our pre-flight, the browser streams the GET. */
7297
+ const ROW_MENU_DOWNLOAD_ROUTE = "/api/lookout.download";
7298
+ /** Where the current session id is read from, sidebar marker first, storage second. */
7299
+ const ROW_MENU_SESSION_ATTR = "data-sidebar-right-session";
7300
+ const ROW_MENU_SESSION_KEY = "dsh.sessions.current";
7296
7301
  /** A run that places nothing at all, this many times in a row, gives up. */
7297
7302
  const ROW_MENU_MAX_FAILURES = 3;
7298
7303
  const ROW_MENU_SCAN_DEBOUNCE = 200;
@@ -7322,12 +7327,13 @@ window.__ModuleLoader__.load({
7322
7327
  download: "下载",
7323
7328
  folderUnsupported: "文件夹暂不支持",
7324
7329
  copyFailed: "复制失败,请手动选择路径",
7330
+ err400: "下载参数不完整:缺少会话或文件路径",
7325
7331
  err401: "登录已过期,请刷新页面重新登录",
7326
- err413: "文件太大,超出服务器允许的下载上限",
7327
7332
  err404: "文件不存在或已被删除",
7328
- err403: "没有权限读取这个文件",
7333
+ err403: "只能下载当前会话工作区内的文件",
7329
7334
  errOther: "下载失败(HTTP {status})",
7330
7335
  errNetwork: "下载失败,请检查连接",
7336
+ errSession: "无法确定当前会话,已取消下载",
7331
7337
  givingUp: "文件行「⋯」菜单连续注入失败,已自动停止;文件树本身不受影响。",
7332
7338
  dismiss: "不再提示"
7333
7339
  };
@@ -7340,12 +7346,13 @@ window.__ModuleLoader__.load({
7340
7346
  download: "Download",
7341
7347
  folderUnsupported: "Folders not supported",
7342
7348
  copyFailed: "Copy failed — select the path and copy it manually",
7349
+ err400: "The download request is incomplete: missing session or file path.",
7343
7350
  err401: "Your session has expired. Refresh the page and sign in again.",
7344
- err413: "This file is larger than the server's download limit.",
7345
7351
  err404: "The file no longer exists or was deleted.",
7346
- err403: "You do not have permission to read this file.",
7352
+ err403: "You can only download files inside the current session's workspace.",
7347
7353
  errOther: "Download failed (HTTP {status}).",
7348
7354
  errNetwork: "Download failed — check your connection.",
7355
+ errSession: "Cannot determine the current session — download cancelled.",
7349
7356
  givingUp: "The file-row ⋯ menu could not be injected repeatedly and stopped; the file tree itself is unaffected.",
7350
7357
  dismiss: "Do not show again"
7351
7358
  };
@@ -8133,18 +8140,49 @@ window.__ModuleLoader__.load({
8133
8140
 
8134
8141
  function downloadErrorText(status) {
8135
8142
  switch (status) {
8143
+ case 400: return translate("err400");
8136
8144
  case 401: return translate("err401");
8137
- case 413: return translate("err413");
8138
- case 404: return translate("err404");
8139
8145
  case 403: return translate("err403");
8146
+ case 404: return translate("err404");
8140
8147
  default: return translate("errOther", { status: status > 0 ? status : "?" });
8141
8148
  }
8142
8149
  }
8143
8150
 
8151
+ /** The current session id: sidebar marker first, persisted pointer second, "" if neither. */
8152
+ function currentSessionId() {
8153
+ try {
8154
+ const marker = document.querySelector("[" + ROW_MENU_SESSION_ATTR + "]");
8155
+ const attr = marker !== null && marker !== undefined ? marker.getAttribute(ROW_MENU_SESSION_ATTR) : null;
8156
+ if (typeof attr === "string" && attr !== "") return attr;
8157
+ } catch (error) { /* fall through to storage */ }
8158
+ try {
8159
+ const stored = window.localStorage.getItem(ROW_MENU_SESSION_KEY);
8160
+ if (typeof stored === "string" && stored !== "") return stored;
8161
+ } catch (error) { /* no storage: no session */ }
8162
+ return "";
8163
+ }
8164
+
8165
+ function downloadUrl(sessionId, path) {
8166
+ return ROW_MENU_DOWNLOAD_ROUTE
8167
+ + "?sessionId=" + encodeURIComponent(sessionId)
8168
+ + "&path=" + encodeURIComponent(path);
8169
+ }
8170
+
8171
+ /**
8172
+ * Download without ever holding the file: HEAD pre-flights the route, then a
8173
+ * plain <a download> lets the browser stream its own GET to disk. A failed
8174
+ * pre-flight never starts a download, it only explains why.
8175
+ */
8144
8176
  async function downloadRowFile(path) {
8177
+ const sessionId = currentSessionId();
8178
+ if (sessionId === "") {
8179
+ if (!disposed) showToast(translate("errSession"), false);
8180
+ return;
8181
+ }
8182
+ const url = downloadUrl(sessionId, path);
8145
8183
  let response = null;
8146
8184
  try {
8147
- response = await window.fetch("api/file?path=" + encodeURIComponent(path), { credentials: "same-origin" });
8185
+ response = await window.fetch(url, { method: "HEAD", credentials: "same-origin" });
8148
8186
  } catch (error) {
8149
8187
  if (!disposed) showToast(translate("errNetwork"), false);
8150
8188
  return;
@@ -8154,19 +8192,16 @@ window.__ModuleLoader__.load({
8154
8192
  if (!disposed) showToast(downloadErrorText(status), false);
8155
8193
  return;
8156
8194
  }
8195
+ if (disposed) return;
8157
8196
  try {
8158
- const blob = await response.blob();
8159
- if (disposed) return;
8160
- const objectUrl = window.URL.createObjectURL(blob);
8161
8197
  const link = document.createElement("a");
8162
- link.href = objectUrl;
8198
+ link.href = url;
8163
8199
  link.download = fileNameFor(path);
8164
8200
  link.rel = "noopener";
8165
8201
  link.style.display = "none";
8166
8202
  document.body.appendChild(link);
8167
8203
  link.click();
8168
8204
  link.remove();
8169
- window.setTimeout(() => { try { window.URL.revokeObjectURL(objectUrl); } catch { /* gone */ } }, 1000);
8170
8205
  } catch (error) {
8171
8206
  if (!disposed) showToast(translate("errNetwork"), false);
8172
8207
  }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * dsh-lookout · 宿主侧流式下载路由
3
+ *
4
+ * 一条精确的 Fetch 路由 `GET|HEAD /api/lookout.download`,把**某个会话 cwd 之内**的
5
+ * 一个普通文件流式发给浏览器(内存恒定:createReadStream → Readable.toWeb → Response)。
6
+ *
7
+ * 三条硬约束(改代码时别动):
8
+ * 1. 路径逐段必须匹配 [A-Za-z0-9_$.-]+,且宿主强制 `/api/` 前缀——所以是
9
+ * `/api/lookout.download` 这个字面量,不能把参数拼进路径。
10
+ * 2. **不要把 connection/fs 塞进 lib/index.js 顶层的 inject 数组**:那会让没有 web 载体的
11
+ * 组合(CLI)一直等不到服务、卡死插件加载。这里用 ctx.inject 局部等待。
12
+ * 3. 放行判定是"词法归一化 + realpath"两道:只有词法那道挡不住符号链接逃逸。
13
+ *
14
+ * 错误码:缺参/非绝对路径 400;越界或非普通文件 403;不存在 404;其它 500(只 warn 一次,不吐堆栈)。
15
+ */
16
+ import { createReadStream, fstat } from 'node:fs'
17
+ import { realpath } from 'node:fs/promises'
18
+ import { Readable } from 'node:stream'
19
+ import { basename, isAbsolute, normalize, sep } from 'node:path'
20
+
21
+ /** 路由路径:宿主强制 /api/ 前缀,且每段要过 [A-Za-z0-9_$.-]+ 校验。 */
22
+ export const DOWNLOAD_ROUTE_PATH = '/api/lookout.download'
23
+
24
+ /** 同一路由路径的短名(老调用方只认这个名字)。 */
25
+ export const DOWNLOAD_ROUTE = DOWNLOAD_ROUTE_PATH
26
+
27
+ /** 认不出扩展名时一律给这个,避免浏览器瞎猜。 */
28
+ const DEFAULT_CONTENT_TYPE = 'application/octet-stream'
29
+
30
+ /**
31
+ * 最小扩展名映射。只有一张表,不做内容嗅探:下载场景里"猜错"比"给 stream"更糟。
32
+ * @type {Record<string, string>}
33
+ */
34
+ const CONTENT_TYPES = {
35
+ '.txt': 'text/plain',
36
+ '.md': 'text/markdown',
37
+ '.json': 'application/json',
38
+ '.csv': 'text/csv',
39
+ '.html': 'text/html',
40
+ '.css': 'text/css',
41
+ '.js': 'text/javascript',
42
+ '.mjs': 'text/javascript',
43
+ '.xml': 'application/xml',
44
+ '.yaml': 'text/yaml',
45
+ '.yml': 'text/yaml',
46
+ '.png': 'image/png',
47
+ '.jpg': 'image/jpeg',
48
+ '.jpeg': 'image/jpeg',
49
+ '.gif': 'image/gif',
50
+ '.webp': 'image/webp',
51
+ '.svg': 'image/svg+xml',
52
+ '.pdf': 'application/pdf',
53
+ '.zip': 'application/zip',
54
+ '.gz': 'application/gzip',
55
+ '.mp3': 'audio/mpeg',
56
+ '.wav': 'audio/wav',
57
+ '.mp4': 'video/mp4',
58
+ }
59
+
60
+ /**
61
+ * 按扩展名给出 content-type。
62
+ * @param {string} fileName 文件名(大小写不敏感)
63
+ * @returns {string} MIME 类型;认不出给 application/octet-stream
64
+ */
65
+ export function contentTypeOf(fileName) {
66
+ const dot = fileName.lastIndexOf('.')
67
+ if (dot <= 0) return DEFAULT_CONTENT_TYPE
68
+ return CONTENT_TYPES[fileName.slice(dot).toLowerCase()] ?? DEFAULT_CONTENT_TYPE
69
+ }
70
+
71
+ /**
72
+ * content-disposition 里 filename="..." 那份的 ASCII 兜底:
73
+ * 非可见 ASCII 与引号/反斜杠一律换成下划线,空名字退回 download。
74
+ * @param {string} fileName 原始文件名
75
+ * @returns {string} 纯 ASCII 文件名
76
+ */
77
+ export function asciiFileName(fileName) {
78
+ const cleaned = String(fileName).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_').trim()
79
+ return cleaned.length === 0 ? 'download' : cleaned
80
+ }
81
+
82
+ /**
83
+ * 拼完整的 content-disposition:ASCII 兜底 + RFC 5987 的 filename*(真名,中文不丢)。
84
+ * @param {string} fileName 原始文件名
85
+ * @returns {string} 头值
86
+ */
87
+ export function contentDisposition(fileName) {
88
+ const name = String(fileName ?? '')
89
+ return `attachment; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name || 'download')}`
90
+ }
91
+
92
+ /**
93
+ * 判定 candidate 是否落在 root 之内(root 自身算在内)。两边都应是已归一化的绝对路径。
94
+ * @param {string} root 边界目录
95
+ * @param {string} candidate 待判定路径
96
+ * @returns {boolean} 是否在范围内
97
+ */
98
+ export function withinRoot(root, candidate) {
99
+ if (candidate === root) return true
100
+ const base = root.endsWith(sep) ? root : root + sep
101
+ return candidate.startsWith(base)
102
+ }
103
+
104
+ /**
105
+ * 读一个宿主服务:先用 Reflect.get(普通对象与 ctx 都吃),再退回 ctx.get。
106
+ * @param {object} ctx 上下文(真实 ctx、inject 回调里的 scoped,或 {fs, sessions} 这类直给对象)
107
+ * @param {string} name 服务名
108
+ * @returns {any} 服务实例或 undefined
109
+ */
110
+ function serviceOf(ctx, name) {
111
+ const direct = Reflect.get(ctx, name)
112
+ if (direct !== undefined && direct !== null) return direct
113
+ return typeof ctx?.get === 'function' ? ctx.get(name) : undefined
114
+ }
115
+
116
+ /**
117
+ * 取会话 cwd。字段名现场确认过:dsh-session 的 Session 上是 `session.header.cwd`
118
+ * (SessionHeader.cwd,创建时校验必须是绝对路径字符串);`meta.cwd` / `cwd` 只作兜底。
119
+ * @param {object|undefined} session ctx.sessions.get() 的返回值
120
+ * @returns {string|undefined} 绝对路径 cwd
121
+ */
122
+ export function sessionCwd(session) {
123
+ if (session === null || typeof session !== 'object') return undefined
124
+ const candidates = [session.header?.cwd, session.meta?.cwd, session.cwd]
125
+ for (const value of candidates) if (typeof value === 'string' && value.length > 0) return value
126
+ return undefined
127
+ }
128
+
129
+ /**
130
+ * 取某个会话的工作区目录(下载的边界)。先 get,拿不到再在 list 里按 id 匹配一次。
131
+ * @param {object|undefined} sessions ctx.sessions
132
+ * @param {string} sessionId 会话 id
133
+ * @returns {string|null} 绝对路径,取不到给 null
134
+ */
135
+ export function sessionWorkspace(sessions, sessionId) {
136
+ if (sessions === null || typeof sessions !== 'object') return null
137
+ if (typeof sessions.get === 'function') {
138
+ const cwd = sessionCwd(sessions.get(sessionId))
139
+ if (cwd !== undefined) return cwd
140
+ }
141
+ if (typeof sessions.list === 'function') {
142
+ for (const session of sessions.list() ?? []) {
143
+ if (session?.header?.id === sessionId || session?.id === sessionId) {
144
+ const cwd = sessionCwd(session)
145
+ if (cwd !== undefined) return cwd
146
+ }
147
+ }
148
+ }
149
+ return null
150
+ }
151
+
152
+ /**
153
+ * realpath 的"宽容版":路径不存在/无权限时原样返回,让后面的 stat 去定 404。
154
+ * @param {string} path 绝对路径
155
+ * @returns {Promise<string>} realpath,或原路径
156
+ */
157
+ async function realPathOrSelf(path) {
158
+ try {
159
+ return await realpath(path)
160
+ } catch {
161
+ return path
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 统一的错误响应:纯文本 + 禁缓存。
167
+ * @param {number} status HTTP 状态码
168
+ * @param {string} message 一行说明(不吐内部细节)
169
+ * @returns {Response} 响应
170
+ */
171
+ function errorResponse(status, message) {
172
+ return new Response(message, {
173
+ status,
174
+ headers: {
175
+ 'content-type': 'text/plain; charset=utf-8',
176
+ 'cache-control': 'private, no-store',
177
+ },
178
+ })
179
+ }
180
+
181
+ /**
182
+ * 把 fs 层的失败翻译成状态码:FS_NOT_FOUND / ENOENT 当"不存在",其它交给外层 500。
183
+ * @param {unknown} error 抛出的错误
184
+ * @returns {boolean} 是否属于"不存在"
185
+ */
186
+ function isNotFound(error) {
187
+ const code = error !== null && typeof error === 'object' ? error.code : undefined
188
+ return code === 'FS_NOT_FOUND' || code === 'ENOENT'
189
+ }
190
+
191
+ /**
192
+ * 造一个下载 handler。
193
+ *
194
+ * deps 只为一件事存在:单测要数"到底有没有真去读文件"。生产路径不传,用 node:fs 的真身。
195
+ * @param {object} ctx 上下文(真实 ctx / inject 回调里的 scoped / {fs, sessions} 直给对象)
196
+ * @param {{createReadStream?: typeof createReadStream}} [deps] 可替换的底层流工厂
197
+ * @returns {(request: Request) => Promise<Response>} Fetch handler
198
+ */
199
+ export function createDownloadHandler(ctx, deps = {}) {
200
+ const openStream = typeof deps.createReadStream === 'function' ? deps.createReadStream : createReadStream
201
+
202
+ return async function handleDownload(request) {
203
+ try {
204
+ const signal = request.signal
205
+ const params = new URL(request.url).searchParams
206
+ const sessionId = params.get('sessionId')
207
+ const requestedPath = params.get('path')
208
+
209
+ // —— 400:参数这一关先过,不合法就不碰文件系统 ——
210
+ if (sessionId === null || sessionId.length === 0) return errorResponse(400, 'missing sessionId')
211
+ if (requestedPath === null || requestedPath.length === 0) return errorResponse(400, 'missing path')
212
+ if (!isAbsolute(requestedPath)) return errorResponse(400, 'path must be absolute')
213
+ // 尾随分隔符是"目录写法":按"没有这个文件"处理,别让它走到 stat 再变成 500。
214
+ if (/[\\/]$/.test(requestedPath)) return errorResponse(404, 'file not found')
215
+
216
+ const sessions = serviceOf(ctx, 'sessions')
217
+ if (sessions === undefined) return errorResponse(500, 'download unavailable: missing sessions service')
218
+
219
+ // —— 403:会话或它的 cwd 拿不到,就没有边界可谈 ——
220
+ const cwd = sessionWorkspace(sessions, sessionId)
221
+ if (cwd === null) return errorResponse(403, 'session cwd unavailable')
222
+
223
+ // —— 第一道:词法归一化(挡 .. 这类纯文本穿越) ——
224
+ const root = normalize(cwd)
225
+ const candidate = normalize(requestedPath)
226
+ if (!withinRoot(root, candidate)) return errorResponse(403, 'path outside session cwd')
227
+
228
+ const fs = serviceOf(ctx, 'fs')
229
+ if (typeof fs?.resolve !== 'function' || typeof fs?.stat !== 'function' || typeof fs?.processPath !== 'function') {
230
+ return errorResponse(500, 'download unavailable: missing fs service')
231
+ }
232
+
233
+ let target
234
+ let rootTarget
235
+ try {
236
+ target = await fs.resolve(candidate, { signal })
237
+ rootTarget = await fs.resolve(root, { signal })
238
+ } catch (error) {
239
+ if (isNotFound(error)) return errorResponse(404, 'file not found')
240
+ throw error
241
+ }
242
+
243
+ // —— 第二道:realpath 之后仍在范围内(挡符号链接逃逸) ——
244
+ // 双保险:既信后端的 processPath(本地后端本身就是 realpath 结果),
245
+ // 也自己 realpath 一次,后端换成非 realpath 的实现时这道闸照样有效。
246
+ const realRoot = String(fs.processPath(rootTarget))
247
+ const hostPath = String(fs.processPath(target))
248
+ const [realRootPath, realTargetPath] = await Promise.all([realPathOrSelf(realRoot), realPathOrSelf(hostPath)])
249
+ if (!withinRoot(realRootPath, realTargetPath)) return errorResponse(403, 'path outside session cwd after realpath')
250
+
251
+ let info
252
+ try {
253
+ info = await fs.stat(target, signal)
254
+ } catch (error) {
255
+ if (isNotFound(error) || error?.code === 'ENOTDIR' || error?.code === 'EISDIR') return errorResponse(404, 'file not found')
256
+ throw error
257
+ }
258
+ if (info === undefined) return errorResponse(404, 'file not found')
259
+ if (info.type !== 'file') return errorResponse(403, 'not a regular file')
260
+
261
+ const fileName = basename(candidate)
262
+ const headers = {
263
+ 'content-type': contentTypeOf(fileName),
264
+ 'content-disposition': contentDisposition(fileName),
265
+ 'cache-control': 'private, no-store',
266
+ }
267
+ // 后端不保证给 size;缺了就不写 content-length(宁可不写,也不写一个假的 0)。
268
+ let declaredSize = 0
269
+ if (typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0) {
270
+ declaredSize = info.size
271
+ headers['content-length'] = String(info.size)
272
+ }
273
+
274
+ // HEAD 只要头:同样的 status + headers,body 必须为空,且**不打开文件**。
275
+ if (request.method === 'HEAD') return new Response(null, { status: 200, headers })
276
+
277
+ const stream = openStream(realTargetPath)
278
+ // 打开后用同一个 fd 复核一次大小:stat 与 open 之间被换掉的极端情况,
279
+ // 宁可断流也不要发出一份长度对不上的响应。
280
+ stream.once('open', (fd) => {
281
+ if (declaredSize === 0) return
282
+ try {
283
+ fstat(fd, (error, stats) => {
284
+ if (error === null && stats !== undefined && stats.size !== declaredSize) stream.destroy(new Error('size changed while downloading'))
285
+ })
286
+ } catch { /* best effort */ }
287
+ })
288
+ stream.on('error', (error) => {
289
+ const code = error?.code ?? ''
290
+ // 客户端主动取消/连接被掐断是常态,不该刷日志。
291
+ if (code === 'ERR_STREAM_PREMATURE_CLOSE' || code === 'ECONNRESET' || code === 'EPIPE' || error?.name === 'AbortError') return
292
+ console.warn('[dsh-lookout] download stream failed:', code !== '' ? code : (error?.message ?? error))
293
+ })
294
+ return new Response(Readable.toWeb(stream), { status: 200, headers })
295
+ } catch (error) {
296
+ // 500 只说一句,不带堆栈:日志里也不要整段内部路径。
297
+ console.warn('[dsh-lookout] download route failed:', error?.message ?? error)
298
+ return errorResponse(500, 'download failed')
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * 把下载路由挂到 connection 上。
305
+ *
306
+ * 用 ctx.inject 局部等 connection/fs/sessions:缺任何一个就不注册,而不是让整个插件
307
+ * 卡在加载阶段(那正是不能把它们塞进顶层 inject 的原因)。
308
+ * @param {object} ctx 插件上下文
309
+ * @param {{createReadStream?: typeof createReadStream}} [deps] 仅单测使用
310
+ * @returns {void}
311
+ */
312
+ const REGISTERED_CONNECTIONS = new WeakSet()
313
+
314
+ export function registerDownloadRoute(ctx, deps = {}) {
315
+ const attach = (scoped) => {
316
+ const connection = serviceOf(scoped, 'connection')
317
+ if (typeof connection?.fetch?.register !== 'function') return
318
+ // 同一个 connection 只挂一次:宿主对重复路径会直接抛错,抛在 apply 里会带崩整个插件。
319
+ if (REGISTERED_CONNECTIONS.has(connection)) return
320
+ const route = {
321
+ path: DOWNLOAD_ROUTE_PATH,
322
+ methods: ['GET', 'HEAD'],
323
+ requestBody: 'buffered',
324
+ fetch: createDownloadHandler(scoped, deps),
325
+ }
326
+ const register = () => {
327
+ if (REGISTERED_CONNECTIONS.has(connection)) return
328
+ try {
329
+ const dispose = connection.fetch.register(route)
330
+ REGISTERED_CONNECTIONS.add(connection)
331
+ return dispose
332
+ } catch (error) {
333
+ // 已经被别人占了同名路径(或宿主换了校验):记一条,不拖累插件加载。
334
+ console.warn('[dsh-lookout] download route not registered:', error?.code ?? error?.message ?? error)
335
+ return undefined
336
+ }
337
+ }
338
+ // 包一层 effect:插件卸载时路由跟着撤掉。
339
+ if (typeof scoped?.effect === 'function') scoped.effect(register, `dsh-lookout: ${DOWNLOAD_ROUTE_PATH}`)
340
+ else register()
341
+ }
342
+ if (typeof ctx?.inject === 'function') ctx.inject(['connection', 'fs', 'sessions'], attach)
343
+ else attach(ctx)
344
+ }
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { FaviconCustomService } from './icon/index.js'
22
22
  import { apply as applyNotify } from './notify/index.js'
23
23
  import { apply as applySessionInfo } from './session/index.js'
24
24
  import { MailSchema } from './notify/config.js'
25
+ import { registerDownloadRoute } from './download.js'
25
26
 
26
27
  export const name = 'dsh-lookout'
27
28
 
@@ -68,4 +69,6 @@ export function apply(ctx, config) {
68
69
  applyNotify(ctx, config)
69
70
  // ③ C:宿主侧本来就是空实现。
70
71
  applySessionInfo(ctx)
72
+ // ④ 宿主流式下载路由:自己 inject(connection/fs/sessions),缺服务就不挂,不动顶层 inject。
73
+ registerDownloadRoute(ctx)
71
74
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-lookout",
3
3
  "description": "瞭望哨:盯着你的每个会话——谁跑完了、谁出错了、谁卡住了、谁在等你审批,用浏览器图标角标、红点/黄点和邮件通知你;顺带显示当前会话 ID。· Lookout: watches every session — who finished, who errored, who is stuck, and who is waiting for your approval — and notifies you through the browser favicon badge, red/yellow dots, and email, while also showing the current session ID.",
4
- "version": "0.5.2",
4
+ "version": "0.5.3",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",