diff --git a/package.json b/package.json index 331e8e29a..34d865520 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,8 @@ "@types/serviceworker": "^0.0.120", "@unocss/postcss": "66.5.4", "@vitest/coverage-v8": "^4.0.18", + "acorn": "^8.16.0", + "uglify-js":"^3.19.3", "autoprefixer": "^10.4.21", "cross-env": "^10.1.0", "crx": "^5.0.1", @@ -89,6 +91,7 @@ "iconv-lite": "^0.7.2", "jsdom": "^26.1.0", "jszip": "^3.10.1", + "magic-string": "^0.30.21", "mock-xmlhttprequest": "^8.4.1", "postcss": "^8.5.6", "postcss-loader": "^8.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dcb47c9a..ca4e846db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ importers: version: 1.58.2 '@rspack/cli': specifier: ^1.7.6 - version: 1.7.6(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1) + version: 1.7.6(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1(uglify-js@3.19.3)) '@rspack/core': specifier: ^1.6.8 version: 1.7.6(@swc/helpers@0.5.17) @@ -141,6 +141,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18(@types/node@22.16.0)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.1)) + acorn: + specifier: ^8.16.0 + version: 8.16.0 autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) @@ -183,6 +186,9 @@ importers: jszip: specifier: ^3.10.1 version: 3.10.1 + magic-string: + specifier: ^0.30.21 + version: 0.30.21 mock-xmlhttprequest: specifier: ^8.4.1 version: 8.4.1 @@ -191,7 +197,7 @@ importers: version: 8.5.6 postcss-loader: specifier: ^8.2.0 - version: 8.2.0(@rspack/core@1.7.6(@swc/helpers@0.5.17))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.96.1) + version: 8.2.0(@rspack/core@1.7.6(@swc/helpers@0.5.17))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.96.1(uglify-js@3.19.3)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -207,6 +213,9 @@ importers: typescript-eslint: specifier: ^8.46.2 version: 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + uglify-js: + specifier: ^3.19.3 + version: 3.19.3 unocss: specifier: 66.5.4 version: 66.5.4(postcss@8.5.6)(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.1)) @@ -776,12 +785,6 @@ packages: '@jridgewell/source-map@0.3.10': resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - - '@jridgewell/sourcemap-codec@1.5.4': - resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -1554,18 +1557,8 @@ packages: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} - acorn@8.13.0: - resolution: {integrity: sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==} - engines: {node: '>=0.4.0'} - hasBin: true - - acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} - engines: {node: '>=0.4.0'} - hasBin: true - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true @@ -3941,6 +3934,11 @@ packages: ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -4714,7 +4712,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.12': dependencies: - '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.29 '@jridgewell/remapping@2.3.5': @@ -4730,16 +4728,12 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 optional: true - '@jridgewell/sourcemap-codec@1.5.0': {} - - '@jridgewell/sourcemap-codec@1.5.4': {} - '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.29': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping@0.3.31': dependencies: @@ -4749,7 +4743,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': dependencies: @@ -5041,11 +5035,11 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 1.7.6 '@rspack/binding-win32-x64-msvc': 1.7.6 - '@rspack/cli@1.7.6(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1)': + '@rspack/cli@1.7.6(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1(uglify-js@3.19.3))': dependencies: '@discoveryjs/json-ext': 0.5.7 '@rspack/core': 1.7.6(@swc/helpers@0.5.17) - '@rspack/dev-server': 1.1.5(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1) + '@rspack/dev-server': 1.1.5(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1(uglify-js@3.19.3)) exit-hook: 4.0.0 webpack-bundle-analyzer: 4.10.2 transitivePeerDependencies: @@ -5066,13 +5060,13 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.17 - '@rspack/dev-server@1.1.5(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1)': + '@rspack/dev-server@1.1.5(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1(uglify-js@3.19.3))': dependencies: '@rspack/core': 1.7.6(@swc/helpers@0.5.17) chokidar: 3.6.0 http-proxy-middleware: 2.0.9(@types/express@4.17.25) p-retry: 6.2.1 - webpack-dev-server: 5.2.2(tslib@2.8.1)(webpack@5.96.1) + webpack-dev-server: 5.2.2(tslib@2.8.1)(webpack@5.96.1(uglify-js@3.19.3)) ws: 8.18.1 transitivePeerDependencies: - '@types/express' @@ -5676,19 +5670,15 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk@8.3.4: dependencies: - acorn: 8.14.1 + acorn: 8.16.0 - acorn@8.13.0: {} - - acorn@8.14.1: {} - - acorn@8.15.0: {} + acorn@8.16.0: {} agent-base@7.1.1: dependencies: @@ -6545,8 +6535,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 esquery@1.6.0: @@ -7361,7 +7351,7 @@ snapshots: mlly@1.7.4: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.5.4 @@ -7588,7 +7578,7 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-loader@8.2.0(@rspack/core@1.7.6(@swc/helpers@0.5.17))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.96.1): + postcss-loader@8.2.0(@rspack/core@1.7.6(@swc/helpers@0.5.17))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.96.1(uglify-js@3.19.3)): dependencies: cosmiconfig: 9.0.0(typescript@5.9.3) jiti: 2.6.1 @@ -7596,7 +7586,7 @@ snapshots: semver: 7.7.2 optionalDependencies: '@rspack/core': 1.7.6(@swc/helpers@0.5.17) - webpack: 5.96.1 + webpack: 5.96.1(uglify-js@3.19.3) transitivePeerDependencies: - typescript @@ -8207,20 +8197,22 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - terser-webpack-plugin@5.3.14(webpack@5.96.1): + terser-webpack-plugin@5.3.14(uglify-js@3.19.3)(webpack@5.96.1(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.2 serialize-javascript: 6.0.2 terser: 5.43.1 - webpack: 5.96.1 + webpack: 5.96.1(uglify-js@3.19.3) + optionalDependencies: + uglify-js: 3.19.3 optional: true terser@5.43.1: dependencies: '@jridgewell/source-map': 0.3.10 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true @@ -8292,7 +8284,7 @@ snapshots: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 22.16.0 - acorn: 8.13.0 + acorn: 8.16.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 @@ -8373,6 +8365,8 @@ snapshots: ufo@1.5.4: {} + uglify-js@3.19.3: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.3 @@ -8563,7 +8557,7 @@ snapshots: webpack-bundle-analyzer@4.10.2: dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk: 8.3.4 commander: 7.2.0 debounce: 1.2.1 @@ -8578,7 +8572,7 @@ snapshots: - bufferutil - utf-8-validate - webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.96.1): + webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.96.1(uglify-js@3.19.3)): dependencies: colorette: 2.0.20 memfs: 4.56.10(tslib@2.8.1) @@ -8587,11 +8581,11 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.2 optionalDependencies: - webpack: 5.96.1 + webpack: 5.96.1(uglify-js@3.19.3) transitivePeerDependencies: - tslib - webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.96.1): + webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.96.1(uglify-js@3.19.3)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -8619,10 +8613,10 @@ snapshots: serve-index: 1.9.2 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.96.1) + webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.96.1(uglify-js@3.19.3)) ws: 8.18.1 optionalDependencies: - webpack: 5.96.1 + webpack: 5.96.1(uglify-js@3.19.3) transitivePeerDependencies: - bufferutil - debug @@ -8633,14 +8627,14 @@ snapshots: webpack-sources@3.3.3: optional: true - webpack@5.96.1: + webpack@5.96.1(uglify-js@3.19.3): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 + acorn: 8.16.0 browserslist: 4.25.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.2 @@ -8655,7 +8649,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.2 - terser-webpack-plugin: 5.3.14(webpack@5.96.1) + terser-webpack-plugin: 5.3.14(uglify-js@3.19.3)(webpack@5.96.1(uglify-js@3.19.3)) watchpack: 2.4.4 webpack-sources: 3.3.3 transitivePeerDependencies: diff --git a/rspack-plugins/ZipExecutionPlugin.ts b/rspack-plugins/ZipExecutionPlugin.ts new file mode 100644 index 000000000..75d14659c --- /dev/null +++ b/rspack-plugins/ZipExecutionPlugin.ts @@ -0,0 +1,643 @@ +import type { Compiler, Compilation } from "@rspack/core"; +import { deflateRawSync } from "zlib"; + +import * as acorn from "acorn"; +import MagicString from "magic-string"; +import { minify } from "uglify-js"; + +// 先保留 trimCode +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const trimCode = (code: string) => { + return code.replace(/[\r\n]\s+/g, "").trim(); +}; + +/** + * ## inflate-raw + * * 轻量级的 DEFLATE 解压算法实现(遵循 RFC 1951),内联进插件以避免额外依赖 + * * 实现参考:https://github.com/js-vanilla/inflate-raw/ 仅做格式压缩与封装用于运行时解码 + * * lightweight implementation of the DEFLATE decompression algorithm (RFC 1951) + * * See https://github.com/js-vanilla/inflate-raw/ + */ +const inflateRawCode = minify( + `const $inflateRaw_ = (() => { + const Uint8Arr = Uint8Array; + + const $fromBase64 = Uint8Arr.fromBase64?.bind(Uint8Arr) ?? ((b64) => { + const binStr = atob(b64); + let n = binStr.length; + const input = new Uint8Arr(n); + while (n--) input[n] = binStr.charCodeAt(n); + return input; + }); + + const $inflate = (b64) => { + const input = $fromBase64(b64); + + // Output Buffer (Standard) + let outSize = input.length * 4; + if (outSize < 32768) outSize = 32768; + let out = new Uint8Arr(outSize); + let outIdx = 0; + const ensure = (need) => { + let n = out.length; + const required = outIdx + need; + if (required > n) { + do { n = (n * 3) >>> 1; } while (n < required); + const newOut = new Uint8Arr(n); + newOut.set(out); + out = newOut; + } + }; + + // --- MEMORY OPTIMIZATION --- + // We reuse these for every block to avoid Garbage Collection churn. + const tableMemory = new Uint16Array(65536 + 320 + 512 + 32); + const lTable = tableMemory.subarray(0, 32768); // Shared for Literals & CodeLengths + const dTable = tableMemory.subarray(32768, 65536); // Shared for Distances + const sortedSymsMem = tableMemory.subarray(65536, 65536 + 320); // size = 320 + let lxTree; // size = 512 + let dxTree; // size = 32 + const treeMemory = new Int32Array(48); // Small temp buffer + + // Bit Reader + let bitBuf = 0, bitLen = 0, inpIdx = 0; + const refill = () => { + while (bitLen < 16 && inpIdx < input.length) { + bitBuf |= input[inpIdx++] << bitLen; + bitLen += 8; + } + }; + const readBits = (n) => { + refill(); + const res = bitBuf & ((1 << n) - 1); + bitBuf >>>= n; + bitLen -= n; + return res; + }; + + // Constants + const ord = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]; + const lensOf0 = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]; + const ex0 = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]; + const distsOf1 = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]; + const ex1 = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]; + + const countsMem = treeMemory.subarray(0, 16); + const offsetsMem = treeMemory.subarray(16, 32); + // Modified buildTree: Accepts a target buffer (lut) to fill + const buildTree = (lens, lut) => { + const counts = countsMem.fill(0); + let maxBits = 0; + for (let i = 0; i < lens.length; i++) { + const l = lens[i]; + if (l > 0) { + counts[l]++; + if (l > maxBits) maxBits = l; + } + } + + const limit = 1 << maxBits; + const resLut = lut.subarray(0, limit); + const offsets = offsetsMem; + let off = 0; + for (let i = 1; i <= maxBits; i++) { + offsets[i] = off; + off += counts[i]; + } + + const sorted = sortedSymsMem; + for (let i = 0; i < lens.length; i++) { + if (lens[i] > 0) sorted[offsets[lens[i]]++] = i; + } + + let rev = 0; + let sortedIdx = 0; + for (let len = 1; len <= maxBits; len++) { + const step = 1 << len; + const count = counts[len]; + for (let i = 0; i < count; i++) { + const sym = sorted[sortedIdx++]; + const entry = (len << 9) | sym; + + // Fill all indices in the LUT that share this bit-reversed prefix + for (let j = rev; j < limit; j += step) resLut[j] = entry; + + // Increment 'rev' in bit-reversed order: + // Propagate carry from the MSB (at position len-1) down to the LSB + let bit = 1 << (len - 1); + while (rev & bit) { + rev ^= bit; + bit >>= 1; + } + rev ^= bit; + } + } + return resLut; + }; + + const decodeSymbol = (lut) => { + refill(); + // Mask allows us to use smaller tables for smaller trees + const bitMask = lut.length - 1; + const entry = lut[bitBuf & bitMask]; + const len = entry >>> 9; + bitBuf >>>= len; + bitLen -= len; + return entry & 0x1FF; + }; + + // Temp buffers + const ms = new Uint8Arr(320); + const clens = ms.subarray(0, 19); + + let fixedTreeOk = false; + + // Main Loop + let isFinal = 0; + while (!isFinal) { + const bits = readBits(3); + isFinal = bits & 1; + const type = bits >> 1; + + if (type === 0) { // Uncompressed + bitBuf = bitLen = 0; + const len = input[inpIdx++] | (input[inpIdx++] << 8); + inpIdx += 2; // Skip nlen + ensure(len); + out.set(input.subarray(inpIdx, inpIdx + len), outIdx); + outIdx += len; + inpIdx += len; + + } else { // Compressed + let lTree, dTree; + + if (type === 1) { // Fixed + if (!fixedTreeOk) { + fixedTreeOk = true; + let offset = 65536 + 320; + + const ls = ms.subarray(0, 288); + ls.fill(8, 0, 144); ls.fill(9, 144, 256); ls.fill(7, 256, 280); ls.fill(8, 280, 288); + lxTree = tableMemory.subarray(offset, (offset += 512)); + buildTree(ls, lxTree); + + const ds = ms.subarray(0, 32).fill(5); + dxTree = tableMemory.subarray(offset, (offset += 32)); + buildTree(ds, dxTree); + } + lTree = lxTree; + dTree = dxTree; + + } else { // Dynamic + const bits = readBits(14); + const hlit = (bits & 0b11111) + 257; + const hdist = ((bits >> 5) & 0b11111) + 1; + const hclen = ((bits >> 10) & 0b1111) + 4; + + clens.fill(0); + for (let i = 0; i < hclen; i++) clens[ord[i]] = readBits(3); + + // Use lTable temporarily for Code Length tree + const clTree = buildTree(clens, lTable); + + const hLen = hlit + hdist; + const allLens = ms.subarray(0, hLen).fill(0); + + let i = 0; + while (i < hLen) { + const s = decodeSymbol(clTree); + if (s < 16) allLens[i++] = s; + else { + let r = 0, val = 0; + if (s === 16) { r = 3 + readBits(2); val = allLens[i - 1]; } + else if (s === 17) { r = 3 + readBits(3); } + else { r = 11 + readBits(7); } + while (r--) allLens[i++] = val; + } + } + // Now build actual trees into their respective buffers + lTree = buildTree(allLens.subarray(0, hlit), lTable); + dTree = buildTree(allLens.subarray(hlit), dTable); + } + + // Decode Huffman Block + while (true) { + const s = decodeSymbol(lTree); + if (s < 256) { + ensure(1); + out[outIdx++] = s; + } else if (s === 256) { + break; + } else { + const si = s - 257; + let len = lensOf0[si] + readBits(ex0[si]); + const di = decodeSymbol(dTree); + const dist = distsOf1[di] + readBits(ex1[di]); + + ensure(len); + // Match Copy + const pos = outIdx - dist; + // Efficiently handle RLE or very small distances + if (dist === 1) { + // Case 1: High-speed RLE (1-byte pattern) + out.fill(out[pos], outIdx, (outIdx += len)); + } else { + // Case 2: Exponential Growing Window + while (len > 0) { + let chunk = outIdx - pos; + if (len < chunk) chunk = len; // dist grows every iteration + out.set(out.subarray(pos, pos + chunk), outIdx); + outIdx += chunk; + len -= chunk; + } + } + } + } + } + } + return new TextDecoder().decode(out.subarray(0, outIdx)); + }; + + return $inflate; + + })();`, + { + parse: {}, + compress: false, + mangle: false, + output: { + beautify: true, + quote_style: 3, // original + wrap_iife: false, + indent_level: 2, + indent_start: 0, + comments: false, + braces: false, + ascii_only: false, + annotations: false, + preamble: [ + "// lightweight implementation of the DEFLATE decompression algorithm (RFC 1951)", + "// * See https://github.com/js-vanilla/inflate-raw/", + ].join("\n"), + }, + } +).code; + +export function compileDecodeSource(templateCode: string, base64Data: string, pName: string) { + return [ + `const $b64_ = "${base64Data}";`, + `${inflateRawCode}`, + `const $text_ = $inflateRaw_($b64_);`, + `const ${pName} = JSON.parse($text_);`, + `${templateCode}`, + ].join("\n"); +} + +interface Candidate { + id: number; + d: string; + type: "Template" | "Literal" | "Quasi"; + start: number; + end: number; + value: string; + zz: boolean; + prefix: string; // store " " or "" + suffix: string; // store " " or "" + freq?: number; +} + +const findAvailableVarName = (source: string) => { + // "zzstrs" + for (let e = 0xc0; e <= 0xff; e++) { + if (e === 0xd7 || e === 0xf7) continue; + const c = "$" + String.fromCharCode(e); + if (!source.includes(c)) return c; + } + throw new Error("Unable to compress"); +}; + +// 先保留 findShortName +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const findShortName = (source: string) => { + // $H + const filterFn = (w: string, i: number) => i === 0 || !/[\w$]/.test(w[0]); + const candidates = [..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"].map( + (c) => [c, source.split("$" + c).filter(filterFn).length] as const + ); + candidates.sort((a, b) => a[1] - b[1]); + const [candidateChar, _candidatesFreq] = candidates[0]; + const pName = "$" + candidateChar; + return pName; +}; + +export class ZipExecutionPlugin { + processFn(source: string, filename: string = "") { + // const vName = findAvailableVarName(source); + // const pName = findShortName(source); + // source = source.replaceAll(pName, vName); + const pName = findAvailableVarName(source); + + // 1. Parse + let ast: acorn.Node; + try { + ast = acorn.parse(source, { + ecmaVersion: "latest", + sourceType: "module", + ranges: true, + }); + } catch (err) { + console.warn(`[ZipExec] Parse failed ${filename}:`, (err as Error).message); + return false; + } + + // 2. Collect candidates (robust walker + context) + const candidates = this.collectCandidates(ast, source); + if (candidates.length === 0) return false; + + // Normalization & Deduplication + const extracted: string[] = []; + const operations: Candidate[] = []; + const candidatesFreq = new Map(); + + let mapped = candidates.map((c) => { + const d = this.normalizeValue(c.value); + if (c.zz) { + let q = candidatesFreq.get(d); + if (!q) candidatesFreq.set(d, (q = [0, 0, d.length])); + q[0] += 1; + return [c, d, q] as const; + } else { + return [c, d, [0, 0, 0]] as const; + } + }); + + mapped = mapped.filter(([c, d, q]) => { + if (q[0] === 1) { + // for freq === 1, if the size difference is small, replacement will make the compressed coding longer. + if (d.length < 14) { + q[0] = 0; + q[1] = 0; + q[2] = 0; + c.zz = false; + } + } + return true; + }); + + const sorted = [...candidatesFreq.entries()].sort((a, b) => b[1][0] - a[1][0]); + let i = 0; + for (const [d, q] of sorted) { + if (q[0] > 0) { + q[1] = i++; + extracted.push(d); + } + } + + for (const [c, d, q] of mapped) { + operations.push({ ...c, d: d, id: q[1], freq: q[0] }); + } + + // Replace bottom-up (safe offsets) + operations.sort((a, b) => b.start - a.start); + const ms = new MagicString(source); + const usedIds = new Set(); + + for (const op of operations) { + let doZZ = false; + const p = op.type === "Template" ? op.start - 1 : op.start; + const q = op.type === "Template" ? op.end + 1 : op.end; + if (op.zz) { + const freq = op.freq || 0; + if (freq === 0) throw new Error("invalid freq"); + const newValue = `${pName}[${op.id}]`; + + let oldSize; + + let r; + if (op.type === "Template") { + // Static template: removes backticks + // someFn(`1234567`) -> someFn($X[1234]) + r = `${op.prefix}${newValue}${op.suffix}`; + oldSize = op.end - op.start + 2; // opValue = targetString + } else if (op.type === "Quasi") { + // Quasi: stays inside backticks + // someFn(`...${123456789}...`) -> someFn(`...${$X[1234]}...`) + r = `\${${newValue}}`; + oldSize = op.end - op.start; // opValue = targetString + } else { + // Literal: removes quotes + // someFn("1234567") -> someFn($X[1234]) + // note: case"12345678" -> case $X[1234] + r = `${op.prefix}${newValue}${op.suffix}`; + oldSize = op.end - op.start; // opValue = "targetString" + } + + const newSize = r.length; + if (newSize > oldSize) { + //@ts-ignore : ignore empty value + extracted[op.id] = 0; // No replacement to $X. Just keep the id in $X + } else { + doZZ = true; + usedIds.add(op.id); + ms.overwrite(p, q, r); + } + } + if (!doZZ) { + // Handling non-compressed strings (like those with newlines) + const old = op.value; + if (/[\r\n]/.test(old)) { + if (op.type === "Template") { + ms.overwrite(op.start - 1, op.end + 1, JSON.stringify(op.d)); + } else if (op.type === "Quasi" && /^[\r\n\w$.=*,?:!(){}[\]@#%^&*/ '"+-]+$/.test(old)) { + ms.overwrite(op.start, op.end, op.d.replace(/\n/g, "\\n")); + } + } + } + } + + // Compress + const json = JSON.stringify(extracted); + // const deflated = pako.deflateRaw(Buffer.from(json, "utf8"), { level: 6 }); + const deflated = deflateRawSync(Buffer.from(json, "utf8"), { level: 6 }); + if (!deflated) throw new Error("Compression Failed"); + const base64 = Buffer.from(deflated).toString("base64"); + + // Wrap + const finalSource = compileDecodeSource(ms.toString(), base64, pName); + // testing: + // const finalSource = `var ${vName}=JSON.parse(new TextDecoder().decode(require('pako').inflateRaw(Buffer.from("${base64}","base64"))));\n${ms.toString()}`; + + return { finalSource, source, extracted, usedIds }; + } + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap("ZipExecutionPlugin", (compilation: Compilation) => { + compilation.hooks.processAssets.tapPromise( + { + name: "ZipExecutionPlugin", + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE, // after all compressions + }, + async (assets) => { + for (const [filename, asset] of Object.entries(assets)) { + if (!filename.endsWith("ts.worker.js")) continue; + + let source = asset.source().toString(); + + const ret = this.processFn(source, filename); + if (ret === false) continue; + source = ret.source; + const { finalSource, extracted, usedIds } = ret; + + compilation.updateAsset(filename, new compiler.webpack.sources.RawSource(finalSource)); + + console.debug(`[ZipExecutionPlugin] Processed ${filename}: ${extracted.length} unique strings extracted`); + console.debug(`[ZipExecutionPlugin] Replaced ${usedIds.size} extractions`); + } + } + ); + }); + } + + private collectCandidates(ast: acorn.Node, source: string): Omit[] { + const results: Omit[] = []; + + const getPadding = (start: number, end: number) => { + //xy"abcd"jk + //3 7 + //s[3-1] = s[2] = " + //s[7+1] = s[8] = j + const c1 = source[start - 1] || ""; + const c2 = source[end] || ""; + const isWord = /[\w$"'`]/; + return { + prefix: isWord.test(c1) ? " " : "", + suffix: isWord.test(c2) ? " " : "", + }; + }; + + const walk = (node: any, parent: any = null) => { + if (!node || typeof node !== "object") return; + + if (node.type === "Literal" && typeof node.value === "string") { + if (this.isExtractable(node, parent, "Literal")) { + const { prefix, suffix } = getPadding(node.start, node.end); + const oriLen = node.end - node.start; // "targetString" + // someFn("123456") -> someFn($X[1234]) + // note: case"1234567" -> case $X[1234] + const isZZ = oriLen >= 8 + prefix.length + suffix.length; + if (isZZ) { + results.push({ + type: "Literal", + start: node.start, + end: node.end, + value: node.value, + zz: true, + prefix, + suffix, + }); + } + } + } else if (node.type === "TemplateLiteral") { + if (node.expressions.length === 0) { + // Static Template: treat as one unit + const quasi = node.quasis[0]; + const val = quasi.value.cooked ?? quasi.value.raw; + if (this.isExtractable(quasi, parent, "Template")) { + // Templates overwrite backticks, so peek 1 char further out + // someFn(`123456`) -> someFn($X[1234]) + // node = `Template` + // quasi = Template + const { prefix, suffix } = getPadding(quasi.start - 1, quasi.end + 1); + const oriLen = quasi.end - quasi.start; // targetString + const isZZ = oriLen >= 6 + prefix.length + suffix.length; + const hasNewline = val.includes("\n") || val.includes("\r"); + if (isZZ || hasNewline) { + results.push({ + type: "Template", + start: quasi.start, + end: quasi.end, + value: val, + zz: isZZ, + prefix: isZZ ? prefix : "", + suffix: isZZ ? suffix : "", + }); + } + } + } else { + // Complex Template: extract individual quasis + for (const quasi of node.quasis) { + const val = quasi.value.cooked ?? quasi.value.raw; + if (val && this.isExtractable(quasi, parent, "Quasi", val)) { + // Quasis are inside `${}`, usually don't need padding relative to word boundaries + // `${...}123456789ab${...}` -> `${...}${$X[1234]}${...}` + const oriLen = quasi.end - quasi.start; // `${...}targetString${...}` + const isZZ = oriLen >= 11; + const hasNewline = val.includes("\n") || val.includes("\r"); + if (isZZ || hasNewline) { + results.push({ + type: "Quasi", + start: quasi.start, + end: quasi.end, + value: val, + zz: isZZ, + prefix: "", + suffix: "", + }); + } + } + } + } + } + + for (const key of Object.keys(node)) { + if (["parent", "loc", "range", "start", "end"].includes(key)) continue; + const child = node[key]; + if (Array.isArray(child)) for (const c of child) walk(c, node); + else if (child && typeof child === "object" && child.type) walk(child, node); + } + }; + + walk(ast); + return results; + } + + private isExtractable(node: any, parent: any, type: "Literal" | "Template" | "Quasi", overrideVal?: string): boolean { + const content = overrideVal ?? (node.type === "Literal" ? node.value : (node.value.cooked ?? "")); + + // Thresholds: Quasis need more length because they add `${}` (3 chars) + // if (type === "Quasi" && content.length < 12) return false; + // if (type === "Template" && content.length < 7) return false; + // if (type === "Literal" && content.length < 9) return false; + + // ---- Exclusions ---- + + // "use strict" + if (parent?.type === "ExpressionStatement" && content === "use strict") return false; + + // Tagged templates but type is not "Quasi" + if (parent?.type === "TaggedTemplateExpression" && type !== "Quasi") return false; + + // Object keys (non-computed) + if (parent?.type === "Property" && parent.key === node && !parent.computed) return false; + + // Import/export sources + const isModuleSource = + parent && + (parent.type === "ImportDeclaration" || + parent.type === "ExportNamedDeclaration" || + parent.type === "ExportAllDeclaration") && + parent.source === node; + if (isModuleSource) return false; + + // Dynamic import + if (parent?.type === "ImportExpression" && parent.source === node) return false; + + return true; + } + + private normalizeValue(value: string): string { + if (value.includes("\r")) { + value = value.replace(/\r\n|\r/g, "\n"); + } + return value; + } +} diff --git a/rspack.config.ts b/rspack.config.ts index 8c3abbeec..91969281c 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -1,5 +1,6 @@ import * as path from "path"; import { rspack, NormalModule, type Configuration } from "@rspack/core"; +import { ZipExecutionPlugin } from "./rspack-plugins/ZipExecutionPlugin"; import { readFileSync } from "fs"; import { v4 as uuidv4 } from "uuid"; import { toChromeVersion } from "./scripts/version.js"; @@ -232,6 +233,7 @@ export default { minify: true, chunks: ["sandbox"], }), + new ZipExecutionPlugin(), ].filter(Boolean), experiments: { css: true, @@ -254,7 +256,7 @@ export default { passes: 2, drop_console: false, drop_debugger: !isDev, - ecma: 2020, + ecma: 2022, arrows: true, dead_code: true, ie8: false, @@ -272,7 +274,7 @@ export default { format: { comments: false, beautify: false, - ecma: 2020, + ecma: 2022, }, }, }), diff --git a/scripts/pack.js b/scripts/pack.js index 23ecfb393..b83bc2f62 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -60,13 +60,13 @@ execSync("npm run build", { stdio: "inherit" }); const firefoxManifest = { ...manifest, background: { ...manifest.background } }; const chromeManifest = { ...manifest, background: { ...manifest.background } }; -delete chromeManifest.content_security_policy; chromeManifest.optional_permissions = chromeManifest.optional_permissions.filter((val) => val !== "userScripts"); delete chromeManifest.background.scripts; +// Firefox MV3 不支持 "background" permission +firefoxManifest.optional_permissions = firefoxManifest.optional_permissions.filter((val) => val !== "background"); delete firefoxManifest.background.service_worker; delete firefoxManifest.sandbox; -// firefoxManifest.content_security_policy = "script-src 'self' blob:; object-src 'self' blob:"; firefoxManifest.browser_specific_settings = { gecko: { id: `{${ @@ -75,6 +75,15 @@ firefoxManifest.browser_specific_settings = { // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/userScripts#browser_compatibility // Firefox 136 (Released 2025-03-04) strict_min_version: "136.0", + data_collection_permissions: { + required: [ + "none", // 没有必须传送至第三方的资料。安装转页没有记录用户何时何地安装了什么。 + ], + optional: [ + "authenticationInfo", // 使用 Cloud Backup / Import 时,有传送用户的资料至第三方作登入验证 + "personallyIdentifyingInfo", // 使用 电邮 或 帐密 让第三方识别个人身份进行 Cloud Backup / Import + ], + }, }, }; @@ -112,10 +121,8 @@ firefox.file("manifest.json", JSON.stringify(firefoxManifest)); await Promise.all([ addDir(chrome, "./dist/ext", "", ["manifest.json"]), - addDir(firefox, "./dist/ext", "", ["manifest.json", "ts.worker.js"]), + addDir(firefox, "./dist/ext", "", ["manifest.json"]), ]); -// 添加ts.worker.js名字为gz -firefox.file("src/ts.worker.js.gz", await fs.readFile("./dist/ext/src/ts.worker.js", { encoding: "utf8" })); // 导出zip包 chrome diff --git a/src/pages/components/RuntimeSetting/index.tsx b/src/pages/components/RuntimeSetting/index.tsx index 472d513c8..db5a72201 100644 --- a/src/pages/components/RuntimeSetting/index.tsx +++ b/src/pages/components/RuntimeSetting/index.tsx @@ -5,7 +5,7 @@ import FileSystemParams from "../FileSystemParams"; import { systemConfig } from "@App/pages/store/global"; import type { FileSystemType } from "@Packages/filesystem/factory"; import FileSystemFactory from "@Packages/filesystem/factory"; -import { isPermissionOk } from "@App/pkg/utils/utils"; +import { isPermissionOk, isFirefox } from "@App/pkg/utils/utils"; const CollapseItem = Collapse.Item; @@ -25,31 +25,48 @@ const RuntimeSetting: React.FC = () => { setFilesystemType(res.filesystem); setFilesystemParam(res.params[res.filesystem] || {}); }); - isPermissionOk("background").then((result) => { - if (result === null) return; // 无法要求 background permission - setEnableBackgroundState(result); - }); + if (isFirefox()) { + // no background permission + } else { + isPermissionOk("background").then((result) => { + if (result === null) return; // 无法要求 background permission + setEnableBackgroundState(result); + }); + } }, []); const setEnableBackground = (enable: boolean) => { - if (enable) { - chrome.permissions.request({ permissions: ["background"] }, (granted) => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError); - Message.error(t("enable_background.enable_failed")!); - return; - } - setEnableBackgroundState(granted); - }); + if (isFirefox()) { + // no background permission } else { - chrome.permissions.remove({ permissions: ["background"] }, (removed) => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError); - Message.error(t("enable_background.disable_failed")!); - return; - } - setEnableBackgroundState(!removed); - }); + if (enable) { + chrome.permissions.request({ permissions: ["background"] }, (granted) => { + if (chrome.runtime.lastError) { + console.error(chrome.runtime.lastError); + Message.error(t("enable_background.enable_failed")!); + return; + } + setEnableBackgroundState(granted); + }); + } else { + chrome.permissions.remove({ permissions: ["background"] }, (removed) => { + if (chrome.runtime.lastError) { + console.error(chrome.runtime.lastError); + Message.error(t("enable_background.disable_failed")!); + return; + } + if (removed) { + // 成功移除 background 权限,明确关闭开关 + setEnableBackgroundState(false); + } else { + // 未成功移除时再次校验权限状态,以真实权限为准更新 UI + isPermissionOk("background").then((result) => { + if (result === null) return; + setEnableBackgroundState(result); + }); + } + }); + } } }; @@ -57,18 +74,22 @@ const RuntimeSetting: React.FC = () => {
-
- - { - setEnableBackground(!enableBackground); - }} - > - {t("enable_background.title")} - -
- {t("enable_background.description")} + {!isFirefox() && ( +
+ + { + setEnableBackground(!enableBackground); + }} + > + {t("enable_background.title")} + +
+ )} + {!isFirefox() && ( + {t("enable_background.description")} + )}