@weniatall/dsh-file-viewer 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/lib/client.js +286 -28
- package/lib/core.js +50 -1
- package/lib/index.js +136 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ dsh plugin --profile web add <release-tgz-url>
|
|
|
46
46
|
- **会话中点击文件**:在会话回复(变更文件卡片、产物交付卡片、行内代码引用等)中点击任何文件路径,自动唤起文件查看抽屉并直接选中预览该文件。
|
|
47
47
|
- `Esc` 或点击抽屉外部遮罩关闭。
|
|
48
48
|
|
|
49
|
+
**桌面端接管说明**:桌面版宿主判定自己能在系统里打开路径,因此会在文件路径与更改卡片旁额外渲染原生「在应用中打开」按钮,更改卡片还会走 `/api/changes.open` 这条直接交给操作系统的路由。插件在这两处都会先接管,改为唤起文件查看抽屉:原生打开按钮按 `data-open-path-open` 识别,更改卡片打开路由则从 URL 里的 `sessionId / seq / index` 回查文件路径。取不到路径时一律放行,仍按宿主原方式打开,不会出现点了没反应。
|
|
50
|
+
|
|
49
51
|
顶部可切换工作区、刷新、显示或隐藏点文件。目录在树中原地展开,子级首次展开时才加载。
|
|
50
52
|
|
|
51
53
|
文件树支持**右键上下文菜单**与单项 `@` 快捷按钮:
|
|
@@ -80,7 +82,10 @@ dsh plugin --profile web add <release-tgz-url>
|
|
|
80
82
|
# label: 共享数据盘
|
|
81
83
|
# Windows 示例:
|
|
82
84
|
# - D:/Notes
|
|
85
|
+
# 在线预览(图片/PDF/表格/文档)单文件上限,默认 20 MB
|
|
83
86
|
maxBytes: 20971520
|
|
87
|
+
# 下载到本地单文件上限,默认 100 MB
|
|
88
|
+
maxDownloadBytes: 104857600
|
|
84
89
|
```
|
|
85
90
|
|
|
86
91
|
配置后:
|
|
@@ -90,7 +95,24 @@ dsh plugin --profile web add <release-tgz-url>
|
|
|
90
95
|
- 为保持向后兼容,原有的 `extraRoots` 与 `safeAccessPaths` 别名依然有效并会自动合并;
|
|
91
96
|
- **Web 界面动态配置与双重持久化**:可在输入框工具栏文件查看器入口或文件树顶部的「安全访问路径设置」弹窗中直接添加、查看或删除安全路径,保存后会自动双重持久化写入当前 Profile 配置(`cordis.patch.yml`)与本地文件(`~/.dsh/file-viewer/settings.json`),DSH 服务重启后配置稳定保留不丢失。
|
|
92
97
|
|
|
93
|
-
|
|
98
|
+
### 下载与预览使用两套上限
|
|
99
|
+
|
|
100
|
+
下载上限(`maxDownloadBytes`,默认 100 MB)与在线预览上限(`maxBytes`,默认 20 MB)是**独立**的两个值,因为两条通道的代价不同:
|
|
101
|
+
|
|
102
|
+
- **预览**把整个文件以 base64 塞进 JSON 响应,浏览器还要再 `atob` 解码一次;20 MB 的文件已经会产生约 27 MB 的传输文本,因此这条通道刻意保持小预算;
|
|
103
|
+
- **下载**由浏览器直接消费,或由桌面端流式读取后交给保存流程,不需要解码进页面,因此可以给到 100 MB。
|
|
104
|
+
|
|
105
|
+
两个上限都支持通过配置调整;把 `maxDownloadBytes` 调大时,宿主仍需一次性读出该文件,请按可用内存权衡。
|
|
106
|
+
|
|
107
|
+
### 下载在不同宿主中的行为
|
|
108
|
+
|
|
109
|
+
插件在 `/api` 之下注册了一个带鉴权的流式下载端点,并让客户端按宿主能力选择传输方式:
|
|
110
|
+
|
|
111
|
+
- **浏览器**:先发一次 `HEAD` 预检(超限直接提示「文件超过下载限制(上限 100MB)」,不会产生一条失败的下载记录),随后把鉴权后的 URL 交给浏览器原生下载管理器。进度、暂停、续传与保存位置都由浏览器负责,页面内存不随文件大小增长;
|
|
112
|
+
- **桌面端(Electron)**:桌面外壳没有浏览器的下载面板,因此改用 `fetch` 流式读取,并在抽屉内实时显示百分比进度(如 `正在下载 dataset.zip (45% - 45.2 MB / 100.0 MB)...`),完成后交给保存流程;
|
|
113
|
+
- **兜底**:流式端点不可用(旧宿主或路由被占用)时,自动回退到既有的 RPC `bytes` 通道,功能不缺失。
|
|
114
|
+
|
|
115
|
+
下载端点的错误响应只回传机器可读的原因码(如 `FS_TOO_LARGE`、`too-large`),不会回显宿主的绝对路径;这与 RPC 通道既有的「绝不泄露路径」约定一致。
|
|
94
116
|
|
|
95
117
|
## 未知后缀文件的文本识别
|
|
96
118
|
|
package/lib/client.js
CHANGED
|
@@ -299,7 +299,7 @@ window.__ModuleLoader__.load({
|
|
|
299
299
|
const inject = ["slots", "connection", "workspaces"];
|
|
300
300
|
|
|
301
301
|
const css = `
|
|
302
|
-
.fv-scrim{position:absolute;inset:0;z-index:40;display:flex;align-items:stretch;justify-content:flex-end;background:transparent;pointer-events:auto;
|
|
302
|
+
.fv-scrim{position:absolute;inset:0;z-index:40;display:flex;align-items:stretch;justify-content:flex-end;background:transparent;pointer-events:auto;animation:fv-fade .16s ease-out}
|
|
303
303
|
.fv-shell{display:flex;flex-direction:column;width:min(64vw,1100px);min-width:0;min-height:0;overflow:hidden;border-left:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary);animation:fv-slide .2s cubic-bezier(.2,.8,.2,1);transition:width .2s cubic-bezier(.2,.8,.2,1)}
|
|
304
304
|
.fv-shell[data-fullscreen="true"]{width:100vw;border-left:none}
|
|
305
305
|
/* Windows desktop paints the native caption buttons (minimize, maximize,
|
|
@@ -424,7 +424,7 @@ window.__ModuleLoader__.load({
|
|
|
424
424
|
.fv-badge{display:inline-block;padding:0 4px;font-size:10px;font-weight:600;line-height:14px;border-radius:4px;margin-left:4px;flex:none}
|
|
425
425
|
.fv-badge-mod{background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 18%,transparent);color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
426
426
|
.fv-badge-add{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#10b981) 18%,transparent);color:var(--dsw-alias-state-success-primary,#10b981)}
|
|
427
|
-
.fv-modal-scrim{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:16px;background:color-mix(in srgb,#000 45%,transparent);animation:fv-fade .15s ease-out
|
|
427
|
+
.fv-modal-scrim{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:16px;background:color-mix(in srgb,#000 45%,transparent);animation:fv-fade .15s ease-out}
|
|
428
428
|
.fv-modal{width:min(94vw,560px);max-height:86vh;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);overflow:hidden;font-family:var(--dsw-font-family)}
|
|
429
429
|
.fv-modal-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
430
430
|
.fv-modal-title{font:var(--dsw-font-m-16);font-weight:600;display:flex;align-items:center;gap:6px}
|
|
@@ -870,10 +870,27 @@ window.__ModuleLoader__.load({
|
|
|
870
870
|
// Guard so a hot reload or re-apply never double-wraps shared open
|
|
871
871
|
// handlers; older hosts used workspaces.openPath.
|
|
872
872
|
const wiredOpenPath = typeof WeakSet !== "undefined" ? new WeakSet() : null;
|
|
873
|
+
// The wrappers below outlive a single apply. They were closing over the
|
|
874
|
+
// store of whichever apply installed them, so a second apply left the Host
|
|
875
|
+
// calling the previous store: the click was swallowed, the drawer never
|
|
876
|
+
// opened, and the file viewer looked like it had stopped listening. These
|
|
877
|
+
// pointers are refreshed on every apply so the wrappers always write the
|
|
878
|
+
// store the mounted overlay is actually subscribed to.
|
|
879
|
+
let liveOpenStore = null;
|
|
880
|
+
let liveSessionStore = null;
|
|
873
881
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
882
|
+
/**
|
|
883
|
+
* Open the drawer on one path.
|
|
884
|
+
*
|
|
885
|
+
* `sessionId` is optional: a `dsh-resource://file/session/<id>/…` address
|
|
886
|
+
* names the session it belongs to, and honouring it keeps the drawer on that
|
|
887
|
+
* session's project instead of whatever session happens to be current.
|
|
888
|
+
*/
|
|
889
|
+
function openViewerForPath(openStore, sessionStore, path, sessionId) {
|
|
890
|
+
const target = liveOpenStore || openStore;
|
|
891
|
+
const session = liveSessionStore || sessionStore;
|
|
892
|
+
const sid = sessionId !== undefined && sessionId !== "" ? sessionId : (session ? session.get() : undefined);
|
|
893
|
+
target.set({ filePath: path, sessionId: sid, _t: Date.now() });
|
|
877
894
|
}
|
|
878
895
|
|
|
879
896
|
function parseFileResource(address) {
|
|
@@ -918,8 +935,7 @@ window.__ModuleLoader__.load({
|
|
|
918
935
|
sr.openResource = function(address, options) {
|
|
919
936
|
const parsed = parseFileResource(address);
|
|
920
937
|
if (parsed && parsed.path) {
|
|
921
|
-
|
|
922
|
-
openStore.set({ filePath: parsed.path, sessionId: sid, _t: Date.now() });
|
|
938
|
+
openViewerForPath(openStore, sessionStore, parsed.path, parsed.sessionId);
|
|
923
939
|
return;
|
|
924
940
|
}
|
|
925
941
|
const review = parseChangesReviewResource(address);
|
|
@@ -933,7 +949,7 @@ window.__ModuleLoader__.load({
|
|
|
933
949
|
.then((data) => {
|
|
934
950
|
const file = data?.files?.[idx]?.path || data?.files?.[0]?.path;
|
|
935
951
|
if (file) {
|
|
936
|
-
openStore
|
|
952
|
+
openViewerForPath(openStore, sessionStore, file);
|
|
937
953
|
}
|
|
938
954
|
})
|
|
939
955
|
.catch(() => {});
|
|
@@ -946,8 +962,7 @@ window.__ModuleLoader__.load({
|
|
|
946
962
|
sr.openResourceIn = function(sessionId, address, options) {
|
|
947
963
|
const parsed = parseFileResource(address);
|
|
948
964
|
if (parsed && parsed.path) {
|
|
949
|
-
|
|
950
|
-
openStore.set({ filePath: parsed.path, sessionId: sid, _t: Date.now() });
|
|
965
|
+
openViewerForPath(openStore, sessionStore, parsed.path, sessionId || parsed.sessionId);
|
|
951
966
|
return;
|
|
952
967
|
}
|
|
953
968
|
const review = parseChangesReviewResource(address);
|
|
@@ -960,7 +975,7 @@ window.__ModuleLoader__.load({
|
|
|
960
975
|
.then((data) => {
|
|
961
976
|
const file = data?.files?.[idx]?.path || data?.files?.[0]?.path;
|
|
962
977
|
if (file) {
|
|
963
|
-
openStore
|
|
978
|
+
openViewerForPath(openStore, sessionStore, file, sid);
|
|
964
979
|
}
|
|
965
980
|
})
|
|
966
981
|
.catch(() => {});
|
|
@@ -1000,6 +1015,63 @@ window.__ModuleLoader__.load({
|
|
|
1000
1015
|
};
|
|
1001
1016
|
}
|
|
1002
1017
|
|
|
1018
|
+
/**
|
|
1019
|
+
* Take over the Desktop Host's changed-file open route.
|
|
1020
|
+
*
|
|
1021
|
+
* The changes card's native open control does not go through the RPC
|
|
1022
|
+
* carrier: it POSTs `/api/changes.open?sessionId=…&seq=…&index=…`, which
|
|
1023
|
+
* hands the file to the operating system. Nothing in the RPC path can see
|
|
1024
|
+
* that call, so on Desktop the changes card opened the system editor while
|
|
1025
|
+
* the Web build (where the control is not mounted at all) opened the
|
|
1026
|
+
* viewer. Resolving the same coordinates through the summary route gives the
|
|
1027
|
+
* path, and a synthetic OK response keeps the Host's card state settled.
|
|
1028
|
+
*/
|
|
1029
|
+
function wrapChangesOpenFetch(openStore, sessionStore) {
|
|
1030
|
+
if (typeof window === "undefined" || typeof window.fetch !== "function") return;
|
|
1031
|
+
const originalFetch = window.fetch;
|
|
1032
|
+
if (wiredOpenPath === null || wiredOpenPath.has(originalFetch)) return;
|
|
1033
|
+
wiredOpenPath.add(originalFetch);
|
|
1034
|
+
|
|
1035
|
+
const summaryPath = (sessionId, seq) =>
|
|
1036
|
+
`/api/changes.summary?${new URLSearchParams({ sessionId, seq: String(seq) })}`;
|
|
1037
|
+
|
|
1038
|
+
window.fetch = function(input, init) {
|
|
1039
|
+
const requestUrl = typeof input === "string" ? input : (input && typeof input.url === "string" ? input.url : "");
|
|
1040
|
+
const method = String((init && init.method) || (input && input.method) || "GET").toUpperCase();
|
|
1041
|
+
if (method !== "POST" || !/\/api\/changes\.open\b/.test(requestUrl)) {
|
|
1042
|
+
return originalFetch.apply(this, arguments);
|
|
1043
|
+
}
|
|
1044
|
+
let coordinates;
|
|
1045
|
+
try {
|
|
1046
|
+
const query = new URLSearchParams(requestUrl.split("?")[1] || "");
|
|
1047
|
+
coordinates = {
|
|
1048
|
+
sessionId: query.get("sessionId") || "",
|
|
1049
|
+
seq: Number(query.get("seq")),
|
|
1050
|
+
index: Number(query.get("index") || 0),
|
|
1051
|
+
};
|
|
1052
|
+
} catch (_) {
|
|
1053
|
+
coordinates = null;
|
|
1054
|
+
}
|
|
1055
|
+
if (!coordinates || !coordinates.sessionId || !Number.isFinite(coordinates.seq)) {
|
|
1056
|
+
return originalFetch.apply(this, arguments);
|
|
1057
|
+
}
|
|
1058
|
+
const { sessionId, seq, index } = coordinates;
|
|
1059
|
+
return originalFetch(summaryPath(sessionId, seq), { signal: init && init.signal })
|
|
1060
|
+
.then((response) => (response && response.ok ? response.json() : null))
|
|
1061
|
+
.then((data) => {
|
|
1062
|
+
const files = Array.isArray(data && data.files) ? data.files : [];
|
|
1063
|
+
const entry = files[Number.isFinite(index) ? index : 0] || files[0];
|
|
1064
|
+
const file = entry && entry.path;
|
|
1065
|
+
if (!file) return originalFetch.apply(this, arguments);
|
|
1066
|
+
openViewerForPath(openStore, sessionStore, file, sessionId);
|
|
1067
|
+
// The Host only reads `response.ok`; a bodiless 204 settles its card
|
|
1068
|
+
// without claiming the OS actually opened anything.
|
|
1069
|
+
return new Response(null, { status: 204 });
|
|
1070
|
+
})
|
|
1071
|
+
.catch(() => originalFetch.apply(this, arguments));
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1003
1075
|
const COMMON_FILE_EXTENSIONS = new Set([
|
|
1004
1076
|
"go", "ts", "js", "tsx", "jsx", "mjs", "cjs", "py", "java", "c", "cpp", "h", "hpp", "rs", "php", "rb", "swift", "kt", "scala",
|
|
1005
1077
|
"md", "markdown", "txt", "json", "jsonc", "yaml", "yml", "toml", "xml", "html", "css", "scss", "less", "vue", "svelte",
|
|
@@ -1113,6 +1185,25 @@ window.__ModuleLoader__.load({
|
|
|
1113
1185
|
}
|
|
1114
1186
|
}
|
|
1115
1187
|
|
|
1188
|
+
// 3. The Host's native open control carries no path of its own: its label
|
|
1189
|
+
// is the action ("Open", "Open in …"), never the file. The path is
|
|
1190
|
+
// published by the surrounding row in a hidden description span, so
|
|
1191
|
+
// walk up a few levels and take the first span that holds a path. The
|
|
1192
|
+
// walk is bounded and every candidate still has to pass the path
|
|
1193
|
+
// shape check, so a miss falls through to the Host rather than opening
|
|
1194
|
+
// the wrong file.
|
|
1195
|
+
if (element.closest && element.closest("[data-open-path-open]")) {
|
|
1196
|
+
let scope = element.parentElement;
|
|
1197
|
+
for (let depth = 0; scope && depth < 4; depth += 1) {
|
|
1198
|
+
const hiddenPath = scope.querySelector ? scope.querySelector("span[hidden]") : null;
|
|
1199
|
+
if (hiddenPath && hiddenPath.textContent) {
|
|
1200
|
+
const p = normalizeClickedPath(hiddenPath.textContent);
|
|
1201
|
+
if (p) return p;
|
|
1202
|
+
}
|
|
1203
|
+
scope = scope.parentElement;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1116
1207
|
const sources = [
|
|
1117
1208
|
element.getAttribute("data-file-path"),
|
|
1118
1209
|
element.getAttribute("data-path"),
|
|
@@ -1130,6 +1221,13 @@ window.__ModuleLoader__.load({
|
|
|
1130
1221
|
function isFileOpenControl(element) {
|
|
1131
1222
|
if (!element) return false;
|
|
1132
1223
|
if (element.hasAttribute("data-file-path") || element.hasAttribute("data-path")) return true;
|
|
1224
|
+
// The Desktop Host answers `canOpenWorkspacePath` with true, so the shell
|
|
1225
|
+
// mounts its native "open in application" button beside every file path
|
|
1226
|
+
// (the changes card included). That button sits above the path control and
|
|
1227
|
+
// takes the click, so it has to count as a file control or the viewer
|
|
1228
|
+
// never sees the gesture. The `-more` chevron is a menu of applications,
|
|
1229
|
+
// not a plain open, so it is deliberately left to the Host.
|
|
1230
|
+
if (element.closest("[data-open-path-open]")) return true;
|
|
1133
1231
|
if (element.closest("[data-produced-files-row]")) return true;
|
|
1134
1232
|
// Support deliverables: changed files card (ChangedFiles) and presented file cards (PresentedFileCard)
|
|
1135
1233
|
if (element.closest("[data-changed-files], [data-presented-file], [data-presented-files-row]")) return true;
|
|
@@ -1360,22 +1458,28 @@ window.__ModuleLoader__.load({
|
|
|
1360
1458
|
}
|
|
1361
1459
|
|
|
1362
1460
|
/**
|
|
1363
|
-
* Trigger browser file download from a Blob using an ephemeral anchor tag.
|
|
1461
|
+
* Trigger browser file download from a Blob or URL using an ephemeral anchor tag.
|
|
1364
1462
|
*/
|
|
1365
|
-
function triggerDownload(
|
|
1366
|
-
|
|
1463
|
+
function triggerDownload(target, filename) {
|
|
1464
|
+
const currentWin = typeof globalThis !== "undefined" && globalThis.window ? globalThis.window : (typeof window !== "undefined" ? window : null);
|
|
1465
|
+
const currentDoc = typeof globalThis !== "undefined" && globalThis.document ? globalThis.document : (typeof document !== "undefined" ? document : null);
|
|
1466
|
+
if (!currentWin || !currentDoc) return false;
|
|
1367
1467
|
try {
|
|
1368
|
-
const
|
|
1369
|
-
const
|
|
1468
|
+
const isBlob = typeof Blob !== "undefined" && target instanceof Blob;
|
|
1469
|
+
const url = isBlob ? (currentWin.URL?.createObjectURL?.(target) || URL.createObjectURL(target)) : String(target);
|
|
1470
|
+
const anchor = currentDoc.createElement("a");
|
|
1370
1471
|
anchor.href = url;
|
|
1371
1472
|
anchor.download = filename || "download";
|
|
1372
1473
|
anchor.style.display = "none";
|
|
1373
|
-
|
|
1474
|
+
currentDoc.body.appendChild(anchor);
|
|
1374
1475
|
anchor.click();
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1476
|
+
currentDoc.body.removeChild(anchor);
|
|
1477
|
+
if (isBlob) {
|
|
1478
|
+
const timer = setTimeout(() => {
|
|
1479
|
+
try { (currentWin.URL?.revokeObjectURL || URL.revokeObjectURL)(url); } catch (_) {}
|
|
1480
|
+
}, 60000);
|
|
1481
|
+
if (typeof timer?.unref === "function") timer.unref();
|
|
1482
|
+
}
|
|
1379
1483
|
return true;
|
|
1380
1484
|
} catch (_) {
|
|
1381
1485
|
return false;
|
|
@@ -1383,14 +1487,160 @@ window.__ModuleLoader__.load({
|
|
|
1383
1487
|
}
|
|
1384
1488
|
|
|
1385
1489
|
/**
|
|
1386
|
-
*
|
|
1490
|
+
* Whether this page runs inside the Desktop (Electron) shell.
|
|
1491
|
+
*
|
|
1492
|
+
* The Desktop host ships this same UI inside Electron, where `<a download>`
|
|
1493
|
+
* has no download panel to report progress in: the shell saves to a default
|
|
1494
|
+
* folder or opens its own dialog, and a 100 MB transfer gives the user no
|
|
1495
|
+
* feedback while it runs. A real browser owns progress, pause, resume and
|
|
1496
|
+
* the save location, so handing it the URL is strictly better there than
|
|
1497
|
+
* buffering the bytes in the page.
|
|
1498
|
+
*
|
|
1499
|
+
* Electron identifies itself in the UA (and in `process.versions`). A wrong
|
|
1500
|
+
* answer only changes which transfer strategy runs, never which files may
|
|
1501
|
+
* be read, so the probe stays cheap and silent.
|
|
1502
|
+
* @param {any} win - the window to inspect.
|
|
1503
|
+
* @returns {boolean} true when the page is the Desktop shell.
|
|
1387
1504
|
*/
|
|
1388
|
-
function
|
|
1389
|
-
|
|
1505
|
+
function isDesktopShell(win) {
|
|
1506
|
+
try {
|
|
1507
|
+
if (/Electron\//i.test(String(win?.navigator?.userAgent ?? ""))) return true;
|
|
1508
|
+
const versions = win?.process?.versions;
|
|
1509
|
+
return typeof versions?.electron === "string";
|
|
1510
|
+
} catch (_) {
|
|
1511
|
+
return false;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* Download one file, choosing the transfer that fits the host shell.
|
|
1517
|
+
*
|
|
1518
|
+
* Two strategies, because the two shells have opposite needs:
|
|
1519
|
+
* - a browser has a download manager that owns progress, pause, resume
|
|
1520
|
+
* and the save location, so the authenticated URL is handed straight
|
|
1521
|
+
* to it — a HEAD probe first turns "413" and "404" into an immediate
|
|
1522
|
+
* notice instead of a failed download entry;
|
|
1523
|
+
* - the Desktop shell has no such panel, so the bytes stream through
|
|
1524
|
+
* `fetch` with a real-time percentage, then reach the save flow as a
|
|
1525
|
+
* blob. That path keeps the page responsive and is the only one that
|
|
1526
|
+
* can show progress inside the drawer.
|
|
1527
|
+
* Both fall back to the RPC `bytes` method when the streaming route is
|
|
1528
|
+
* unavailable, so an older host or a blocked route still downloads.
|
|
1529
|
+
* @param {(method: string, payload: object) => Promise<any>} requestFn - RPC caller.
|
|
1530
|
+
* @param {string} root - root id the file belongs to.
|
|
1531
|
+
* @param {string} path - path relative to that root.
|
|
1532
|
+
* @param {string} [name] - display name to save under.
|
|
1533
|
+
* @param {(message: string) => void} [onToast] - progress and result sink.
|
|
1534
|
+
* @returns {Promise<boolean>} whether a download was started.
|
|
1535
|
+
*/
|
|
1536
|
+
async function performDownload(requestFn, root, path, name, onToast) {
|
|
1537
|
+
if (!root || !path) {
|
|
1390
1538
|
if (onToast) onToast("未指定下载文件路径");
|
|
1391
|
-
return
|
|
1539
|
+
return false;
|
|
1392
1540
|
}
|
|
1393
1541
|
const filename = name || baseNameOf(path);
|
|
1542
|
+
const currentWin = typeof globalThis !== "undefined" && globalThis.window ? globalThis.window : (typeof window !== "undefined" ? window : null);
|
|
1543
|
+
const currentDoc = typeof globalThis !== "undefined" && globalThis.document ? globalThis.document : (typeof document !== "undefined" ? document : null);
|
|
1544
|
+
|
|
1545
|
+
if (currentWin && currentDoc && currentWin.location && currentWin.location.origin) {
|
|
1546
|
+
const downloadUrl = `/api/file-viewer/download?root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}&filename=${encodeURIComponent(filename)}`;
|
|
1547
|
+
const canFetch = typeof currentWin.fetch === "function";
|
|
1548
|
+
|
|
1549
|
+
// 浏览器:把鉴权后的 URL 直接交给原生下载管理器(进度、暂停、续传、保存位置都由它负责)
|
|
1550
|
+
if (canFetch && !isDesktopShell(currentWin)) {
|
|
1551
|
+
try {
|
|
1552
|
+
const head = await currentWin.fetch(downloadUrl, { method: "HEAD", credentials: "same-origin" });
|
|
1553
|
+
if (head.status === 413) {
|
|
1554
|
+
if (onToast) onToast("文件超过下载限制(上限 100MB)");
|
|
1555
|
+
return false;
|
|
1556
|
+
}
|
|
1557
|
+
if (!head.ok) throw new Error(`HTTP ${head.status}`);
|
|
1558
|
+
} catch (_) {
|
|
1559
|
+
// A failed probe is not a failed download: the browser still owns
|
|
1560
|
+
// the request and reports its own outcome.
|
|
1561
|
+
}
|
|
1562
|
+
const handed = triggerDownload(downloadUrl, filename);
|
|
1563
|
+
if (handed) {
|
|
1564
|
+
if (onToast) onToast(`已交由浏览器下载 ${filename}`);
|
|
1565
|
+
return true;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// 桌面端(或直链不可用):流式读取并实时显示百分比进度
|
|
1570
|
+
if (canFetch) {
|
|
1571
|
+
try {
|
|
1572
|
+
if (onToast) onToast(`正在准备下载 ${filename}...`);
|
|
1573
|
+
const resp = await currentWin.fetch(downloadUrl, { method: "GET", credentials: "same-origin" });
|
|
1574
|
+
if (!resp.ok) {
|
|
1575
|
+
if (resp.status === 413) {
|
|
1576
|
+
if (onToast) onToast("文件超过下载限制(上限 100MB)");
|
|
1577
|
+
return false;
|
|
1578
|
+
}
|
|
1579
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const contentLength = Number(resp.headers?.get?.("content-length")) || 0;
|
|
1583
|
+
const total = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : 0;
|
|
1584
|
+
|
|
1585
|
+
let blob;
|
|
1586
|
+
if (resp.body && typeof resp.body.getReader === "function") {
|
|
1587
|
+
const reader = resp.body.getReader();
|
|
1588
|
+
const chunks = [];
|
|
1589
|
+
let loaded = 0;
|
|
1590
|
+
let lastUpdate = 0;
|
|
1591
|
+
|
|
1592
|
+
while (true) {
|
|
1593
|
+
const { done, value } = await reader.read();
|
|
1594
|
+
if (done) break;
|
|
1595
|
+
if (value) {
|
|
1596
|
+
chunks.push(value);
|
|
1597
|
+
loaded += value.byteLength;
|
|
1598
|
+
const now = Date.now();
|
|
1599
|
+
if (now - lastUpdate > 100 || (total > 0 && loaded >= total)) {
|
|
1600
|
+
lastUpdate = now;
|
|
1601
|
+
const percent = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
|
1602
|
+
const progressText = total > 0
|
|
1603
|
+
? `正在下载 ${filename} (${percent}% - ${formatBytes(loaded)} / ${formatBytes(total)})...`
|
|
1604
|
+
: `正在下载 ${filename} (${formatBytes(loaded)})...`;
|
|
1605
|
+
if (onToast) onToast(progressText);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
const mime = resp.headers?.get?.("content-type") || "application/octet-stream";
|
|
1610
|
+
blob = new Blob(chunks, { type: mime });
|
|
1611
|
+
} else if (typeof resp.blob === "function") {
|
|
1612
|
+
blob = await resp.blob();
|
|
1613
|
+
} else {
|
|
1614
|
+
blob = new Blob([await resp.arrayBuffer()]);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const ok = triggerDownload(blob, filename);
|
|
1618
|
+
if (onToast) {
|
|
1619
|
+
onToast(ok ? `✓ 下载完成 ${filename} (${formatBytes(blob.size)})` : "下载触发失败,请重试");
|
|
1620
|
+
}
|
|
1621
|
+
return ok;
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
if (err?.message?.includes("413") || err?.code === "too-large") {
|
|
1624
|
+
if (onToast) onToast("文件超过下载限制(上限 100MB)");
|
|
1625
|
+
return false;
|
|
1626
|
+
}
|
|
1627
|
+
// 流式通道异常时继续尝试直链与 RPC
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// 无 fetch 环境:只能把 URL 交给宿主处理
|
|
1632
|
+
const direct = triggerDownload(downloadUrl, filename);
|
|
1633
|
+
if (direct) {
|
|
1634
|
+
if (onToast) onToast(`已交由浏览器下载 ${filename}`);
|
|
1635
|
+
return true;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
if (typeof requestFn !== "function") {
|
|
1640
|
+
if (onToast) onToast("未配置有效的下载通道");
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1394
1644
|
if (onToast) onToast(`正在准备下载 ${filename}...`);
|
|
1395
1645
|
return requestFn("bytes", { root, path })
|
|
1396
1646
|
.then((res) => {
|
|
@@ -1405,7 +1655,7 @@ window.__ModuleLoader__.load({
|
|
|
1405
1655
|
})
|
|
1406
1656
|
.catch((err) => {
|
|
1407
1657
|
const msg = err?.code === "too-large"
|
|
1408
|
-
? "文件超过下载限制(上限
|
|
1658
|
+
? "文件超过下载限制(上限 100MB)"
|
|
1409
1659
|
: `下载失败: ${err?.message || "网络异常"}`;
|
|
1410
1660
|
if (onToast) onToast(msg);
|
|
1411
1661
|
return false;
|
|
@@ -1493,6 +1743,11 @@ window.__ModuleLoader__.load({
|
|
|
1493
1743
|
// Which session the conversation header currently belongs to. The phone
|
|
1494
1744
|
// entry is drawn outside that header and cannot receive its props.
|
|
1495
1745
|
const sessionStore = createStore(undefined);
|
|
1746
|
+
// Publish this apply's stores to the Host wrappers. A re-apply installs new
|
|
1747
|
+
// stores but the wrappers are already in place, so they must follow the
|
|
1748
|
+
// ones the freshly mounted overlay subscribes to.
|
|
1749
|
+
liveOpenStore = openStore;
|
|
1750
|
+
liveSessionStore = sessionStore;
|
|
1496
1751
|
// The composer's draft accessors, captured from the slot that receives
|
|
1497
1752
|
// them. The drawer renders in a different subtree and cannot reach the
|
|
1498
1753
|
// input state through props.
|
|
@@ -3394,6 +3649,9 @@ window.__ModuleLoader__.load({
|
|
|
3394
3649
|
// Intercept any openWorkspacePath RPC calls (connection is declared in inject)
|
|
3395
3650
|
wrapConnectionRpc(ctx, openStore, sessionStore);
|
|
3396
3651
|
|
|
3652
|
+
// The changes card's native open route bypasses the RPC carrier entirely.
|
|
3653
|
+
wrapChangesOpenFetch(openStore, sessionStore);
|
|
3654
|
+
|
|
3397
3655
|
// Wrap sidebarRight if already registered or whenever injected. Direct
|
|
3398
3656
|
// property access is not safe here: sidebarRight is not part of this
|
|
3399
3657
|
// plugin's inject, so cordis would reject the read outright.
|
|
@@ -3441,8 +3699,8 @@ window.__ModuleLoader__.load({
|
|
|
3441
3699
|
LANG_OVERRIDES_KEY, LANG_OVERRIDES_MAX, readLangOverrides, writeLangOverrides, readLangOverride, writeLangOverride,
|
|
3442
3700
|
langOverrideKey, isKnownLanguageId, setLanguageCatalog, getLanguageCatalog: () => languageCatalog, LanguagePicker,
|
|
3443
3701
|
MARKDOWN_LABELS, MARKDOWN_CODE_LABELS, READ_BLOCK_LABELS, JSON_TREE_LABELS, DIFF_BLOCK_LABELS, ErrorBoundary,
|
|
3444
|
-
openViewerForPath, wrapWorkspaceOpenPath, wrapConnectionRpc, wrapSidebarRight, parseFileResource, setupGlobalFileClickInterceptor, extractClickedPath, normalizeClickedPath, looksLikeFilePath, isFileOpenControl, isFileMentionCode, renderRootOptions,
|
|
3445
|
-
triggerDownload, performDownload, SvgIconDownload,
|
|
3702
|
+
openViewerForPath, wrapWorkspaceOpenPath, wrapConnectionRpc, wrapChangesOpenFetch, wrapSidebarRight, parseFileResource, setupGlobalFileClickInterceptor, extractClickedPath, normalizeClickedPath, looksLikeFilePath, isFileOpenControl, isFileMentionCode, renderRootOptions,
|
|
3703
|
+
triggerDownload, performDownload, isDesktopShell, SvgIconDownload,
|
|
3446
3704
|
};
|
|
3447
3705
|
return module.exports;
|
|
3448
3706
|
}
|
package/lib/core.js
CHANGED
|
@@ -7,9 +7,26 @@
|
|
|
7
7
|
* @module dsh-file-viewer/core
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Largest binary payload the RPC viewer channel will base64-encode (20 MB).
|
|
12
|
+
*
|
|
13
|
+
* This budget belongs to the in-page preview path, which ships bytes as a
|
|
14
|
+
* base64 string inside a JSON envelope: a 20 MB file already costs ~27 MB of
|
|
15
|
+
* wire text plus an `atob` decode on the main thread. Raising it would make
|
|
16
|
+
* every preview of a large PDF or image block the browser, so downloads —
|
|
17
|
+
* which have their own streaming path — deliberately do not share it.
|
|
18
|
+
*/
|
|
11
19
|
export const MAX_BYTES = 20 * 1024 * 1024
|
|
12
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Largest file the download endpoint will stream (100 MB).
|
|
23
|
+
*
|
|
24
|
+
* Kept separate from {@link MAX_BYTES} on purpose: a download is consumed by
|
|
25
|
+
* the save flow rather than decoded into the page, so it can afford a budget
|
|
26
|
+
* the in-page preview path could never justify.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024
|
|
29
|
+
|
|
13
30
|
/** Largest text file the viewer reads as text at all. */
|
|
14
31
|
export const MAX_TEXT_BYTES = 8 * 1024 * 1024
|
|
15
32
|
|
|
@@ -545,6 +562,38 @@ export function mediaTypeOf(path) {
|
|
|
545
562
|
return Object.hasOwn(MEDIA_TYPE_BY_EXTENSION, ext) ? MEDIA_TYPE_BY_EXTENSION[ext] : undefined
|
|
546
563
|
}
|
|
547
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Build a `Content-Disposition` value that survives non-ASCII file names.
|
|
567
|
+
*
|
|
568
|
+
* HTTP header values are ByteStrings: every code unit must be ≤ 0xFF, so a raw
|
|
569
|
+
* Chinese or emoji name makes `Response` reject the whole header (and the
|
|
570
|
+
* download fails with a 500). RFC 6266/5987 solves this with two parameters:
|
|
571
|
+
* a plain `filename=` fallback restricted to ASCII, and `filename*=` carrying
|
|
572
|
+
* the real name as percent-encoded UTF-8. Browsers prefer `filename*` when it
|
|
573
|
+
* is present, so the fallback only has to stay legal, not pretty.
|
|
574
|
+
*
|
|
575
|
+
* The fallback drops anything outside printable ASCII, then collapses the
|
|
576
|
+
* result so a name made entirely of non-ASCII characters still yields a usable
|
|
577
|
+
* value instead of an empty one.
|
|
578
|
+
* @param {string} name - the file name to advertise.
|
|
579
|
+
* @returns {string} a header value safe for any byte-string header carrier.
|
|
580
|
+
*/
|
|
581
|
+
export function contentDispositionFor(name) {
|
|
582
|
+
const raw = typeof name === 'string' ? name : ''
|
|
583
|
+
// Strip CR/LF first: those would end the header rather than appear in it.
|
|
584
|
+
const withoutBreaks = raw.replace(/[\r\n]+/g, ' ')
|
|
585
|
+
// Printable ASCII minus the quote and backslash that would break the quoting.
|
|
586
|
+
const ascii = withoutBreaks.replace(/[^\x20-\x7E]/g, '').replace(/["\\]/g, '')
|
|
587
|
+
const trimmed = ascii.trim()
|
|
588
|
+
const fallback = trimmed === '' || trimmed === '.' || trimmed === '..' ? 'download' : trimmed
|
|
589
|
+
const encoded = encodeURIComponent(withoutBreaks)
|
|
590
|
+
// encodeURIComponent leaves ' ( ) * ! unescaped; RFC 5987 forbids none of
|
|
591
|
+
// them but a stray ' would be read as the charset/language separator, so it
|
|
592
|
+
// is escaped explicitly.
|
|
593
|
+
const extValue = encoded.replace(/'/g, '%27')
|
|
594
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${extValue}`
|
|
595
|
+
}
|
|
596
|
+
|
|
548
597
|
/**
|
|
549
598
|
* Which viewer renders this path. The client switches on this one word, so the
|
|
550
599
|
* host and the client can never disagree about a file's kind.
|
package/lib/index.js
CHANGED
|
@@ -23,11 +23,13 @@ import z from '@deepseek-ai/schemastery'
|
|
|
23
23
|
import {
|
|
24
24
|
LANGUAGE_CATALOG,
|
|
25
25
|
MAX_BYTES,
|
|
26
|
+
MAX_DOWNLOAD_BYTES,
|
|
26
27
|
MAX_PREVIEW_BYTES,
|
|
27
28
|
MAX_TEXT_BYTES,
|
|
28
29
|
TEXT_PROBE_BYTES,
|
|
29
30
|
appendMention,
|
|
30
31
|
baseNameOf,
|
|
32
|
+
contentDispositionFor,
|
|
31
33
|
detectLanguageFromText,
|
|
32
34
|
extensionOf,
|
|
33
35
|
formatDocTextToMarkdown,
|
|
@@ -42,6 +44,7 @@ import {
|
|
|
42
44
|
joinPath,
|
|
43
45
|
kindOf,
|
|
44
46
|
langOf,
|
|
47
|
+
mediaTypeOf,
|
|
45
48
|
normalizeSafePaths,
|
|
46
49
|
parseGitStatus,
|
|
47
50
|
parsePatchToDiffs,
|
|
@@ -59,11 +62,13 @@ import {
|
|
|
59
62
|
export {
|
|
60
63
|
LANGUAGE_CATALOG,
|
|
61
64
|
MAX_BYTES,
|
|
65
|
+
MAX_DOWNLOAD_BYTES,
|
|
62
66
|
MAX_PREVIEW_BYTES,
|
|
63
67
|
MAX_TEXT_BYTES,
|
|
64
68
|
TEXT_PROBE_BYTES,
|
|
65
69
|
appendMention,
|
|
66
70
|
baseNameOf,
|
|
71
|
+
contentDispositionFor,
|
|
67
72
|
detectLanguageFromText,
|
|
68
73
|
extensionOf,
|
|
69
74
|
formatDocTextToMarkdown,
|
|
@@ -79,6 +84,7 @@ export {
|
|
|
79
84
|
joinPath,
|
|
80
85
|
kindOf,
|
|
81
86
|
langOf,
|
|
87
|
+
mediaTypeOf,
|
|
82
88
|
normalizeSafePaths,
|
|
83
89
|
parseGitStatus,
|
|
84
90
|
parsePatchToDiffs,
|
|
@@ -101,6 +107,15 @@ export const inject = ['fs', 'connection', 'settings', 'workspaceRegistry', 'ses
|
|
|
101
107
|
export const NS = 'file-viewer'
|
|
102
108
|
export const RPC_CHANNEL = '/dsh-file-viewer'
|
|
103
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Exact Fetch route that streams one file to the browser's save flow.
|
|
112
|
+
*
|
|
113
|
+
* Lives below `/api` so the Connection carrier applies its Host/Origin fence
|
|
114
|
+
* and browser-cookie admission before this handler ever runs; only an owner
|
|
115
|
+
* Peer (the same principal the RPC channel serves) reaches it.
|
|
116
|
+
*/
|
|
117
|
+
export const DOWNLOAD_ROUTE = '/api/file-viewer/download'
|
|
118
|
+
|
|
104
119
|
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
|
|
105
120
|
|
|
106
121
|
export function liveField(schema) {
|
|
@@ -222,7 +237,8 @@ export const Config = z.object({
|
|
|
222
237
|
safePaths: liveField(z.union([z.array(PathEntry), z.string()]).default([])).description('安全访问路径(除工作区外的可访问路径列表)'),
|
|
223
238
|
safeAccessPaths: liveField(z.union([z.array(PathEntry), z.string()]).default([])).description('安全访问路径(safePaths 别名)'),
|
|
224
239
|
extraRoots: liveField(z.union([z.array(PathEntry), z.string()]).default([])).description('额外根目录(向后兼容 safePaths)'),
|
|
225
|
-
maxBytes: z.natural().default(MAX_BYTES),
|
|
240
|
+
maxBytes: z.natural().default(MAX_BYTES).description('在线预览(图片/PDF/表格等)单文件上限,默认 20 MB'),
|
|
241
|
+
maxDownloadBytes: z.natural().default(MAX_DOWNLOAD_BYTES).description('下载到本地单文件上限,默认 100 MB'),
|
|
226
242
|
settingsFile: z.string().default('').description('安全访问路径本地持久化配置文件路径(留空则默认保存在 ~/.dsh/file-viewer/settings.json)'),
|
|
227
243
|
})
|
|
228
244
|
|
|
@@ -1178,6 +1194,105 @@ export async function handleRpc(ctx, options, method, payload, signal) {
|
|
|
1178
1194
|
}
|
|
1179
1195
|
}
|
|
1180
1196
|
|
|
1197
|
+
/**
|
|
1198
|
+
* Map one thrown value onto a status plus a body that is safe to echo.
|
|
1199
|
+
*
|
|
1200
|
+
* Two rules matter here and both are enforced by the existing RPC channel
|
|
1201
|
+
* (`handleRpc`), so the streaming route must not be a weaker door:
|
|
1202
|
+
* - backend fs failures carry host codes (`FS_TOO_LARGE`, `FS_NOT_FOUND`,
|
|
1203
|
+
* …) and must land on the status the client already switches on, or the
|
|
1204
|
+
* "exceeds 100 MB" notice never fires;
|
|
1205
|
+
* - the body is an opaque reason, never `error.message`, because fs errors
|
|
1206
|
+
* embed absolute host paths (`EACCES: … 'D:/repo/secret/.env'`).
|
|
1207
|
+
* @param {unknown} error - the thrown value.
|
|
1208
|
+
* @returns {{status: number, reason: string}} status and a path-free reason.
|
|
1209
|
+
*/
|
|
1210
|
+
export function downloadFailure(error) {
|
|
1211
|
+
if (error instanceof ViewerError) {
|
|
1212
|
+
const viewerStatus = {
|
|
1213
|
+
'not-found': 404,
|
|
1214
|
+
'not-a-directory': 403,
|
|
1215
|
+
'is-a-directory': 403,
|
|
1216
|
+
'unknown-root': 404,
|
|
1217
|
+
'no-roots': 404,
|
|
1218
|
+
'outside-root': 403,
|
|
1219
|
+
'access-denied': 403,
|
|
1220
|
+
'too-large': 413,
|
|
1221
|
+
unsupported: 415,
|
|
1222
|
+
}
|
|
1223
|
+
return { status: viewerStatus[error.code] ?? 400, reason: error.code }
|
|
1224
|
+
}
|
|
1225
|
+
const code = typeof error?.code === 'string' ? error.code : ''
|
|
1226
|
+
const fsStatus = {
|
|
1227
|
+
FS_NOT_FOUND: 404,
|
|
1228
|
+
FS_NOT_DIRECTORY: 403,
|
|
1229
|
+
FS_NOT_REGULAR_FILE: 403,
|
|
1230
|
+
FS_PERMISSION_DENIED: 403,
|
|
1231
|
+
FS_SANDBOX_DENIED: 403,
|
|
1232
|
+
FS_TOO_LARGE: 413,
|
|
1233
|
+
FS_ABORTED: 499,
|
|
1234
|
+
FS_IO_ERROR: 500,
|
|
1235
|
+
}
|
|
1236
|
+
if (Object.hasOwn(fsStatus, code)) return { status: fsStatus[code], reason: code }
|
|
1237
|
+
return { status: 500, reason: 'download-failed' }
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Stream one file to the browser's save flow under the authenticated API fence.
|
|
1242
|
+
*
|
|
1243
|
+
* Files are read through `ctx.fs` with the caller's signal so a cancelled
|
|
1244
|
+
* download stops the host read, and the byte budget comes from
|
|
1245
|
+
* `maxDownloadBytes` rather than the much smaller in-page preview budget.
|
|
1246
|
+
*/
|
|
1247
|
+
export async function handleDownloadRequest(ctx, options, request) {
|
|
1248
|
+
try {
|
|
1249
|
+
const url = new URL(request.url, 'http://dsh.local')
|
|
1250
|
+
const root = url.searchParams.get('root') || undefined
|
|
1251
|
+
const path = url.searchParams.get('path') || ''
|
|
1252
|
+
const customFilename = url.searchParams.get('filename') || ''
|
|
1253
|
+
|
|
1254
|
+
const { target, relative } = await resolveInRoot(ctx, options, { root, path }, request.signal)
|
|
1255
|
+
const info = await ctx.fs.stat(target, request.signal)
|
|
1256
|
+
if (info === undefined) {
|
|
1257
|
+
return new Response('not-found', { status: 404 })
|
|
1258
|
+
}
|
|
1259
|
+
if (info.type === 'directory') {
|
|
1260
|
+
return new Response('is-a-directory', { status: 403 })
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const maxBytes = options().maxDownloadBytes ?? MAX_DOWNLOAD_BYTES
|
|
1264
|
+
if (typeof info.size === 'number' && info.size > maxBytes) {
|
|
1265
|
+
return new Response('too-large', { status: 413 })
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const naming = relative === '' ? target.displayPath : relative
|
|
1269
|
+
const filename = customFilename || baseNameOf(naming)
|
|
1270
|
+
const mime = mediaTypeOf(naming) || 'application/octet-stream'
|
|
1271
|
+
|
|
1272
|
+
const headers = {
|
|
1273
|
+
'Content-Type': mime,
|
|
1274
|
+
'Content-Disposition': contentDispositionFor(filename),
|
|
1275
|
+
'Cache-Control': 'private, no-store',
|
|
1276
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (request.method === 'HEAD') {
|
|
1280
|
+
if (info.size !== undefined) headers['Content-Length'] = String(info.size)
|
|
1281
|
+
return new Response(null, { headers })
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// `readBytes` enforces the same budget, so a backend that reports no size
|
|
1285
|
+
// still cannot hand back more than the download limit.
|
|
1286
|
+
const bytes = await ctx.fs.readBytes(target, request.signal, maxBytes)
|
|
1287
|
+
headers['Content-Length'] = String(bytes.byteLength)
|
|
1288
|
+
return new Response(bytes, { headers })
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
const { status, reason } = downloadFailure(error)
|
|
1291
|
+
ctx.logger?.warn?.(`file-viewer: download failed with ${reason}`)
|
|
1292
|
+
return new Response(reason, { status })
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1181
1296
|
/**
|
|
1182
1297
|
* Register the browser-facing channel.
|
|
1183
1298
|
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
@@ -1227,6 +1342,7 @@ export function apply(ctx, config = {}) {
|
|
|
1227
1342
|
safeAccessPaths: settings?.safeAccessPaths ?? [],
|
|
1228
1343
|
extraRoots: settings?.extraRoots ?? [],
|
|
1229
1344
|
maxBytes: settings?.maxBytes ?? MAX_BYTES,
|
|
1345
|
+
maxDownloadBytes: settings?.maxDownloadBytes ?? MAX_DOWNLOAD_BYTES,
|
|
1230
1346
|
_updateSafePaths: updateSafePaths,
|
|
1231
1347
|
_settingsFile: settingsFile,
|
|
1232
1348
|
}
|
|
@@ -1238,5 +1354,24 @@ export function apply(ctx, config = {}) {
|
|
|
1238
1354
|
(method, payload, signal) => handleRpc(ctx, options, method, payload, signal),
|
|
1239
1355
|
{ authority: 'trusted-host' },
|
|
1240
1356
|
)
|
|
1357
|
+
/*
|
|
1358
|
+
* The download route is an optimization, not a requirement: the RPC
|
|
1359
|
+
* `bytes` method still downloads anything the streaming path cannot
|
|
1360
|
+
* reach. Registering a duplicate path throws inside `connection.fetch`
|
|
1361
|
+
* (its exact-route table rejects collisions), so a failure here must
|
|
1362
|
+
* degrade to the RPC path instead of aborting the whole plugin apply.
|
|
1363
|
+
*/
|
|
1364
|
+
try {
|
|
1365
|
+
if (typeof connectionCtx.connection?.fetch?.register === 'function') {
|
|
1366
|
+
connectionCtx.connection.fetch.register({
|
|
1367
|
+
path: DOWNLOAD_ROUTE,
|
|
1368
|
+
methods: ['GET', 'HEAD'],
|
|
1369
|
+
requestBody: 'buffered',
|
|
1370
|
+
fetch: (request) => handleDownloadRequest(ctx, options, request),
|
|
1371
|
+
})
|
|
1372
|
+
}
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
ctx.logger?.warn?.(`file-viewer: download route unavailable, falling back to RPC (${error?.message || error})`)
|
|
1375
|
+
}
|
|
1241
1376
|
})
|
|
1242
1377
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weniatall/dsh-file-viewer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "Workspace file viewer for DeepSeek Harness: file tree, syntax-highlighted code with language detection and manual override, images, PDF, Excel and Word",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|