@file-viewer/renderer-archive 2.1.7 → 2.1.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/README.en.md CHANGED
@@ -30,8 +30,9 @@ const options = {
30
30
  ## Capabilities
31
31
 
32
32
  - Previews ZIP, TAR, GZIP, RAR, 7z, and common archive directories.
33
+ - Supports encrypted archives: encrypted content is handled through the unified `libarchive.js` path, the built-in dialog asks for a password, and a correct password unlocks directory reading or nested entry previews.
33
34
  - Uses `libarchive.js` Worker + WASM first to keep large archive parsing off the main thread.
34
- - Falls back to ZIP / TAR / GZIP parsing when the Worker cannot be started, which helps mobile WebViews, local static servers, and private intranet deployments.
35
+ - Falls back to ZIP / TAR / GZIP parsing when the Worker cannot be started, which helps mobile WebViews, local static servers, and private intranet deployments. Encrypted archives never use the fallback path and require the libarchive Worker/WASM assets.
35
36
  - Extracts internal files on demand, then delegates nested previews through `renderNestedBuffer` or the core dispatcher.
36
37
  - Includes archive size limits, entry preview limits, worker timeout, IndexedDB cache, and single-entry download.
37
38
 
@@ -55,6 +56,24 @@ const options = {
55
56
  }
56
57
  ```
57
58
 
59
+ ## Encrypted Archives
60
+
61
+ By default, encrypted archives open a built-in password dialog. Applications can also provide an initial password or take over password collection:
62
+
63
+ ```ts
64
+ const options = {
65
+ archive: {
66
+ password: initialPasswordFromYourSystem,
67
+ async requestPassword(context) {
68
+ // context.filename / context.entryName / context.reason / context.attempt
69
+ return await openYourPermissionCheckedPasswordModal(context)
70
+ },
71
+ },
72
+ }
73
+ ```
74
+
75
+ Return a string from `requestPassword` to continue. Return `null` or `undefined` to cancel and show a friendly notice. Wrong passwords request another password and never switch to the JSZip fallback.
76
+
58
77
  ## Migration Note
59
78
 
60
79
  The core package no longer bundles the archive renderer and no longer installs `libarchive.js` for the archive pipeline. ZIP/TAR/GZIP fallback, `jszip`, cache, and Worker logic are owned by this package; core may still temporarily retain `jszip` for the OFD vendor path until OFD is fully extracted. Install this renderer explicitly, or use `@file-viewer/preset-all`, when archive preview is required.
package/README.md CHANGED
@@ -30,8 +30,9 @@ const options = {
30
30
  ## 能力边界
31
31
 
32
32
  - 支持 ZIP、TAR、GZIP、RAR、7z 等常见压缩包目录预览。
33
+ - 支持加密压缩包:检测到加密内容后使用 `libarchive.js` 统一解密,默认弹框要求用户输入密码,密码正确后继续读取目录或预览内部文件。
33
34
  - 优先使用 `libarchive.js` Worker + WASM,避免大压缩包阻塞主线程。
34
- - Worker 不可用时自动回退到 ZIP / TAR / GZIP 兼容模式,适合手机 WebView、本地临时服务器和内网静态部署排障。
35
+ - Worker 不可用时自动回退到 ZIP / TAR / GZIP 兼容模式,适合手机 WebView、本地临时服务器和内网静态部署排障;加密压缩包不会走 fallback,必须发布 libarchive Worker/WASM。
35
36
  - 点击内部文件后才按需解压,并通过 `renderNestedBuffer` 或 core dispatcher 复用 PDF、Office、CAD、XMind、图片、代码等现有 renderer。
36
37
  - 内置体积上限、单文件预览上限、Worker 超时、IndexedDB 缓存和下载入口,避免一次性把压缩包全部展开到内存。
37
38
 
@@ -55,6 +56,24 @@ const options = {
55
56
  }
56
57
  ```
57
58
 
59
+ ## 加密压缩包
60
+
61
+ 默认情况下,检测到加密内容会显示内置密码弹框。业务也可以预置密码,或接管密码获取流程:
62
+
63
+ ```ts
64
+ const options = {
65
+ archive: {
66
+ password: initialPasswordFromYourSystem,
67
+ async requestPassword(context) {
68
+ // context.filename / context.entryName / context.reason / context.attempt
69
+ return await openYourPermissionCheckedPasswordModal(context)
70
+ },
71
+ },
72
+ }
73
+ ```
74
+
75
+ `requestPassword` 返回字符串时继续解密;返回 `null` 或 `undefined` 时取消预览并展示友好提示。错误密码会重新请求密码,不会切换到 JSZip fallback。
76
+
58
77
  ## 迁移说明
59
78
 
60
79
  `@file-viewer/core` 已不再内置 archive renderer,也不再为压缩包链路直接安装 `libarchive.js`。ZIP/TAR/GZIP fallback、`jszip`、缓存和 Worker 逻辑由本包维护;core 在 OFD 完全拆出前可能仍会因 OFD vendor 临时保留 `jszip`。需要压缩包预览时,请显式安装本包,或直接使用 `@file-viewer/preset-all` 聚合能力。
package/dist/archive.js CHANGED
@@ -1,32 +1,49 @@
1
1
  import { resolveFileViewerArchiveWasmUrl, resolveFileViewerArchiveWorkerUrl, } from '@file-viewer/core/assets';
2
- import { createFileViewerTranslator, disposeFileViewerRendered, } from '@file-viewer/core';
2
+ import { collectFileViewerRendererPlugins, createFileRenderHandlerLoader, createFileViewerCoreRendererRegistry, createFileViewerTranslator, createRendererRegistry, disposeFileViewerRendered, installFileViewerRendererPlugins, listFileViewerAutoRendererPresets, normalizeSource, resolveFileViewerRendererPresetInputs, } from '@file-viewer/core';
3
3
  import { buildArchiveNestedRenderContext, createArchiveCacheKey, flattenArchiveObject, formatArchiveBytes, getArchiveEntryExtension, } from './archiveShared.js';
4
4
  import { readArchiveCache, writeArchiveCache } from './archiveCache.js';
5
- import { loadArchiveEntriesWithoutWorker } from './archiveFallback.js';
5
+ import { isLikelyEncryptedArchive, loadArchiveEntriesWithoutWorker, } from './archiveFallback.js';
6
6
  const DEFAULT_MAX_ARCHIVE_SIZE = 320 * 1024 * 1024;
7
7
  const DEFAULT_MAX_ENTRY_PREVIEW_SIZE = 64 * 1024 * 1024;
8
8
  const DEFAULT_WORKER_TIMEOUT_MS = 30000;
9
9
  const MAX_LISTED_ENTRIES = 5000;
10
+ class ArchivePasswordCancelledError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'ArchivePasswordCancelledError';
14
+ }
15
+ }
10
16
  const archiveStyle = `
11
- .archive-shell,.archive-viewer{position:relative;box-sizing:border-box;height:100%;min-height:0;display:grid;grid-template-columns:minmax(280px,34%) minmax(0,1fr);background:#edf2f7;color:#172033;font-family:Aptos,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif}
17
+ .archive-shell,.archive-viewer{position:relative;box-sizing:border-box;width:100%;height:100%;min-width:0;min-height:0;overflow:hidden;display:grid;grid-template-columns:minmax(280px,34%) minmax(0,1fr);background:#edf2f7;color:#172033;font-family:Aptos,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif}
12
18
  .archive-shell *,.archive-viewer *{box-sizing:border-box}
13
- .archive-sidebar{min-width:0;min-height:0;display:flex;flex-direction:column;gap:12px;padding:16px;border-right:1px solid rgba(23,32,51,.08);background:rgba(255,255,255,.72)}
19
+ .archive-sidebar{min-width:0;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:12px;padding:16px;border-right:1px solid rgba(23,32,51,.08);background:rgba(255,255,255,.72);transition:opacity .18s ease,padding .18s ease,border-color .18s ease}
20
+ .archive-shell.archive-sidebar-collapsed,.archive-viewer.archive-sidebar-collapsed{grid-template-columns:0 minmax(0,1fr)}
21
+ .archive-sidebar-collapsed .archive-sidebar{display:none;width:0;max-width:0;padding:0;border-color:transparent;opacity:0;pointer-events:none}
22
+ .archive-sidebar-collapsed .archive-sidebar>*{visibility:hidden}
23
+ .archive-head{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:10px;align-items:start}
24
+ .archive-head-main{min-width:0}
14
25
  .archive-head span,.archive-preview-toolbar span{color:#6c7c90;font-size:12px;font-weight:800;letter-spacing:0}
15
26
  .archive-head strong,.archive-preview-toolbar strong{display:block;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:18px;line-height:1.25}
16
- .archive-head p{margin:8px 0 0;color:#69798b;font-size:13px}
27
+ .archive-head p{min-width:0;margin:8px 0 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#69798b;font-size:13px}
28
+ .archive-sidebar-toggle{width:34px;height:34px;flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;border:1px solid rgba(23,32,51,.1);border-radius:10px;background:#fff;color:#1f7a58;font:inherit;font-size:17px;font-weight:900;line-height:1;cursor:pointer;box-shadow:0 6px 16px rgba(23,32,51,.07)}
29
+ .archive-sidebar-toggle:hover{border-color:rgba(31,122,88,.32);background:#f0fdf4}
17
30
  .archive-warning,.archive-info,.archive-error{border-radius:12px;padding:10px 12px;background:#fff7e8;color:#8a4b00;font-size:13px;line-height:1.5}
18
31
  .archive-info{background:#ecfdf5;color:#166534}
19
32
  .archive-search{width:100%;height:42px;padding:0 12px;border-radius:12px;border:1px solid rgba(23,32,51,.1);outline:none;background:#fff;color:#172033;font:inherit}
20
- .archive-list{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:7px;padding-right:4px}
21
- .archive-entry{width:100%;min-height:58px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;gap:10px;align-items:center;padding:8px 10px 8px calc(10px + var(--entry-depth,0) * 10px);border:1px solid rgba(23,32,51,.07);border-radius:12px;background:rgba(255,255,255,.86);color:inherit;text-align:left;cursor:pointer;font:inherit}
33
+ .archive-list{flex:1;min-width:0;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:7px;padding-right:4px}
34
+ .archive-entry{width:100%;min-width:0;min-height:58px;display:grid;grid-template-columns:42px minmax(0,1fr) minmax(46px,auto);gap:10px;align-items:center;padding:8px 10px 8px calc(10px + var(--entry-depth,0) * 10px);border:1px solid rgba(23,32,51,.07);border-radius:12px;background:rgba(255,255,255,.86);color:inherit;text-align:left;cursor:pointer;font:inherit}
22
35
  .archive-entry:hover,.archive-entry.active{border-color:rgba(33,129,95,.28);box-shadow:0 10px 22px rgba(23,32,51,.08)}
23
36
  .entry-ext{height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:10px;background:rgba(33,129,95,.12);color:#1d7a56;font-size:11px;font-weight:900;text-transform:uppercase}
24
37
  .entry-copy{min-width:0}
25
38
  .entry-copy strong,.entry-copy em{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
26
39
  .entry-copy em,.archive-entry small{color:#718096;font-size:12px;font-style:normal}
40
+ .archive-entry small{min-width:0;max-width:74px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right}
27
41
  .archive-preview{min-width:0;min-height:0;display:flex;flex-direction:column}
28
- .archive-preview-toolbar{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 16px;border-bottom:1px solid rgba(23,32,51,.08);background:rgba(255,255,255,.76)}
42
+ .archive-preview-toolbar{min-width:0;min-height:64px;display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid rgba(23,32,51,.08);background:rgba(255,255,255,.76)}
29
43
  .archive-preview-toolbar button{height:34px;border:0;border-radius:10px;padding:0 12px;background:#1f7a58;color:#fff;font:inherit;font-size:13px;font-weight:800;cursor:pointer}
44
+ .archive-preview-toolbar .archive-sidebar-toggle{background:#fff;color:#1f7a58;border:1px solid rgba(23,32,51,.1);padding:0}
45
+ .archive-preview-title{min-width:0;flex:1}
46
+ .archive-preview-toolbar .archive-download-button{flex:0 0 auto}
30
47
  .archive-nested-target{position:relative;flex:1;min-height:0;overflow:auto}
31
48
  .archive-nested-content{width:100%;height:100%;min-height:420px}
32
49
  .archive-empty{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px;text-align:center;color:#64748b}
@@ -36,14 +53,28 @@ const archiveStyle = `
36
53
  .archive-state p{margin:4px 0 0;color:#64748b}
37
54
  .archive-spinner{width:34px;height:34px;flex-shrink:0;border-radius:999px;border:3px solid rgba(31,122,88,.16);border-top-color:#1f7a58;animation:archive-spin .9s linear infinite}
38
55
  .archive-error{position:absolute;right:18px;bottom:18px;width:min(460px,calc(100% - 36px));box-shadow:0 16px 36px rgba(23,32,51,.14);z-index:5}
56
+ .archive-password-dialog{position:absolute;inset:0;z-index:8;display:flex;align-items:center;justify-content:center;padding:22px;background:rgba(15,23,42,.42);backdrop-filter:blur(10px)}
57
+ .archive-password-card{width:min(420px,100%);display:flex;flex-direction:column;gap:14px;padding:20px;border-radius:18px;background:#fff;color:#172033;box-shadow:0 24px 56px rgba(15,23,42,.24)}
58
+ .archive-password-card h3{margin:0;font-size:19px;line-height:1.25}
59
+ .archive-password-card p{margin:0;color:#64748b;font-size:13px;line-height:1.55}
60
+ .archive-password-card input{width:100%;height:44px;border-radius:12px;border:1px solid rgba(23,32,51,.12);outline:none;padding:0 12px;background:#fff;color:#172033;font:inherit}
61
+ .archive-password-card input:focus{border-color:rgba(31,122,88,.48);box-shadow:0 0 0 3px rgba(31,122,88,.12)}
62
+ .archive-password-error{min-height:18px;color:#b42318!important}
63
+ .archive-password-actions{display:flex;justify-content:flex-end;gap:10px}
64
+ .archive-password-actions button{height:38px;border:0;border-radius:10px;padding:0 14px;font:inherit;font-weight:800;cursor:pointer}
65
+ .archive-password-cancel{background:#eef2f7;color:#334155}
66
+ .archive-password-submit{background:#1f7a58;color:#fff}
39
67
  .archive-hidden{display:none!important}
40
68
  .file-viewer[data-viewer-theme='dark'] .archive-shell,.file-viewer[data-viewer-theme='dark'] .archive-viewer{background:#101820;color:#e6edf3}
41
69
  .file-viewer[data-viewer-theme='dark'] .archive-sidebar,.file-viewer[data-viewer-theme='dark'] .archive-preview-toolbar{border-color:rgba(139,148,158,.2);background:rgba(21,27,35,.82)}
42
70
  .file-viewer[data-viewer-theme='dark'] .archive-entry,.file-viewer[data-viewer-theme='dark'] .archive-search,.file-viewer[data-viewer-theme='dark'] .archive-state>div{background:#151b23;color:#e6edf3;border-color:rgba(139,148,158,.2)}
71
+ .file-viewer[data-viewer-theme='dark'] .archive-password-card{background:#151b23;color:#e6edf3}
72
+ .file-viewer[data-viewer-theme='dark'] .archive-password-card input{background:#0d1117;color:#e6edf3;border-color:rgba(139,148,158,.24)}
73
+ .file-viewer[data-viewer-theme='dark'] .archive-password-cancel{background:#212a35;color:#d7dee8}
43
74
  .file-viewer[data-viewer-theme='dark'] .archive-empty strong{color:#f8fafc}
44
- @media (prefers-color-scheme:dark){.file-viewer[data-viewer-theme='system'] .archive-shell,.file-viewer[data-viewer-theme='system'] .archive-viewer{background:#101820;color:#e6edf3}.file-viewer[data-viewer-theme='system'] .archive-sidebar,.file-viewer[data-viewer-theme='system'] .archive-preview-toolbar{border-color:rgba(139,148,158,.2);background:rgba(21,27,35,.82)}.file-viewer[data-viewer-theme='system'] .archive-entry,.file-viewer[data-viewer-theme='system'] .archive-search,.file-viewer[data-viewer-theme='system'] .archive-state>div{background:#151b23;color:#e6edf3;border-color:rgba(139,148,158,.2)}.file-viewer[data-viewer-theme='system'] .archive-empty strong{color:#f8fafc}}
75
+ @media (prefers-color-scheme:dark){.file-viewer[data-viewer-theme='system'] .archive-shell,.file-viewer[data-viewer-theme='system'] .archive-viewer{background:#101820;color:#e6edf3}.file-viewer[data-viewer-theme='system'] .archive-sidebar,.file-viewer[data-viewer-theme='system'] .archive-preview-toolbar{border-color:rgba(139,148,158,.2);background:rgba(21,27,35,.82)}.file-viewer[data-viewer-theme='system'] .archive-entry,.file-viewer[data-viewer-theme='system'] .archive-search,.file-viewer[data-viewer-theme='system'] .archive-state>div{background:#151b23;color:#e6edf3;border-color:rgba(139,148,158,.2)}.file-viewer[data-viewer-theme='system'] .archive-password-card{background:#151b23;color:#e6edf3}.file-viewer[data-viewer-theme='system'] .archive-password-card input{background:#0d1117;color:#e6edf3;border-color:rgba(139,148,158,.24)}.file-viewer[data-viewer-theme='system'] .archive-password-cancel{background:#212a35;color:#d7dee8}.file-viewer[data-viewer-theme='system'] .archive-empty strong{color:#f8fafc}}
45
76
  @keyframes archive-spin{to{transform:rotate(360deg)}}
46
- @media (max-width:860px){.archive-shell,.archive-viewer{grid-template-columns:1fr;grid-template-rows:minmax(220px,38%) minmax(0,1fr)}.archive-sidebar{border-right:0;border-bottom:1px solid rgba(23,32,51,.08)}}
77
+ @media (max-width:860px){.archive-shell,.archive-viewer{grid-template-columns:1fr;grid-template-rows:minmax(220px,38%) minmax(0,1fr)}.archive-shell.archive-sidebar-collapsed,.archive-viewer.archive-sidebar-collapsed{grid-template-columns:1fr;grid-template-rows:0 minmax(0,1fr)}.archive-sidebar{border-right:0;border-bottom:1px solid rgba(23,32,51,.08)}.archive-preview-toolbar{min-height:56px;padding:10px 12px}.archive-head strong,.archive-preview-toolbar strong{font-size:16px}.archive-entry{grid-template-columns:38px minmax(0,1fr) minmax(40px,auto)}}
47
78
  `;
48
79
  const createStyle = (documentRef) => {
49
80
  const style = documentRef.createElement('style');
@@ -66,6 +97,62 @@ const normalizeWorkerError = (reason) => {
66
97
  }
67
98
  return typeof reason === 'string' ? reason : JSON.stringify(reason);
68
99
  };
100
+ const isArchivePasswordError = (reason) => {
101
+ const message = normalizeWorkerError(reason).toLowerCase();
102
+ return /passphrase|password|encrypted|decrypt|crypto|wrong key|incorrect/i.test(message);
103
+ };
104
+ const resolveAutoRenderersEnabled = (options = {}) => {
105
+ const setting = options.autoRenderers;
106
+ if (typeof setting === 'boolean') {
107
+ return setting;
108
+ }
109
+ if ((setting === null || setting === void 0 ? void 0 : setting.enabled) !== undefined) {
110
+ return setting.enabled;
111
+ }
112
+ return (options.rendererMode || 'extend') !== 'replace';
113
+ };
114
+ const createNestedRendererRegistry = async (context) => {
115
+ const options = (context === null || context === void 0 ? void 0 : context.options) || {};
116
+ const registry = options.rendererMode === 'replace'
117
+ ? createRendererRegistry([])
118
+ : createFileViewerCoreRendererRegistry({
119
+ builtinRenderers: options.builtinRenderers,
120
+ }).registry;
121
+ const rendererInputs = [];
122
+ if (resolveAutoRenderersEnabled(options)) {
123
+ rendererInputs.push(...listFileViewerAutoRendererPresets());
124
+ }
125
+ rendererInputs.push(...resolveFileViewerRendererPresetInputs(options.preset), ...resolveFileViewerRendererPresetInputs(options.presets));
126
+ if (options.renderers) {
127
+ rendererInputs.push(options.renderers);
128
+ }
129
+ const plugins = collectFileViewerRendererPlugins(rendererInputs);
130
+ if (!plugins.length) {
131
+ return registry;
132
+ }
133
+ await installFileViewerRendererPlugins({
134
+ registry,
135
+ plugins,
136
+ registerHandler: registration => {
137
+ const definition = registry.getById(registration.rendererId);
138
+ if (!definition) {
139
+ return;
140
+ }
141
+ registry.register({
142
+ ...definition,
143
+ load: createFileRenderHandlerLoader({
144
+ handler: registration.handler,
145
+ getTarget: loadContext => loadContext.surface.container,
146
+ }),
147
+ });
148
+ },
149
+ });
150
+ return registry;
151
+ };
152
+ const createNestedRenderedInstance = (target, session) => ({
153
+ $el: target,
154
+ destroy: () => { var _a; return (_a = session.destroy) === null || _a === void 0 ? void 0 : _a.call(session); },
155
+ });
69
156
  const withTimeout = async (promise, timeout, message, targetWindow) => {
70
157
  let timer = 0;
71
158
  const timerWindow = targetWindow || (typeof window !== 'undefined' ? window : undefined);
@@ -190,17 +277,36 @@ const resolveWorkerCandidates = async (documentRef, options) => {
190
277
  }
191
278
  return candidates;
192
279
  };
193
- const renderNestedWithCoreFallback = async (buffer, type, target, context) => {
280
+ const renderNestedWithCurrentOptions = async (buffer, type, target, context) => {
194
281
  const t = createFileViewerTranslator(context === null || context === void 0 ? void 0 : context.options);
195
- const { fileViewerCoreRendererDispatcher } = await import('@file-viewer/core');
196
- const handler = fileViewerCoreRendererDispatcher.resolve(type);
197
- if (!handler) {
282
+ const registry = await createNestedRendererRegistry(context);
283
+ const renderer = registry.getByExtension(type);
284
+ if (!(renderer === null || renderer === void 0 ? void 0 : renderer.load)) {
198
285
  target.textContent = t('archive.error.nestedUnsupported', { type });
199
286
  return undefined;
200
287
  }
201
- return handler(buffer, target, type, {
288
+ const session = await renderer.load({
289
+ source: normalizeSource({
290
+ buffer,
291
+ filename: (context === null || context === void 0 ? void 0 : context.filename) || `preview.${type}`,
292
+ type,
293
+ url: context === null || context === void 0 ? void 0 : context.url,
294
+ }),
295
+ surface: { container: target },
296
+ options: (context === null || context === void 0 ? void 0 : context.options) || {},
297
+ registerExportAdapter: context === null || context === void 0 ? void 0 : context.registerExportAdapter,
298
+ renderContext: {
299
+ ...context,
300
+ renderNestedBuffer: (context === null || context === void 0 ? void 0 : context.renderNestedBuffer) || renderNestedWithCurrentOptions,
301
+ },
302
+ });
303
+ return createNestedRenderedInstance(target, session);
304
+ };
305
+ const renderNestedEntry = (buffer, type, target, context) => {
306
+ const nestedRender = (context === null || context === void 0 ? void 0 : context.renderNestedBuffer) || renderNestedWithCurrentOptions;
307
+ return nestedRender(buffer, type, target, {
202
308
  ...context,
203
- renderNestedBuffer: (context === null || context === void 0 ? void 0 : context.renderNestedBuffer) || renderNestedWithCoreFallback,
309
+ renderNestedBuffer: (context === null || context === void 0 ? void 0 : context.renderNestedBuffer) || renderNestedWithCurrentOptions,
204
310
  });
205
311
  };
206
312
  export default async function renderArchive(buffer, target, _type, context) {
@@ -227,14 +333,22 @@ export default async function renderArchive(buffer, target, _type, context) {
227
333
  let archiveNotice = '';
228
334
  let encrypted = null;
229
335
  let filterText = '';
336
+ let passwordAttempt = 0;
337
+ let passwordResolver = null;
338
+ let sidebarCollapsed = false;
230
339
  const style = createStyle(documentRef);
231
340
  const root = createElement(documentRef, 'section', 'archive-shell archive-viewer');
232
341
  const sidebar = createElement(documentRef, 'aside', 'archive-sidebar');
233
342
  const head = createElement(documentRef, 'div', 'archive-head');
343
+ const headMain = createElement(documentRef, 'div', 'archive-head-main');
234
344
  const badge = createElement(documentRef, 'span', undefined, 'ARCHIVE');
235
345
  const title = createElement(documentRef, 'strong', undefined, filename);
346
+ title.title = filename;
236
347
  const stats = createElement(documentRef, 'p');
237
- head.append(badge, title, stats);
348
+ const sidebarHideButton = createElement(documentRef, 'button', 'archive-sidebar-toggle');
349
+ sidebarHideButton.type = 'button';
350
+ headMain.append(badge, title, stats);
351
+ head.append(headMain, sidebarHideButton);
238
352
  const warning = createElement(documentRef, 'div', 'archive-warning');
239
353
  const info = createElement(documentRef, 'div', 'archive-info');
240
354
  const search = createElement(documentRef, 'input', 'archive-search');
@@ -245,11 +359,13 @@ export default async function renderArchive(buffer, target, _type, context) {
245
359
  sidebar.append(head, warning, info, search, list);
246
360
  const preview = createElement(documentRef, 'main', 'archive-preview');
247
361
  const toolbar = createElement(documentRef, 'div', 'archive-preview-toolbar');
248
- const toolbarTitle = createElement(documentRef, 'div');
362
+ const sidebarShowButton = createElement(documentRef, 'button', 'archive-sidebar-toggle');
363
+ sidebarShowButton.type = 'button';
364
+ const toolbarTitle = createElement(documentRef, 'div', 'archive-preview-title');
249
365
  toolbarTitle.append(createElement(documentRef, 'span', undefined, t('archive.preview.title')), createElement(documentRef, 'strong', undefined, t('archive.preview.chooseFile')));
250
- const downloadButton = createElement(documentRef, 'button', undefined, t('archive.preview.downloadFile'));
366
+ const downloadButton = createElement(documentRef, 'button', 'archive-download-button', t('archive.preview.downloadFile'));
251
367
  downloadButton.type = 'button';
252
- toolbar.append(toolbarTitle, downloadButton);
368
+ toolbar.append(sidebarShowButton, toolbarTitle, downloadButton);
253
369
  const nestedTarget = createElement(documentRef, 'div', 'archive-nested-target');
254
370
  preview.append(toolbar, nestedTarget);
255
371
  root.append(sidebar, preview);
@@ -268,11 +384,105 @@ export default async function renderArchive(buffer, target, _type, context) {
268
384
  const errorMessage = createElement(documentRef, 'p');
269
385
  error.append(errorTitle, errorMessage);
270
386
  root.append(error);
387
+ const passwordDialog = createElement(documentRef, 'div', 'archive-password-dialog archive-hidden');
388
+ passwordDialog.setAttribute('role', 'dialog');
389
+ passwordDialog.setAttribute('aria-modal', 'true');
390
+ const passwordCard = createElement(documentRef, 'form', 'archive-password-card');
391
+ const passwordTitle = createElement(documentRef, 'h3', undefined, t('archive.password.title'));
392
+ const passwordDescription = createElement(documentRef, 'p', undefined, t('archive.password.description'));
393
+ const passwordInput = createElement(documentRef, 'input');
394
+ passwordInput.type = 'password';
395
+ passwordInput.autocomplete = 'current-password';
396
+ passwordInput.placeholder = t('archive.password.placeholder');
397
+ const passwordError = createElement(documentRef, 'p', 'archive-password-error');
398
+ const passwordActions = createElement(documentRef, 'div', 'archive-password-actions');
399
+ const passwordCancelButton = createElement(documentRef, 'button', 'archive-password-cancel', t('archive.password.cancel'));
400
+ passwordCancelButton.type = 'button';
401
+ const passwordSubmitButton = createElement(documentRef, 'button', 'archive-password-submit', t('archive.password.confirm'));
402
+ passwordSubmitButton.type = 'submit';
403
+ passwordActions.append(passwordCancelButton, passwordSubmitButton);
404
+ passwordCard.append(passwordTitle, passwordDescription, passwordInput, passwordError, passwordActions);
405
+ passwordDialog.append(passwordCard);
406
+ root.append(passwordDialog);
271
407
  target.replaceChildren(style, root);
272
408
  const listen = (element, event, listener) => {
273
409
  element.addEventListener(event, listener);
274
410
  cleanups.push(() => element.removeEventListener(event, listener));
275
411
  };
412
+ const closePasswordDialog = (password) => {
413
+ passwordDialog.classList.add('archive-hidden');
414
+ const resolve = passwordResolver;
415
+ passwordResolver = null;
416
+ resolve === null || resolve === void 0 ? void 0 : resolve(password);
417
+ };
418
+ const requestPasswordWithDialog = async (reason, previousError) => {
419
+ passwordDescription.textContent = t('archive.password.description');
420
+ passwordError.textContent = previousError || reason === 'invalid-password'
421
+ ? t('archive.password.invalid')
422
+ : '';
423
+ passwordInput.value = '';
424
+ passwordDialog.classList.remove('archive-hidden');
425
+ targetWindow === null || targetWindow === void 0 ? void 0 : targetWindow.setTimeout(() => passwordInput.focus(), 0);
426
+ return new Promise((resolve) => {
427
+ passwordResolver = resolve;
428
+ });
429
+ };
430
+ const requestArchivePassword = async (reason, entry, previousError) => {
431
+ passwordAttempt += 1;
432
+ const contextPayload = {
433
+ filename,
434
+ entryName: entry === null || entry === void 0 ? void 0 : entry.name,
435
+ attempt: passwordAttempt,
436
+ reason,
437
+ error: previousError,
438
+ };
439
+ if ((archiveOptions === null || archiveOptions === void 0 ? void 0 : archiveOptions.password) && passwordAttempt === 1 && !previousError) {
440
+ return archiveOptions.password;
441
+ }
442
+ if (archiveOptions === null || archiveOptions === void 0 ? void 0 : archiveOptions.requestPassword) {
443
+ const customPassword = await archiveOptions.requestPassword(contextPayload);
444
+ return customPassword !== null && customPassword !== void 0 ? customPassword : null;
445
+ }
446
+ return requestPasswordWithDialog(reason, previousError);
447
+ };
448
+ const applyArchivePassword = async (password) => {
449
+ var _a;
450
+ await ((_a = archiveReader === null || archiveReader === void 0 ? void 0 : archiveReader.usePassword) === null || _a === void 0 ? void 0 : _a.call(archiveReader, password));
451
+ };
452
+ const requestAndApplyPassword = async (reason, entry, previousError) => {
453
+ const password = await requestArchivePassword(reason, entry, previousError);
454
+ if (password === null) {
455
+ throw new ArchivePasswordCancelledError(t('archive.error.passwordRequired'));
456
+ }
457
+ await applyArchivePassword(password);
458
+ };
459
+ listen(passwordCard, 'submit', (event) => {
460
+ event.preventDefault();
461
+ const password = passwordInput.value;
462
+ if (!password) {
463
+ passwordError.textContent = t('archive.password.required');
464
+ passwordInput.focus();
465
+ return;
466
+ }
467
+ closePasswordDialog(password);
468
+ });
469
+ listen(passwordCancelButton, 'click', () => {
470
+ closePasswordDialog(null);
471
+ });
472
+ listen(passwordDialog, 'keydown', (event) => {
473
+ if (event.key === 'Escape') {
474
+ event.preventDefault();
475
+ closePasswordDialog(null);
476
+ }
477
+ });
478
+ listen(sidebarHideButton, 'click', () => {
479
+ sidebarCollapsed = true;
480
+ syncState();
481
+ });
482
+ listen(sidebarShowButton, 'click', () => {
483
+ sidebarCollapsed = !sidebarCollapsed;
484
+ syncState();
485
+ });
276
486
  const getArchiveStats = () => {
277
487
  const totalSize = entries.reduce((sum, entry) => sum + entry.size, 0);
278
488
  const previewableCount = entries.filter(entry => entry.previewable).length;
@@ -299,6 +509,20 @@ export default async function renderArchive(buffer, target, _type, context) {
299
509
  await ((_a = archiveReader === null || archiveReader === void 0 ? void 0 : archiveReader.close) === null || _a === void 0 ? void 0 : _a.call(archiveReader));
300
510
  archiveReader = null;
301
511
  };
512
+ const syncSidebarToggleState = () => {
513
+ const showLabel = t('archive.sidebar.show');
514
+ const hideLabel = t('archive.sidebar.hide');
515
+ const activeLabel = sidebarCollapsed ? showLabel : hideLabel;
516
+ root.classList.toggle('archive-sidebar-collapsed', sidebarCollapsed);
517
+ sidebarHideButton.textContent = '‹';
518
+ sidebarShowButton.textContent = sidebarCollapsed ? '☰' : '‹';
519
+ sidebarHideButton.title = hideLabel;
520
+ sidebarShowButton.title = activeLabel;
521
+ sidebarHideButton.setAttribute('aria-label', hideLabel);
522
+ sidebarShowButton.setAttribute('aria-label', activeLabel);
523
+ sidebarHideButton.setAttribute('aria-expanded', String(!sidebarCollapsed));
524
+ sidebarShowButton.setAttribute('aria-expanded', String(!sidebarCollapsed));
525
+ };
302
526
  const syncState = () => {
303
527
  const archiveStats = getArchiveStats();
304
528
  stats.textContent = t('archive.stats.summary', {
@@ -319,7 +543,9 @@ export default async function renderArchive(buffer, target, _type, context) {
319
543
  const activeTitle = toolbarTitle.querySelector('strong');
320
544
  if (activeTitle) {
321
545
  activeTitle.textContent = (selectedEntry === null || selectedEntry === void 0 ? void 0 : selectedEntry.name) || t('archive.preview.chooseFile');
546
+ activeTitle.setAttribute('title', (selectedEntry === null || selectedEntry === void 0 ? void 0 : selectedEntry.path) || (selectedEntry === null || selectedEntry === void 0 ? void 0 : selectedEntry.name) || '');
322
547
  }
548
+ syncSidebarToggleState();
323
549
  };
324
550
  const renderEmptyState = () => {
325
551
  if (selectedEntry || loading || nestedTarget.childElementCount) {
@@ -338,8 +564,16 @@ export default async function renderArchive(buffer, target, _type, context) {
338
564
  button.classList.toggle('active', (selectedEntry === null || selectedEntry === void 0 ? void 0 : selectedEntry.id) === entry.id);
339
565
  const icon = createElement(documentRef, 'span', 'entry-ext', entry.extension || 'file');
340
566
  const copy = createElement(documentRef, 'span', 'entry-copy');
341
- copy.append(createElement(documentRef, 'strong', undefined, entry.name), createElement(documentRef, 'em', undefined, entry.path));
342
- button.append(icon, copy, createElement(documentRef, 'small', undefined, formatArchiveBytes(entry.size)));
567
+ const nameNode = createElement(documentRef, 'strong', undefined, entry.name);
568
+ const pathNode = createElement(documentRef, 'em', undefined, entry.path);
569
+ const sizeText = formatArchiveBytes(entry.size);
570
+ const sizeNode = createElement(documentRef, 'small', undefined, sizeText);
571
+ nameNode.title = entry.name;
572
+ pathNode.title = entry.path;
573
+ sizeNode.title = sizeText;
574
+ button.title = entry.path;
575
+ copy.append(nameNode, pathNode);
576
+ button.append(icon, copy, sizeNode);
343
577
  button.addEventListener('click', () => {
344
578
  void previewEntry(entry);
345
579
  });
@@ -365,6 +599,19 @@ export default async function renderArchive(buffer, target, _type, context) {
365
599
  workers.forEach(worker => worker.terminate());
366
600
  workers.length = 0;
367
601
  };
602
+ const readArchiveDirectoryWithPassword = async (archive, candidate) => {
603
+ for (;;) {
604
+ try {
605
+ return await withTimeout(archive.getFilesObject(), workerTimeoutMs, t('archive.error.candidateReadTimeout', { label: candidate.label }), targetWindow);
606
+ }
607
+ catch (reason) {
608
+ if (!encrypted && !isArchivePasswordError(reason)) {
609
+ throw reason;
610
+ }
611
+ await requestAndApplyPassword(encrypted ? 'invalid-password' : 'read-failed', undefined, reason);
612
+ }
613
+ }
614
+ };
368
615
  const tryOpenArchiveWithWorker = async (Archive, candidate) => {
369
616
  const createdWorkers = [];
370
617
  const workerUrl = await prepareWorkerUrl(candidate, objectUrls);
@@ -382,8 +629,11 @@ export default async function renderArchive(buffer, target, _type, context) {
382
629
  const archive = await withTimeout(Archive.open(archiveFile), workerTimeoutMs, t('archive.error.candidateInitTimeout', { label: candidate.label }), targetWindow);
383
630
  archiveReader = archive;
384
631
  encrypted = await withTimeout(archive.hasEncryptedData(), workerTimeoutMs, t('archive.error.encryptedCheckTimeout', { label: candidate.label }), targetWindow).catch(() => null);
632
+ if (encrypted) {
633
+ await requestAndApplyPassword('encrypted');
634
+ }
385
635
  setLoading(true, t('archive.loading.readingDirectory'), t('archive.loading.directoryReadyHint'));
386
- const fileTree = await withTimeout(archive.getFilesObject(), workerTimeoutMs, t('archive.error.candidateReadTimeout', { label: candidate.label }), targetWindow);
636
+ const fileTree = await readArchiveDirectoryWithPassword(archive, candidate);
387
637
  entries = flattenArchiveObject(fileTree)
388
638
  .sort((left, right) => left.path.localeCompare(right.path));
389
639
  syncState();
@@ -436,10 +686,18 @@ export default async function renderArchive(buffer, target, _type, context) {
436
686
  return;
437
687
  }
438
688
  catch (reason) {
689
+ if (reason instanceof ArchivePasswordCancelledError) {
690
+ throw reason;
691
+ }
439
692
  errors.push(`${candidate.label}: ${normalizeWorkerError(reason)}`);
440
693
  }
441
694
  }
442
695
  await closeArchive();
696
+ if (isLikelyEncryptedArchive(buffer, filename)) {
697
+ encrypted = true;
698
+ syncState();
699
+ throw new Error(t('archive.error.encryptedRequiresWorker'));
700
+ }
443
701
  if (await tryOpenArchiveWithFallback()) {
444
702
  return;
445
703
  }
@@ -458,9 +716,7 @@ export default async function renderArchive(buffer, target, _type, context) {
458
716
  const child = createElement(documentRef, 'div', 'archive-nested-content');
459
717
  nestedTarget.append(child);
460
718
  const nestedContext = buildArchiveNestedRenderContext(context, entry, archiveOptions);
461
- nestedRendered = (context === null || context === void 0 ? void 0 : context.renderNestedBuffer)
462
- ? await context.renderNestedBuffer(entryBuffer, entry.extension, child, nestedContext)
463
- : await renderNestedWithCoreFallback(entryBuffer, entry.extension, child, nestedContext);
719
+ nestedRendered = await renderNestedEntry(entryBuffer, entry.extension, child, nestedContext);
464
720
  };
465
721
  const extractEntryBuffer = async (entry) => {
466
722
  const cacheKey = createArchiveCacheKey(filename, buffer.byteLength, entry);
@@ -470,7 +726,21 @@ export default async function renderArchive(buffer, target, _type, context) {
470
726
  return cached.buffer;
471
727
  }
472
728
  }
473
- const file = await entry.compressedFile.extract();
729
+ let file;
730
+ for (;;) {
731
+ try {
732
+ file = await entry.compressedFile.extract();
733
+ break;
734
+ }
735
+ catch (reason) {
736
+ if (!archiveReader || !isArchivePasswordError(reason)) {
737
+ throw reason;
738
+ }
739
+ encrypted = true;
740
+ syncState();
741
+ await requestAndApplyPassword('extract-failed', entry, reason);
742
+ }
743
+ }
474
744
  const entryBuffer = await file.arrayBuffer();
475
745
  if (cacheEnabled) {
476
746
  await writeArchiveCache({
@@ -542,6 +812,7 @@ export default async function renderArchive(buffer, target, _type, context) {
542
812
  return {
543
813
  $el: root,
544
814
  async unmount() {
815
+ closePasswordDialog(null);
545
816
  cleanups.splice(0).forEach(cleanup => cleanup());
546
817
  await clearNestedPreview();
547
818
  await closeArchive();
@@ -1,4 +1,5 @@
1
1
  import { type ArchiveEntryView } from './archiveShared.js';
2
+ export declare const isLikelyEncryptedArchive: (data: ArrayBuffer, filename: string) => boolean;
2
3
  /**
3
4
  * Worker fallback for constrained browsers, temporary local servers, and
4
5
  * mobile WebViews. The main libarchive path still covers broader formats;
@@ -2,6 +2,9 @@ import { getArchiveEntryExtension, isArchiveSystemMetadataPath, isPreviewableArc
2
2
  const ZIP_LIKE_EXTENSIONS = new Set(['zip', 'zipx', 'jar', 'war', 'ear', 'apk', 'cbz']);
3
3
  const TAR_LIKE_EXTENSIONS = new Set(['tar', 'tgz', 'gz', 'gzip']);
4
4
  const TAR_BLOCK_SIZE = 512;
5
+ const ZIP_CENTRAL_FILE_HEADER = 0x02014b50;
6
+ const ZIP_LOCAL_FILE_HEADER = 0x04034b50;
7
+ const ZIP_GENERAL_PURPOSE_ENCRYPTED_FLAG = 0x0001;
5
8
  const toArrayBuffer = (bytes) => {
6
9
  const output = new Uint8Array(bytes.byteLength);
7
10
  output.set(bytes);
@@ -97,6 +100,35 @@ const getArchiveExtension = (filename) => {
97
100
  }
98
101
  return getArchiveEntryExtension(filename);
99
102
  };
103
+ const readUint16 = (view, offset) => {
104
+ return offset + 2 <= view.byteLength ? view.getUint16(offset, true) : 0;
105
+ };
106
+ const readUint32 = (view, offset) => {
107
+ return offset + 4 <= view.byteLength ? view.getUint32(offset, true) : 0;
108
+ };
109
+ const hasEncryptedZipEntries = (data) => {
110
+ const view = new DataView(data);
111
+ for (let offset = 0; offset + 12 <= view.byteLength; offset += 1) {
112
+ const signature = readUint32(view, offset);
113
+ if (signature !== ZIP_CENTRAL_FILE_HEADER && signature !== ZIP_LOCAL_FILE_HEADER) {
114
+ continue;
115
+ }
116
+ const flagOffset = signature === ZIP_CENTRAL_FILE_HEADER
117
+ ? offset + 8
118
+ : offset + 6;
119
+ if ((readUint16(view, flagOffset) & ZIP_GENERAL_PURPOSE_ENCRYPTED_FLAG) !== 0) {
120
+ return true;
121
+ }
122
+ }
123
+ return false;
124
+ };
125
+ export const isLikelyEncryptedArchive = (data, filename) => {
126
+ const extension = getArchiveExtension(filename);
127
+ if (ZIP_LIKE_EXTENSIONS.has(extension)) {
128
+ return hasEncryptedZipEntries(data);
129
+ }
130
+ return false;
131
+ };
100
132
  const getGzipEntryName = (filename) => {
101
133
  const lower = filename.toLowerCase();
102
134
  if (lower.endsWith('.gzip')) {
@@ -160,6 +192,9 @@ const loadTarEntries = async (data, filename, extension) => {
160
192
  export const loadArchiveEntriesWithoutWorker = async (data, filename) => {
161
193
  const extension = getArchiveExtension(filename);
162
194
  if (ZIP_LIKE_EXTENSIONS.has(extension)) {
195
+ if (isLikelyEncryptedArchive(data, filename)) {
196
+ return null;
197
+ }
163
198
  return loadZipEntries(data);
164
199
  }
165
200
  if (TAR_LIKE_EXTENSIONS.has(extension)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@file-viewer/renderer-archive",
3
- "version": "2.1.7",
3
+ "version": "2.1.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone archive renderer plugin for Flyfish File Viewer with libarchive worker, ZIP/TAR fallback, IndexedDB cache, and nested previews.",
@@ -56,7 +56,7 @@
56
56
  "LICENSE"
57
57
  ],
58
58
  "dependencies": {
59
- "@file-viewer/core": "^2.1.7",
59
+ "@file-viewer/core": "^2.1.8",
60
60
  "jszip": "^3.10.1",
61
61
  "libarchive.js": "^2.0.2"
62
62
  },