@turbowarp/sb3fix 0.4.0 → 0.5.0
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/package.json +2 -2
- package/src/sb3fix.js +136 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@turbowarp/sb3fix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Fix corrupted Scratch projects",
|
|
5
5
|
"main": "src/sb3fix.js",
|
|
6
6
|
"scripts": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@turbowarp/jszip": "^3.12.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"copy-webpack-plugin": "^
|
|
27
|
+
"copy-webpack-plugin": "^14.0.0",
|
|
28
28
|
"webpack": "^5.98.0",
|
|
29
29
|
"webpack-cli": "^6.0.1"
|
|
30
30
|
}
|
package/src/sb3fix.js
CHANGED
|
@@ -24,17 +24,31 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
24
24
|
*/
|
|
25
25
|
const isObject = (obj) => !!obj && typeof obj === 'object';
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Object.hasOwn() but it works in more browsers.
|
|
29
|
+
* @param {unknown} obj
|
|
30
|
+
* @param {string} property
|
|
31
|
+
* @returns {boolean} true if Object.hasOwn(obj, property)
|
|
32
|
+
*/
|
|
33
|
+
const hasOwn = (obj, property) => Object.prototype.hasOwnProperty.call(obj, property);
|
|
34
|
+
|
|
27
35
|
/**
|
|
28
36
|
* @typedef PlatformInfo
|
|
29
|
-
* @property {boolean} [allowsNonScalarVariables]
|
|
37
|
+
* @property {boolean} [allowsNonScalarVariables] If true, variables don't need to be a single string/number/boolean.
|
|
38
|
+
* @property {boolean} [nativelyUnderstandsSpork] If true, the platform's editor can natively understand spork-isms in project.json and thus compatibility fixes can be skipped.
|
|
30
39
|
*/
|
|
31
40
|
|
|
32
41
|
/**
|
|
33
42
|
* @type {Record<Platform, PlatformInfo>}
|
|
34
43
|
*/
|
|
35
44
|
const platforms = {
|
|
36
|
-
scratch: {
|
|
45
|
+
scratch: {
|
|
46
|
+
// Although the Scratch website uses spork, the desktop app still doesn't so for now, we'll keep applying the spork fixes there too.
|
|
47
|
+
nativelyUnderstandsSpork: false,
|
|
48
|
+
allowsNonScalarVariables: false
|
|
49
|
+
},
|
|
37
50
|
turbowarp: {
|
|
51
|
+
nativelyUnderstandsSpork: false,
|
|
38
52
|
allowsNonScalarVariables: true
|
|
39
53
|
}
|
|
40
54
|
};
|
|
@@ -44,8 +58,8 @@ const platforms = {
|
|
|
44
58
|
* @returns {PlatformInfo}
|
|
45
59
|
*/
|
|
46
60
|
const getPlatform = (options) => {
|
|
47
|
-
if (options &&
|
|
48
|
-
if (
|
|
61
|
+
if (options && hasOwn(options, 'platform')) {
|
|
62
|
+
if (hasOwn(platforms, options.platform)) {
|
|
49
63
|
return platforms[options.platform];
|
|
50
64
|
}
|
|
51
65
|
throw new Error(`Unknown platform: ${options.platform}`);
|
|
@@ -171,6 +185,7 @@ const fixJSON = (data, options = {}) => {
|
|
|
171
185
|
|
|
172
186
|
/**
|
|
173
187
|
* @param {unknown[]} native
|
|
188
|
+
* @returns {boolean} true to keep, false to delete
|
|
174
189
|
*/
|
|
175
190
|
const fixCompressedNativeInPlace = (native) => {
|
|
176
191
|
if (!Array.isArray(native)) {
|
|
@@ -201,7 +216,7 @@ const fixJSON = (data, options = {}) => {
|
|
|
201
216
|
log('number native had invalid value');
|
|
202
217
|
native[1] = String(value);
|
|
203
218
|
}
|
|
204
|
-
|
|
219
|
+
return true;
|
|
205
220
|
}
|
|
206
221
|
|
|
207
222
|
// Color: [9, hex color]
|
|
@@ -214,7 +229,7 @@ const fixJSON = (data, options = {}) => {
|
|
|
214
229
|
log('color native had invalid value');
|
|
215
230
|
native[1] = '#000000';
|
|
216
231
|
}
|
|
217
|
-
|
|
232
|
+
return true;
|
|
218
233
|
}
|
|
219
234
|
|
|
220
235
|
// Text: [10, string|number]
|
|
@@ -227,7 +242,25 @@ const fixJSON = (data, options = {}) => {
|
|
|
227
242
|
log('text native had invalid value');
|
|
228
243
|
native[1] = String(value);
|
|
229
244
|
}
|
|
230
|
-
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Broadcast: [11, name, id]
|
|
249
|
+
case 11: {
|
|
250
|
+
if (native.length !== 3) {
|
|
251
|
+
throw new Error(`Broadcast native is of unexpected length: ${native.length}`);
|
|
252
|
+
}
|
|
253
|
+
const name = native[1];
|
|
254
|
+
if (typeof name !== 'string') {
|
|
255
|
+
log(`broadcast native name was not a string`);
|
|
256
|
+
native[1] = String(native[1]);
|
|
257
|
+
}
|
|
258
|
+
const id = native[2];
|
|
259
|
+
if (typeof id !== 'string') {
|
|
260
|
+
log(`deleting compressed broadcast reference with non-string ID`);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
return true;
|
|
231
264
|
}
|
|
232
265
|
|
|
233
266
|
// Variable: [12, variable name, variable id, x?, y?]
|
|
@@ -243,18 +276,27 @@ const fixJSON = (data, options = {}) => {
|
|
|
243
276
|
log(`variable or list native name was not a string`);
|
|
244
277
|
native[1] = String(native[1]);
|
|
245
278
|
}
|
|
246
|
-
|
|
279
|
+
const id = native[2];
|
|
280
|
+
if (typeof id !== 'string') {
|
|
281
|
+
log(`deleting compressed variable or list reference with non-string ID`);
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
247
285
|
}
|
|
286
|
+
|
|
287
|
+
default:
|
|
288
|
+
throw new Error(`Unknown native type: ${type}`);
|
|
248
289
|
}
|
|
249
290
|
};
|
|
250
291
|
|
|
251
292
|
/**
|
|
252
293
|
* @param {string} id
|
|
253
294
|
* @param {unknown} block
|
|
295
|
+
* @returns {boolean} true to keep, false to delete
|
|
254
296
|
*/
|
|
255
297
|
const fixBlockInPlace = (id, block) => {
|
|
256
298
|
if (Array.isArray(block)) {
|
|
257
|
-
fixCompressedNativeInPlace(block);
|
|
299
|
+
return fixCompressedNativeInPlace(block);
|
|
258
300
|
} else if (isObject(block)) {
|
|
259
301
|
const inputs = block.inputs;
|
|
260
302
|
if (!isObject(inputs)) {
|
|
@@ -266,7 +308,10 @@ const fixJSON = (data, options = {}) => {
|
|
|
266
308
|
}
|
|
267
309
|
for (let i = 1; i < input.length; i++) {
|
|
268
310
|
if (Array.isArray(input[i])) {
|
|
269
|
-
fixCompressedNativeInPlace(input[i])
|
|
311
|
+
if (!fixCompressedNativeInPlace(input[i])) {
|
|
312
|
+
// Logging happens in fixCompressedNativeInPlace
|
|
313
|
+
input[i] = null;
|
|
314
|
+
}
|
|
270
315
|
}
|
|
271
316
|
}
|
|
272
317
|
}
|
|
@@ -280,11 +325,68 @@ const fixJSON = (data, options = {}) => {
|
|
|
280
325
|
throw new Error(`block ${id} field ${fieldName} is not an array`);
|
|
281
326
|
}
|
|
282
327
|
}
|
|
328
|
+
|
|
329
|
+
return true;
|
|
283
330
|
} else {
|
|
284
331
|
throw new Error(`block ${id} is not an object`);
|
|
285
332
|
}
|
|
286
333
|
};
|
|
287
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Fix backwards-incompatible changes that Scratch made in the spork migration.
|
|
337
|
+
* @param {object} blocks
|
|
338
|
+
*/
|
|
339
|
+
const fixSporkBackwardsIncompatibilityInPlace = (blocks) => {
|
|
340
|
+
for (const [blockId, block] of Object.entries(blocks)) {
|
|
341
|
+
if (!isObject(block)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
switch (block.opcode) {
|
|
346
|
+
// Custom block definition prototype blocks used to be marked as shadow: true, but spork marks as shadow: false.
|
|
347
|
+
// Our scratch-blocks relies on it being shadow: true to prevent moving, so we'll force it to be that way.
|
|
348
|
+
case 'procedures_prototype':
|
|
349
|
+
if (block.shadow !== true) {
|
|
350
|
+
log(`(spork) forcing shadow on procedures_prototype block ${blockId}`);
|
|
351
|
+
block.shadow = true;
|
|
352
|
+
}
|
|
353
|
+
break;
|
|
354
|
+
|
|
355
|
+
// For completeness with the above, set the argument reporter generators to be shadow: true as well.
|
|
356
|
+
case 'argument_reporter_string_number':
|
|
357
|
+
case 'argument_reporter_boolean': {
|
|
358
|
+
const parent = blocks[block.parent];
|
|
359
|
+
if (isObject(parent) && parent.opcode === 'procedures_prototype' && block.shadow !== true) {
|
|
360
|
+
log(`(spork) forcing shadow on ${block.opcode} block ${blockId}`);
|
|
361
|
+
block.shadow = true;
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// control_stop used to define a mutation for whether it has a connection below, which is what old
|
|
367
|
+
// scratch-blocks relies on to determine if there is another conneciton below or not. Spork does not define
|
|
368
|
+
// this mutation and relies only on the STOP_OPTION field. We will generate the mutation if it's missing so
|
|
369
|
+
// that a "stop other scripts in sprite" block doesn't cause the workspace to fail to load.
|
|
370
|
+
case 'control_stop': {
|
|
371
|
+
if (!block.mutation) {
|
|
372
|
+
const field = block.fields && block.fields.STOP_OPTION;
|
|
373
|
+
const stopOption = Array.isArray(field) ? field[0] : null;
|
|
374
|
+
const hasNext = stopOption === 'other scripts in sprite' || stopOption === 'other scripts in stage';
|
|
375
|
+
if (hasNext) {
|
|
376
|
+
log(`(spork) generating mutation on control_stop block ${blockId}`);
|
|
377
|
+
block.mutation = {
|
|
378
|
+
tagName: 'mutation',
|
|
379
|
+
hasnext: hasNext ? 'true' : 'false',
|
|
380
|
+
children: []
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
288
390
|
/**
|
|
289
391
|
* @param {string} id
|
|
290
392
|
* @param {unknown} comment
|
|
@@ -393,7 +495,14 @@ const fixJSON = (data, options = {}) => {
|
|
|
393
495
|
throw new Error('blocks is not an object');
|
|
394
496
|
}
|
|
395
497
|
for (const [blockId, block] of Object.entries(blocks)) {
|
|
396
|
-
fixBlockInPlace(blockId, block)
|
|
498
|
+
if (!fixBlockInPlace(blockId, block)) {
|
|
499
|
+
// Logging happens in fixBlockInPlace
|
|
500
|
+
delete blocks[blockId];
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!platform.nativelyUnderstandsSpork) {
|
|
505
|
+
fixSporkBackwardsIncompatibilityInPlace(blocks);
|
|
397
506
|
}
|
|
398
507
|
|
|
399
508
|
// Comments are not required
|
|
@@ -475,7 +584,7 @@ const fixJSON = (data, options = {}) => {
|
|
|
475
584
|
'off',
|
|
476
585
|
'on-flipped'
|
|
477
586
|
];
|
|
478
|
-
if (
|
|
587
|
+
if (hasOwn(stage, 'videoState') && !VIDEO_STATES.includes(stage.videoState)) {
|
|
479
588
|
log(`stage had invalid videoState: ${stage.videoState}`);
|
|
480
589
|
stage.videoState = 'off';
|
|
481
590
|
}
|
|
@@ -631,21 +740,25 @@ const fixZip = async (data, options = {}) => {
|
|
|
631
740
|
}
|
|
632
741
|
});
|
|
633
742
|
|
|
634
|
-
/** @type {Array<[string, import("@turbowarp/jszip").JSZipObject]>} */
|
|
635
|
-
const zipFiles = [];
|
|
636
|
-
zip.forEach((relativePath, file) => {
|
|
637
|
-
zipFiles.push([relativePath, file]);
|
|
638
|
-
});
|
|
639
|
-
|
|
640
743
|
// Remove any unreadable files from the zip. This can notably happen if the compressed data in the zip was
|
|
641
744
|
// corrupted, which would make the uncompressed data size field not match. Scratch/JSZip will refuse to
|
|
642
745
|
// keep loading the project if that happens. If we remove the asset, at least there's a chance it can now
|
|
643
746
|
// be downloaded from the asset server instead.
|
|
644
|
-
for (const [relativePath, file] of
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
648
|
-
|
|
747
|
+
for (const [relativePath, file] of Object.entries(zip.files)) {
|
|
748
|
+
if (file.dir) {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (/(?:project|sprite)\.json/.test(relativePath) || /[0-9a-f]{32}\./.test(relativePath)) {
|
|
753
|
+
try {
|
|
754
|
+
// If the file is corrupt, this will throw.
|
|
755
|
+
await file.async('uint8array');
|
|
756
|
+
} catch (error) {
|
|
757
|
+
log(`zip had unreadable file ${relativePath}: ${error}`);
|
|
758
|
+
zip.remove(relativePath);
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
log(`zip had extraneous file ${relativePath}`);
|
|
649
762
|
zip.remove(relativePath);
|
|
650
763
|
}
|
|
651
764
|
}
|