@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.
Files changed (2) hide show
  1. package/package.json +2 -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.4.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": "^13.0.0",
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 && Object.prototype.hasOwnProperty.call(options, 'platform')) {
48
- if (Object.prototype.hasOwnProperty.call(platforms, options.platform)) {
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
- break;
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
- break;
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
- break;
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
- break;
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 (Object.prototype.hasOwnProperty.call(stage, 'videoState') && !VIDEO_STATES.includes(stage.videoState)) {
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 zipFiles) {
645
- try {
646
- await file.async('uint8array');
647
- } catch (error) {
648
- log(`zip had unreadable file ${relativePath}: ${error}`);
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
  }