diff --git a/package.json b/package.json index 51a767332..a3cc09a4e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "eslint-linter-browserify": "9.26.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^5.5.8", + "fflate": "^0.8.3", "i18next": "^23.16.4", "monaco-editor": "^0.52.2", "react": "^18.3.1", @@ -86,7 +87,6 @@ "husky": "^9.1.7", "iconv-lite": "^0.7.2", "jsdom": "^26.1.0", - "jszip": "^3.10.1", "mock-xmlhttprequest": "^8.4.1", "postcss": "^8.5.6", "postcss-loader": "^8.2.0", diff --git a/packages/filesystem/zip/rw.ts b/packages/filesystem/zip/rw.ts index 04bd30530..fdccd4e01 100644 --- a/packages/filesystem/zip/rw.ts +++ b/packages/filesystem/zip/rw.ts @@ -1,5 +1,4 @@ -import type { JSZipObject } from "jszip"; -import type { JSZipFileOptions, JSZipFile } from "@App/pkg/utils/jszip-x"; +import type { JSZipFileOptions, JSZipFile, JSZipObject } from "@App/pkg/utils/jszip-x"; import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; export class ZipFileReader implements FileReader { @@ -29,13 +28,13 @@ export class ZipFileWriter implements FileWriter { } } - async write(content: string): Promise { + async write(content: string | Blob): Promise { const opts = {} as JSZipFileOptions; if (this.modifiedDate) { - const date = new Date(this.modifiedDate); - const dateWithOffset = new Date(date.getTime() - date.getTimezoneOffset() * 60000); - opts.date = dateWithOffset; + opts.date = new Date(this.modifiedDate); + // fflate does not require timezone adjustment to UTC Date } - this.zip.file(this.path, content, opts); + const fileData = typeof content === "string" ? content : new Uint8Array(await content.arrayBuffer()); + this.zip.file(this.path, fileData, opts); } } diff --git a/packages/filesystem/zip/zip.ts b/packages/filesystem/zip/zip.ts index 508071d72..a5fa10d31 100644 --- a/packages/filesystem/zip/zip.ts +++ b/packages/filesystem/zip/zip.ts @@ -47,8 +47,7 @@ export default class ZipFileSystem implements FileSystem { const files: FileInfo[] = []; for (const [filename, jsZipObject] of Object.entries(this.zip.files)) { const date = jsZipObject.date; // the last modification date - const dateWithOffset = new Date(date.getTime() + date.getTimezoneOffset() * 60000); - const lastModificationDate = dateWithOffset.getTime(); + const lastModificationDate = date.getTime(); files.push({ name: filename, path: filename, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62c50b7df..9ec1e7de9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: fast-xml-parser: specifier: ^5.5.8 version: 5.5.8 + fflate: + specifier: ^0.8.3 + version: 0.8.3 i18next: specifier: ^23.16.4 version: 23.16.4 @@ -180,9 +183,6 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 - jszip: - specifier: ^3.10.1 - version: 3.10.1 mock-xmlhttprequest: specifier: ^8.4.1 version: 8.4.1 @@ -2167,6 +2167,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2434,9 +2437,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immediate@3.0.6: - resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -2680,9 +2680,6 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - jszip@3.10.1: - resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2703,9 +2700,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lie@3.3.0: - resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} - lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2993,9 +2987,6 @@ packages: package-manager-detector@1.3.0: resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==} - pako@1.0.11: - resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3410,9 +3401,6 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - setimmediate@1.0.5: - resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -6187,6 +6175,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6470,8 +6460,6 @@ snapshots: ignore@7.0.5: {} - immediate@3.0.6: {} - import-fresh@3.3.0: dependencies: parent-module: 1.0.1 @@ -6710,13 +6698,6 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - jszip@3.10.1: - dependencies: - lie: 3.3.0 - pako: 1.0.11 - readable-stream: 2.3.8 - setimmediate: 1.0.5 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6739,10 +6720,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lie@3.3.0: - dependencies: - immediate: 3.0.6 - lines-and-columns@1.2.4: {} local-pkg@1.1.1: @@ -7015,8 +6992,6 @@ snapshots: package-manager-detector@1.3.0: {} - pako@1.0.11: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7494,8 +7469,6 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.0.0 - setimmediate@1.0.5: {} - setprototypeof@1.2.0: {} shallowequal@1.1.0: {} diff --git a/scripts/pack.js b/scripts/pack.js index d293ed82f..188b6e5cf 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -1,7 +1,6 @@ /* global process */ import { promises as fs } from "fs"; -import { createWriteStream } from "fs"; -import JSZip from "jszip"; +import { strToU8, zipSync } from "fflate"; import ChromeExtension from "crx"; import { execSync } from "child_process"; import manifest from "../src/manifest.json" with { type: "json" }; @@ -16,12 +15,10 @@ const PACK_FIREFOX = false; // ============================================================================ -const createJSZip = () => { - const currDate = new Date(); - const dateWithOffset = new Date(currDate.getTime() - currDate.getTimezoneOffset() * 60000); - // replace the default date with dateWithOffset - JSZip.defaults.date = dateWithOffset; - return new JSZip(); +const zipMtime = new Date(); + +const addZipFile = (zip, path, content) => { + zip[path] = [typeof content === "string" ? strToU8(content) : content, { mtime: zipMtime }]; }; // 判断是否为beta版本 @@ -113,8 +110,8 @@ firefoxManifest.optional_permissions = firefoxManifest.optional_permissions?.fil (permission) => permission !== "background" ); -const chrome = createJSZip(); -const firefox = createJSZip(); +const chrome = {}; +const firefox = {}; async function addDir(zip, localDir, toDir, filters) { const sub = async (localDir, toDir) => { @@ -129,15 +126,15 @@ async function addDir(zip, localDir, toDir, filters) { if (stats.isDirectory()) { await sub(localPath, `${toPath}/`); } else { - zip.file(toPath, await fs.readFile(localPath)); + addZipFile(zip, toPath, await fs.readFile(localPath)); } } }; await sub(localDir, toDir); } -chrome.file("manifest.json", JSON.stringify(chromeManifest)); -firefox.file("manifest.json", JSON.stringify(firefoxManifest)); +addZipFile(chrome, "manifest.json", JSON.stringify(chromeManifest)); +addZipFile(firefox, "manifest.json", JSON.stringify(firefoxManifest)); await Promise.all([ addDir(chrome, "./dist/ext", "", ["manifest.json"]), @@ -145,22 +142,10 @@ await Promise.all([ ]); // 导出zip包 -chrome - .generateNodeStream({ - type: "nodebuffer", - streamFiles: true, - compression: "DEFLATE", - }) - .pipe(createWriteStream(`./dist/${packageInfo.name}-v${packageInfo.version}-chrome.zip`)); +await fs.writeFile(`./dist/${packageInfo.name}-v${packageInfo.version}-chrome.zip`, zipSync(chrome)); PACK_FIREFOX && - firefox - .generateNodeStream({ - type: "nodebuffer", - streamFiles: true, - compression: "DEFLATE", - }) - .pipe(createWriteStream(`./dist/${packageInfo.name}-v${packageInfo.version}-firefox.zip`)); + (await fs.writeFile(`./dist/${packageInfo.name}-v${packageInfo.version}-firefox.zip`, zipSync(firefox))); // 处理crx const crx = new ChromeExtension({ diff --git a/src/pkg/utils/jszip-x.ts b/src/pkg/utils/jszip-x.ts index bea81a717..53bca3688 100644 --- a/src/pkg/utils/jszip-x.ts +++ b/src/pkg/utils/jszip-x.ts @@ -1,77 +1,334 @@ -/** - * - * JSZIP 由于不再更新,问题只能手改。 - * - * UTC时间问题 - * https://github.com/Stuk/jszip/issues/369#issuecomment-546204220 - * https://blog.csdn.net/weixin_45410246/article/details/150015478 - * - * Typescript: Fix missing types for JSZip.defaults - * https://github.com/Stuk/jszip/pull/927 - * https://github.com/Stuk/jszip/issues/690 - * - * - * 日后应考虑 fork 一下加入以下PR - * - * 修正单一档案不能大于 2GB - * https://github.com/Stuk/jszip/pull/791 - * - * - * 其他参考: - * https://greasyfork.org/scripts/526002-gitzip-lite/code - * - */ -import JSZip from "jszip"; +import { strFromU8, strToU8, unzipSync, zipSync } from "fflate"; +import type { Unzipped, ZipOptions, Zippable, ZippableFile } from "fflate"; type Compression = "STORE" | "DEFLATE"; +const ZIP_MIME_TYPE = "application/zip"; + interface CompressionOptions { level: number; } interface InputByType { - base64: string; string: string; text: string; - binarystring: string; - array: number[]; uint8array: Uint8Array; arraybuffer: ArrayBuffer; blob: Blob; - stream: NodeJS.ReadableStream; } type InputFileFormat = InputByType[keyof InputByType] | Promise; -interface JSZipDefaults { - base64: boolean; // default false - binary: boolean; // default false - dir: boolean; // default false - createFolders: boolean; // default true - date: Date; // default null - compression: Compression | null; // default null - compressionOptions: CompressionOptions | null; // default null - comment: string | null; // default null - unixPermissions: number | string | null; // default null - dosPermissions: number | null; // default null +export interface JSZipFileOptions { + date?: Date; +} + +export interface JSZipGenerateOptions { + type: "blob" | "uint8array" | "arraybuffer"; + compression?: Compression | null; // default null + compressionOptions?: CompressionOptions | null; // default null + comment?: string | null; // default null; for entire zip file } -type JSZipWithDefaults = typeof JSZip & { defaults: JSZipDefaults }; +export class FflateZipObject { + name: string; -const JSZipX = JSZip as JSZipWithDefaults; + date: Date; + + private content: Uint8Array; + + constructor(name: string, content: Uint8Array, date?: Date) { + this.name = name; + this.content = content; + this.date = date || new Date(); + } + + async async(type: "string" | "blob" = "string"): Promise { + if (type === "blob") { + return new Blob([toArrayBuffer(this.content)]); + } + return strFromU8(this.content); + } + + getContent() { + return this.content; + } +} + +export class FflateZipFile { + files: Record = {}; + + file(path: string): FflateZipObject | null; + + file(path: string, content: string | Uint8Array | Blob, options?: JSZipFileOptions): this; + + file(path: string, content?: string | Uint8Array | Blob, options?: JSZipFileOptions): FflateZipObject | this | null { + if (content === undefined) { + return this.files[path] || null; + } + this.files[path] = new FflateZipObject(path, toUint8ArraySync(content), options?.date); + return this; + } + + remove(path: string) { + delete this.files[path]; + return this; + } + + async loadAsync(content: InputFileFormat): Promise { + const zipContent = await toUint8Array(await content); + const zipDates = getZipEntryDates(zipContent); + const files: Unzipped = unzipSync(zipContent); + this.files = {}; + for (const [path, fileContent] of Object.entries(files)) { + this.files[path] = new FflateZipObject(path, fileContent, zipDates.get(path)); + } + return this; + } + + generateAsync(options: JSZipGenerateOptions & { type: "blob" }): Promise; + + generateAsync(options: JSZipGenerateOptions & { type: "uint8array" }): Promise; + + generateAsync(options: JSZipGenerateOptions & { type: "arraybuffer" }): Promise; + + async generateAsync(options: JSZipGenerateOptions): Promise { + const comment = options?.comment || undefined; + const level = getLevel(options); + const data: Zippable = {}; + const entries = Object.entries(this.files); + if (entries.length > 65535) { + // see https://github.com/101arrowz/fflate/issues/229 + // see https://github.com/101arrowz/fflate/pull/230 + // see https://github.com/101arrowz/fflate/pull/270 + throw new Error("NOT IMPLEMENTED YET: creating ZIP archives that contain more than 65,535 entries"); + } + for (const [path, file] of entries) { + const zippableFile: ZippableFile = [ + file.getContent(), + { + level, + mtime: file.date, + }, + ]; + data[path] = zippableFile; + } + let output: Uint8Array = zipSync(data, { level }); + if (comment) { + try { + output = addZipArchiveComment(output, comment); + } catch (e) { + console.error("Unable to add zip comment", e); + } + } + switch (options.type) { + case "blob": + return new Blob([toArrayBuffer(output)], { type: ZIP_MIME_TYPE }); + case "arraybuffer": + return toArrayBuffer(output); + case "uint8array": + default: + return output; + } + } +} export const createJSZip = () => { - const currDate = new Date(); - const dateWithOffset = new Date(currDate.getTime() - currDate.getTimezoneOffset() * 60000); - // replace the default date with dateWithOffset - JSZipX.defaults.date = dateWithOffset; - return new JSZipX(); + return new FflateZipFile(); }; -export const loadAsyncJSZip = (content: InputFileFormat, options?: JSZip.JSZipLoadOptions): Promise => { - return createJSZip().loadAsync(content, options) as Promise; +export const loadAsyncJSZip = async (content: InputFileFormat): Promise => { + return createJSZip().loadAsync(content); }; -export type JSZipFile = typeof JSZipX; +export type JSZipFile = FflateZipFile; + +export type JSZipObject = FflateZipObject; + +/** + * Adds (or replaces) the archive-level comment of a ZIP file by patching its + * End of Central Directory (EOCD) record. + * + * fflate has no built-in API for the archive comment (its `comment` option is + * per-entry), so this rewrites the EOCD directly: it locates the EOCD, updates + * the 2-byte comment-length field, and appends the new comment bytes. Any + * existing archive comment is discarded. + * + * Also see {@link https://github.com/101arrowz/fflate/issues/269} + * + * The EOCD is located by scanning backward for its signature (`50 4b 05 06`) + * and validating each candidate against `offset + 22 + declaredCommentLength + * === zip.length`. This prevents matching signature bytes that happen to appear + * inside file data or an existing comment, so the function is safe to apply to + * its own output (idempotent re-stamping). + * + * The comment is stored as raw UTF-8 bytes with no language-encoding flag, + * which is the de-facto convention honored by common readers. + * + * @param zip - A standard (non-ZIP64) ZIP archive, e.g. the output of fflate's + * `zipSync` / `zip`. + * @param comment - The archive comment to set. Encoded to UTF-8; an empty + * string clears any existing comment. + * @returns A new `Uint8Array` containing the archive with the updated comment. + * The input is not mutated. + * + * @throws {Error} If the UTF-8-encoded comment exceeds 65,535 bytes (the + * maximum the 2-byte EOCD length field can represent). + * @throws {Error} If the input is too short to contain an EOCD record. + * @throws {Error} If no valid EOCD record can be found. + * + * @remarks + * Only standard ZIP archives are supported; ZIP64 (EOCD64) is not handled. + * This is not a concern for typical `zipSync` output, which does not emit + * ZIP64 under normal entry counts and sizes. + * + * @example + * ```ts + * const zip = zipSync({ "hello.txt": strToU8("hello") }); + * const withComment = addZipArchiveComment(zip, "built by my tool"); + * ``` + */ +export function addZipArchiveComment(zip: Uint8Array, comment: string): Uint8Array { + const commentBytes = strToU8(comment); + + if (commentBytes.length > 0xffff) { + throw new Error("ZIP archive comment must be <= 65,535 bytes"); + } + + // End of Central Directory: + // signature: 50 4b 05 06 + // fixed size before comment: 22 bytes + const EOCD_SIZE = 22; + const len = zip.length; + + if (len < EOCD_SIZE) { + throw new Error("Invalid ZIP: too short to contain EOCD"); + } + + // Scan backward for the EOCD signature. The real EOCD's comment field must + // extend exactly to EOF (offset + 22 + declaredCommentLength === len), which + // rejects stray signature bytes inside file data or an existing comment. + const searchStart = Math.max(0, len - EOCD_SIZE - 0xffff); + + let eocd = -1; + + for (let i = len - EOCD_SIZE; i >= searchStart; i--) { + if ( + zip[i] === 0x50 && + zip[i + 1] === 0x4b && + zip[i + 2] === 0x05 && + zip[i + 3] === 0x06 && + // Critical validation: this candidate EOCD must account for the + // entire remaining tail of the file. + i + EOCD_SIZE + (zip[i + 20] | (zip[i + 21] << 8)) === len + ) { + eocd = i; + break; + } + } + + if (eocd < 0) { + throw new Error("Could not find valid ZIP End of Central Directory record"); + } + + const tail = eocd + EOCD_SIZE; + const out = new Uint8Array(tail + commentBytes.length); + + // Copy ZIP through EOCD fixed fields, excluding old archive comment. + out.set(zip.subarray(0, tail)); + + // EOCD archive comment length at offset +20, little-endian. + out[eocd + 20] = commentBytes.length & 0xff; + out[eocd + 21] = commentBytes.length >>> 8; + + // Append new archive comment. + out.set(commentBytes, tail); -export type JSZipFileOptions = JSZip.JSZipFileOptions; + return out; +} + +function getLevel(options: { compression?: Compression | null; compressionOptions?: CompressionOptions | null }) { + if (options.compression === "STORE") { + return 0 satisfies ZipOptions["level"]; + } + const level = options.compressionOptions?.level; + if (level === undefined) { + return undefined; + } + return Math.max(0, Math.min(9, level)) as ZipOptions["level"]; +} + +function toUint8ArraySync(content: string | Uint8Array | Blob): Uint8Array { + if (typeof content === "string") { + return strToU8(content); + } + if (content instanceof Uint8Array) { + return content; + } + throw new Error("Blob content must be loaded asynchronously before creating a ZIP"); +} + +async function toUint8Array(content: InputByType[keyof InputByType]): Promise { + if (typeof content === "string") { + return strToU8(content); + } + if (content instanceof Uint8Array) { + return content; + } + if (content instanceof ArrayBuffer) { + return new Uint8Array(content); + } + if (content instanceof Blob) { + return new Uint8Array(await content.arrayBuffer()); + } + return strToU8(String(content)); +} + +function getZipEntryDates(data: Uint8Array): Map { + const dates = new Map(); + let endOffset = data.length - 22; + for (; endOffset >= 0 && readUint32(data, endOffset) !== 0x06054b50; endOffset -= 1) { + if (data.length - endOffset > 65558) { + return dates; + } + } + if (endOffset < 0) { + return dates; + } + const entryCount = readUint16(data, endOffset + 10); + let offset = readUint32(data, endOffset + 16); + for (let i = 0; i < entryCount && readUint32(data, offset) === 0x02014b50; i += 1) { + const flags = readUint16(data, offset + 8); + const modTime = readUint16(data, offset + 12); + const modDate = readUint16(data, offset + 14); + const filenameLength = readUint16(data, offset + 28); + const extraLength = readUint16(data, offset + 30); + const commentLength = readUint16(data, offset + 32); + const filename = strFromU8(data.subarray(offset + 46, offset + 46 + filenameLength), !(flags & 2048)); + dates.set(filename, dosDateTimeToDate(modDate, modTime)); + offset += 46 + filenameLength + extraLength + commentLength; + } + return dates; +} + +function dosDateTimeToDate(dosDate: number, dosTime: number): Date { + const year = ((dosDate >> 9) & 0x7f) + 1980; + const month = ((dosDate >> 5) & 0x0f) - 1; + const day = dosDate & 0x1f; + const hours = (dosTime >> 11) & 0x1f; + const minutes = (dosTime >> 5) & 0x3f; + const seconds = (dosTime & 0x1f) * 2; + return new Date(year, month, day, hours, minutes, seconds); +} + +function readUint16(data: Uint8Array, offset: number): number { + return data[offset] | (data[offset + 1] << 8); +} + +function readUint32(data: Uint8Array, offset: number): number { + return (data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24)) >>> 0; +} + +function toArrayBuffer(data: Uint8Array): ArrayBuffer { + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; +}