diff --git a/package.json b/package.json index 2c09dc018..61e76bfd3 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "acorn": "^8.15.0", "chardet": "^2.1.1", "cron": "^4.4.0", "crypto-js": "^4.2.0", @@ -38,6 +39,7 @@ "eslint-linter-browserify": "9.26.0", "eventemitter3": "^5.0.1", "i18next": "^23.16.4", + "magic-string": "^0.30.21", "monaco-editor": "^0.52.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff52d0907..afa1152dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@18.3.1) + acorn: + specifier: ^8.15.0 + version: 8.15.0 chardet: specifier: ^2.1.1 version: 2.1.1 @@ -47,6 +50,9 @@ importers: i18next: specifier: ^23.16.4 version: 23.16.4 + magic-string: + specifier: ^0.30.21 + version: 0.30.21 monaco-editor: specifier: ^0.52.2 version: 0.52.2 @@ -782,12 +788,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==} @@ -1449,16 +1449,6 @@ 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==} engines: {node: '>=0.4.0'} @@ -2907,9 +2897,6 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true - magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4791,7 +4778,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': @@ -4807,21 +4794,17 @@ snapshots: '@jridgewell/trace-mapping': 0.3.29 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.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: @@ -5509,7 +5492,7 @@ snapshots: dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 - magic-string: 0.30.17 + magic-string: 0.30.21 optionalDependencies: 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) @@ -5526,7 +5509,7 @@ snapshots: '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 - magic-string: 0.30.17 + magic-string: 0.30.21 pathe: 2.0.3 '@vitest/spy@3.2.4': @@ -5647,11 +5630,7 @@ snapshots: acorn-walk@8.3.4: dependencies: - acorn: 8.14.1 - - acorn@8.13.0: {} - - acorn@8.14.1: {} + acorn: 8.15.0 acorn@8.15.0: {} @@ -7292,10 +7271,6 @@ snapshots: lz-string@1.5.0: {} - magic-string@0.30.17: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.4 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8383,7 +8358,7 @@ snapshots: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 22.16.0 - acorn: 8.13.0 + acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 @@ -8608,7 +8583,7 @@ snapshots: chai: 5.2.0 debug: 4.4.1 expect-type: 1.2.2 - magic-string: 0.30.17 + magic-string: 0.30.21 pathe: 2.0.3 picomatch: 4.0.2 std-env: 3.9.0 diff --git a/rspack-plugins/ZipExecutionPlugin.ts b/rspack-plugins/ZipExecutionPlugin.ts new file mode 100644 index 000000000..cafd1bf63 --- /dev/null +++ b/rspack-plugins/ZipExecutionPlugin.ts @@ -0,0 +1,387 @@ +import type { Compiler, Compilation } from "@rspack/core"; +import zlib from "zlib"; + +import * as acorn from "acorn"; +import MagicString from "magic-string"; + +const trimCode = (code: string) => { + return code.replace(/[\r\n]\s+/g, "").trim(); +}; + +export function compileDecodeSource(templateCode: string, base64Data: string, pName: string) { + // ------------------------------------------ inflate-raw ------------------------------------------ + // lightweight implementation of the DEFLATE decompression algorithm (RFC 1951) + // * See https://github.com/js-vanilla/inflate-raw/ + const inflateRawCode = trimCode(` + (()=>{let _=Uint8Array,e=_.fromBase64?.bind(_)??(e=>{let l=atob(e),$=l.length,r=new _($);for(;$--;)r[$]=l.charCodeAt($);return r}), + l=l=>{let $=e(l),r=4*$.length;r<32768&&(r=32768);let t=new _(r),a=0,f=e=>{let l=t.length,$=a+e;if($>l){do l=3*l>>>1;while(l<$); + let r=new _(l);r.set(t),t=r}},s=new Uint16Array(66400),u=s.subarray(0,32768),b=s.subarray(32768,65536),n=s.subarray(65536,65856),i, + o,y=new Int32Array(48),h=0,w=0,g=0,d=()=>{for(;w<16&&g<$.length;)h|=$[g++]<{d();let e=h&(1<<_)-1;return h>>>=_,w-=_,e}, + F=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],k=[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], + m=[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],v=[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], + x=[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],A=y.subarray(0,16),B=y.subarray(16,32),C=(_,e)=>{let l=A.fill(0),$=0;for(let r=0;r<_.length;r++){ + let t=_[r];t>0&&(l[t]++,t>$&&($=t))}let a=1<<$,f=e.subarray(0,a),s=B,u=0;for(let b=1;b<=$;b++)s[b]=u,u+=l[b];let i=n;for(let o=0;o<_.length;o++)_[o]>0&&(i[s[_[o]]++]=o); + let y=0,h=0;for(let w=1;w<=$;w++){let g=1<>=1;y^=v}}return f}, + R=_=>{d();let e=_.length-1,l=_[h&e],$=l>>>9;return h>>>=$,w-=$,511&l},j=new _(320),p=j.subarray(0,19),q=!1,z=0;for(;!z;){let D=c(3);z=1&D;let E=D>>1;if(0===E){ + h=w=0;let G=$[g++]|$[g++]<<8;g+=2,f(G),t.set($.subarray(g,g+G),a),a+=G,g+=G}else{let H,I;if(1===E){if(!q){q=!0;let J=65856,K=j.subarray(0,288);K.fill(8,0,144),K.fill(9,144,256), + K.fill(7,256,280),K.fill(8,280,288),C(K,i=s.subarray(J,J+=512));let L=j.subarray(0,32).fill(5);C(L,o=s.subarray(J,J+=32))}H=i,I=o}else{let M=c(14),N=(31&M)+257,O=(M>>5&31)+1, + P=(M>>10&15)+4;p.fill(0);for(let Q=0;Q0;){ + let _t=a-_r;_e<_t&&(_t=_e),t.set(t.subarray(_r,_r+_t),a),a+=_t,_e-=_t}}}}}return new TextDecoder().decode(t.subarray(0,a))};return l})(); + `); + // ------------------------------------------------------------------------------------------------- + return ` + const $b64_ = "${base64Data}"; + const $inflateRaw_ = ${inflateRawCode}; + const $text_ = $inflateRaw_($b64_); + const ${pName} = JSON.parse($text_); + ${templateCode} +`; +} + +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"); +}; + +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); + + // 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 = zlib.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.includes("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 6409878d5..6894b57a6 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -1,6 +1,7 @@ import * as path from "path"; import { defineConfig } from "@rspack/cli"; import { rspack } from "@rspack/core"; +import { ZipExecutionPlugin } from "./rspack-plugins/ZipExecutionPlugin"; import { readFileSync } from "fs"; import { NormalModule } from "@rspack/core"; import { v4 as uuidv4 } from "uuid"; @@ -136,7 +137,6 @@ export default defineConfig({ const manifest = JSON.parse(content.toString()); if (isDev || isBeta) { manifest.name = "__MSG_scriptcat_beta__"; - // manifest.content_security_policy = "script-src 'self' https://cdn.crowdin.com; object-src 'self'"; } return JSON.stringify(manifest); }, @@ -220,6 +220,7 @@ export default defineConfig({ minify: true, chunks: ["sandbox"], }), + new ZipExecutionPlugin(), ].filter(Boolean), experiments: { css: true, @@ -243,7 +244,7 @@ export default defineConfig({ passes: 2, drop_console: false, drop_debugger: !isDev, - ecma: 2020, + ecma: 2022, arrows: true, dead_code: true, ie8: false, @@ -261,7 +262,7 @@ export default defineConfig({ format: { comments: false, beautify: false, - ecma: 2020, + ecma: 2022, }, }, }), diff --git a/scripts/pack.js b/scripts/pack.js index 26d0f79a2..58a5af4fb 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -74,13 +74,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: `{${ @@ -89,6 +89,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 + ], + }, }, }; @@ -126,10 +135,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/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 4fa3acbbc..8ea36659e 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -53,9 +53,20 @@ import { scriptToMenu, type TPopupPageLoadInfo } from "./popup_scriptmenu"; const ORIGINAL_URLMATCH_SUFFIX = "{ORIGINAL}"; // 用于标记原始URLPatterns的后缀 +const RuntimeRegisterCode = { + UNSET: 0, + REGISTER_DONE: 1, + UNREGISTER_DONE: 2, +} as const; + +type RuntimeRegisterCode = ValueOf; + const runtimeGlobal = { - registered: false, + registerState: RuntimeRegisterCode.UNSET, messageFlag: "PENDING", +} as { + registerState: RuntimeRegisterCode; + messageFlag: string; }; export type TTabInfo = { @@ -314,15 +325,15 @@ export class RuntimeService { await cacheInstance.set("runtimeStartFlag", true); } - let registered = false; + let count = 0; try { const res = await chrome.userScripts?.getScripts({ ids: ["scriptcat-inject"] }); - registered = res?.length === 1; + count = res?.length; } catch { // 该错误为预期内情况,无需记录 debug 日志 } finally { // 考虑 UserScripts API 不可使用等情况 - runtimeGlobal.registered = registered; + runtimeGlobal.registerState = count === 1 ? RuntimeRegisterCode.REGISTER_DONE : RuntimeRegisterCode.UNSET; } } @@ -669,8 +680,9 @@ export class RuntimeService { // 取消脚本注册 async unregisterUserscripts() { // 检查 registered 避免重复操作增加系统开支 - if (runtimeGlobal.registered) { - runtimeGlobal.registered = false; + // 已成功注册(true)或是未知有无注册(null)的情况下执行 + if (runtimeGlobal.registerState !== RuntimeRegisterCode.UNREGISTER_DONE) { + runtimeGlobal.registerState = RuntimeRegisterCode.UNREGISTER_DONE; // 重置 flag 避免取消注册失败 // 即使注册失败,通过重置 flag 可避免错误地呼叫已取消注册的Script await Promise.allSettled([chrome.userScripts?.unregister(), chrome.scripting.unregisterContentScripts()]); @@ -838,7 +850,7 @@ export class RuntimeService { // 注意:Chrome 不支持 file.js?query retContent = [ { - id: "scriptcat-content", + id: "scriptcat-scripting", js: ["/src/scripting.js"], matches: [""], allFrames: true, @@ -893,7 +905,7 @@ export class RuntimeService { if (!this.isUserScriptsAvailable || !this.isLoadScripts) return; // 判断是否已经注册过 - if (runtimeGlobal.registered) { + if (runtimeGlobal.registerState === RuntimeRegisterCode.REGISTER_DONE) { // 异常情况 // 检查scriptcat-content和scriptcat-inject是否存在 const res = await chrome.userScripts.getScripts({ ids: ["scriptcat-inject"] }); @@ -903,7 +915,7 @@ export class RuntimeService { // scriptcat-content/scriptcat-inject不存在的情况 // 走一次重新注册的流程 this.logger.warn("registered = true but scriptcat-content/scriptcat-inject not exists, re-register userscripts."); - runtimeGlobal.registered = false; // 异常时强制反注册 + runtimeGlobal.registerState = RuntimeRegisterCode.UNSET; // 异常时强制反注册 } // 删除旧注册 await this.unregisterUserscripts(); @@ -926,7 +938,7 @@ export class RuntimeService { const list: chrome.userScripts.RegisteredUserScript[] = [...particularScriptList, ...injectScriptList]; - runtimeGlobal.registered = true; + let failed = false; try { await chrome.userScripts.register(list); } catch (e: any) { @@ -941,6 +953,7 @@ export class RuntimeService { try { await chrome.userScripts.update([script]); } catch (e) { + failed = true; this.logger.error("update error", Logger.E(e)); } } else { @@ -953,9 +966,11 @@ export class RuntimeService { try { await chrome.scripting.registerContentScripts(contentScriptList); } catch (e: any) { + failed = true; this.logger.error("register content.js error", Logger.E(e)); } } + runtimeGlobal.registerState = failed ? RuntimeRegisterCode.UNSET : RuntimeRegisterCode.REGISTER_DONE; } // 给指定tab发送消息 diff --git a/src/pages/components/RuntimeSetting/index.tsx b/src/pages/components/RuntimeSetting/index.tsx index 60e7d2c7a..b96477f71 100644 --- a/src/pages/components/RuntimeSetting/index.tsx +++ b/src/pages/components/RuntimeSetting/index.tsx @@ -5,6 +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 { isFirefox } from "@App/pkg/utils/utils"; const CollapseItem = Collapse.Item; @@ -24,52 +25,62 @@ const RuntimeSetting: React.FC = () => { setFilesystemType(res.filesystem); setFilesystemParam(res.params[res.filesystem] || {}); }); - chrome.permissions.contains({ permissions: ["background"] }, (result) => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError); - return; - } - 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) => { + chrome.permissions.contains({ permissions: ["background"] }, (result) => { if (chrome.runtime.lastError) { console.error(chrome.runtime.lastError); - Message.error(t("enable_background.disable_failed")!); return; } - setEnableBackgroundState(!removed); + setEnableBackgroundState(result); }); } + }, []); + + const setEnableBackground = (enable: boolean) => { + if (isFirefox()) { + // no background permission + } else { + 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; + } + setEnableBackgroundState(!removed); + }); + } + } }; return (
-
- - { - setEnableBackground(!enableBackground); - }} - > - {t("enable_background.title")} - -
+ {!isFirefox() && ( +
+ + { + setEnableBackground(!enableBackground); + }} + > + {t("enable_background.title")} + +
+ )} {t("enable_background.description")}