From 8e884d330678b99f9bc5504728cc35a52653a5a7 Mon Sep 17 00:00:00 2001 From: Georgi Angelov Date: Fri, 6 Feb 2026 13:47:39 +0200 Subject: [PATCH 1/7] feat: support external backpack configuration through the GUIConfig set of interfaces This allows other projects to specify their own backpack server implementation and API. --- .../scratch-gui/src/components/gui/gui.jsx | 5 +- .../scratch-gui/src/containers/backpack.jsx | 39 +++--- packages/scratch-gui/src/gui-config.ts | 110 +++++++++++++++- packages/scratch-gui/src/index-standalone.tsx | 4 +- packages/scratch-gui/src/lib/backpack-api.js | 80 ------------ .../lib/backpack/payload-serializable-data.ts | 51 ++++++++ .../src/lib/legacy-backpack-storage.ts | 121 ++++++++++++++++++ .../scratch-gui/src/lib/legacy-storage.ts | 20 +-- 8 files changed, 311 insertions(+), 119 deletions(-) create mode 100644 packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts create mode 100644 packages/scratch-gui/src/lib/legacy-backpack-storage.ts diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 0452e9ba4c..de7a3eab00 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -123,6 +123,7 @@ const GUIComponent = props => { authorAvatarBadge, basePath, backdropLibraryVisible, + backpackConfigured, backpackHost, backpackVisible, blocksId, @@ -520,7 +521,7 @@ const GUIComponent = props => { /> : null} - {backpackVisible ? ( + {backpackVisible && backpackConfigured ? ( ({ // This is the button's mode, as opposed to the actual current state + backpackConfigured: !!state.scratchGui.config.storage?.backpackStorage, blocksId: state.scratchGui.timeTravel.year.toString(), stageSizeMode: state.scratchGui.stageSize.stageSize, colorMode: state.scratchGui.settings.colorMode, diff --git a/packages/scratch-gui/src/containers/backpack.jsx b/packages/scratch-gui/src/containers/backpack.jsx index aef0afe163..8d7ea6b23f 100644 --- a/packages/scratch-gui/src/containers/backpack.jsx +++ b/packages/scratch-gui/src/containers/backpack.jsx @@ -2,15 +2,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import bindAll from 'lodash.bindall'; import BackpackComponent from '../components/backpack/backpack.jsx'; -import { - getBackpackContents, - saveBackpackObject, - deleteBackpackObject, - soundPayload, - costumePayload, - spritePayload, - codePayload -} from '../lib/backpack-api'; +import soundPayload from '../lib/backpack/sound-payload'; +import costumePayload from '../lib/backpack/costume-payload'; +import spritePayload from '../lib/backpack/sprite-payload'; +import codePayload from '../lib/backpack/code-payload'; +import {PayloadSerializableData} from '../lib/backpack/payload-serializable-data.ts'; import DragConstants from '../lib/drag-constants'; import DropAreaHOC from '../lib/drop-area-hoc.jsx'; import {GUIStoragePropType} from '../gui-config'; @@ -74,6 +70,7 @@ class Backpack extends React.Component { } handleDrop (dragInfo) { const scratchStorage = this.props.storage.scratchStorage; + const backpackStorage = this.props.storage.backpackStorage; let payloader = null; let presaveAsset = null; @@ -111,12 +108,18 @@ class Backpack extends React.Component { } return payload; }) - .then(payload => saveBackpackObject({ - host: this.props.host, - token: this.props.token, - username: this.props.username, - ...payload - })) + .then(payload => { + const serializableData = new PayloadSerializableData(payload); + return backpackStorage.save( + { + token: this.props.token, + username: this.props.username, + type: serializableData.getType(), + name: serializableData.getName() + }, + serializableData + ); + }) .then(item => { this.setState({ loading: false, @@ -131,8 +134,7 @@ class Backpack extends React.Component { } handleDelete (id) { this.setState({loading: true}, () => { - deleteBackpackObject({ - host: this.props.host, + this.props.storage.backpackStorage.delete({ token: this.props.token, username: this.props.username, id: id @@ -152,8 +154,7 @@ class Backpack extends React.Component { getContents () { if (this.props.token && this.props.username) { this.setState({loading: true, error: false}, () => { - getBackpackContents({ - host: this.props.host, + this.props.storage.backpackStorage.list({ token: this.props.token, username: this.props.username, offset: this.state.contents.length, diff --git a/packages/scratch-gui/src/gui-config.ts b/packages/scratch-gui/src/gui-config.ts index 0773972d32..163d5f3a17 100644 --- a/packages/scratch-gui/src/gui-config.ts +++ b/packages/scratch-gui/src/gui-config.ts @@ -10,6 +10,7 @@ export interface GUIConfig { export interface GUIStorage { scratchStorage: ScratchStorage; + backpackStorage?: GUIBackpackStorage; // Called multiple times (as changes happen) setProjectHost?(host: string): void; @@ -31,8 +32,108 @@ export interface GUIStorage { ): Promise<{ id: ProjectId }>; saveProjectThumbnail?(projectId: ProjectId, thumbnail: Blob): void; +} + +export interface GUIBackpackStorage { + list(request: BackpackListItemsInput): Promise; + save(item: BackpackSaveItemInput, data: SerializableData): Promise; + delete(item: BackpackDeleteItemInput): Promise; +} + +export interface BackpackListItemsInput { + /** + * The username of the currently-logged in user + */ + username: string, + + /** + * The auth token given to GUI in props + */ + token: string, + limit: number, + offset: number +} + +export interface BackpackSaveItemInput { + /** + * The username of the currently-logged in user + */ + username: string, + + /** + * The auth token given to GUI in props + */ + token: string, + + /** + * Type of backpack object + */ + type: BackpackItemType, + + /** + * User-facing name of the object being saved + */ + name: string, +} + +export interface SerializableData { + mimeType(): string, + dataAsBase64(): Promise, + thumbnailAsBase64(): Promise +} + +export interface BackpackDeleteItemInput { + id: string, + username: string, + token: string +} + +export type BackpackItemType = 'costume' | 'sound' | 'script' | 'sprite'; + +export interface BackpackItem { + /** + * A unique identifier for the backpack item. + * UUID format. + */ + id: string, + + /** + * Name of the item + */ + name: string, - // TODO: Support backpack storage + /** + * The type of backpack item + */ + type: BackpackItemType, + + /** + * The path (URL without host) of the thumbnail + */ + thumbnail: string, + + /** + * The full URL (incl. host) of the thumbnail + */ + thumbnailUrl: string, + + /** + * The md5ext of the backpack item. + * + * Different backpack items are loaded from different places: + * + * - costume -> the md5ext specified here is loaded from + * the asset server (has to be registered on the storage instance) + * - sound -> same as above + * - script -> loaded from the backpack server using `bodyUrl`. The `body` field isn't used. + * - sprite -> same as above + */ + body: string, + + /** + * The full URL (incl. host) of the backpack body + */ + bodyUrl: string, } export type TranslatorFunction = ( @@ -46,8 +147,15 @@ export interface MessageObject { defaultMessage: string; } +export const GUIBackpackStoragePropType = PropTypes.shape({ + list: PropTypes.func.isRequired, + save: PropTypes.func.isRequired, + delete: PropTypes.func.isRequired, +}); + export const GUIStoragePropType = PropTypes.shape({ scratchStorage: PropTypes.object.isRequired, + backpackStorage: GUIBackpackStoragePropType, setProjectHost: PropTypes.func, setProjectToken: PropTypes.func, diff --git a/packages/scratch-gui/src/index-standalone.tsx b/packages/scratch-gui/src/index-standalone.tsx index 2bf3d94f87..f3113af37e 100644 --- a/packages/scratch-gui/src/index-standalone.tsx +++ b/packages/scratch-gui/src/index-standalone.tsx @@ -16,10 +16,12 @@ export * from './exported-reducers'; export * from 'scratch-storage'; +export * from './lib/legacy-backpack-storage'; + export {default as buildDefaultProject} from './lib/default-project'; // TODO: Better typing once ScratchGUI has types - + export type GUIProps = any; // ComponentPropsWithoutRef; export type HigherOrderComponent = (component: ReactComponentLike) => ReactComponentLike; diff --git a/packages/scratch-gui/src/lib/backpack-api.js b/packages/scratch-gui/src/lib/backpack-api.js index 37a0aded03..41eb670c9d 100644 --- a/packages/scratch-gui/src/lib/backpack-api.js +++ b/packages/scratch-gui/src/lib/backpack-api.js @@ -1,77 +1,4 @@ import xhr from 'xhr'; -import costumePayload from './backpack/costume-payload'; -import soundPayload from './backpack/sound-payload'; -import spritePayload from './backpack/sprite-payload'; -import codePayload from './backpack/code-payload'; - -// Add a new property for the full thumbnail url, which includes the host. -// Also include a full body url for loading sprite zips -// TODO retreiving the images through storage would allow us to remove this. -const includeFullUrls = (item, host) => Object.assign({}, item, { - thumbnailUrl: `${host}/${item.thumbnail}`, - bodyUrl: `${host}/${item.body}` -}); - -const getBackpackContents = ({ - host, - username, - token, - limit, - offset -}) => new Promise((resolve, reject) => { - xhr({ - method: 'GET', - uri: `${host}/${username}?limit=${limit}&offset=${offset}`, - headers: {'x-token': token}, - json: true - }, (error, response) => { - if (error || response.statusCode !== 200) { - return reject(new Error(response.status)); - } - return resolve(response.body.map(item => includeFullUrls(item, host))); - }); -}); - -const saveBackpackObject = ({ - host, - username, - token, - type, // Type of object being saved to the backpack - mime, // Mime-type of the object being saved - name, // User-facing name of the object being saved - body, // Base64-encoded body of the object being saved - thumbnail // Base64-encoded JPEG thumbnail of the object being saved -}) => new Promise((resolve, reject) => { - xhr({ - method: 'POST', - uri: `${host}/${username}`, - headers: {'x-token': token}, - json: {type, mime, name, body, thumbnail} - }, (error, response) => { - if (error || response.statusCode !== 200) { - return reject(new Error(response.status)); - } - return resolve(includeFullUrls(response.body, host)); - }); -}); - -const deleteBackpackObject = ({ - host, - username, - token, - id -}) => new Promise((resolve, reject) => { - xhr({ - method: 'DELETE', - uri: `${host}/${username}/${id}`, - headers: {'x-token': token} - }, (error, response) => { - if (error || response.statusCode !== 200) { - return reject(new Error(response.status)); - } - return resolve(response.body); - }); -}); // Two types of backpack items are not retreivable through storage // code, as json and sprite3 as arraybuffer zips. @@ -90,13 +17,6 @@ const fetchCode = fetchAs.bind(null, 'json'); const fetchSprite = fetchAs.bind(null, 'arraybuffer'); export { - getBackpackContents, - saveBackpackObject, - deleteBackpackObject, - costumePayload, - soundPayload, - spritePayload, - codePayload, fetchCode, fetchSprite }; diff --git a/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts b/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts new file mode 100644 index 0000000000..3296b76dc6 --- /dev/null +++ b/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts @@ -0,0 +1,51 @@ +import {SerializableData, BackpackItemType} from '../../gui-config'; + +/** + * The shape of a backpack payload as returned by the existing payload functions + * (costume-payload, sound-payload, sprite-payload, code-payload) + */ +export interface BackpackPayload { + type: BackpackItemType; + name: string; + mime: string; + body: string; + thumbnail: string; +} + +/** + * Adapter class that wraps an existing payload object to implement the SerializableData interface. + * This allows the legacy payload functions to be used with the new GUIBackpackStorage interface. + */ +export class PayloadSerializableData implements SerializableData { + private payload: BackpackPayload; + + constructor(payload: BackpackPayload) { + this.payload = payload; + } + + mimeType(): string { + return this.payload.mime; + } + + dataAsBase64(): Promise { + return Promise.resolve(this.payload.body); + } + + thumbnailAsBase64(): Promise { + return Promise.resolve(this.payload.thumbnail); + } + + /** + * Returns the type of the backpack item + */ + getType(): BackpackItemType { + return this.payload.type; + } + + /** + * Returns the name of the backpack item + */ + getName(): string { + return this.payload.name; + } +} diff --git a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts new file mode 100644 index 0000000000..e7080c3b1c --- /dev/null +++ b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts @@ -0,0 +1,121 @@ +import xhr from 'xhr'; +import {ScratchStorage, Asset} from 'scratch-storage'; +import { + GUIBackpackStorage, + BackpackListItemsInput, + BackpackSaveItemInput, + BackpackDeleteItemInput, + BackpackItem, + SerializableData +} from '../gui-config'; + +type BackpackItemWithoutUrls = Omit; + +// Add a new property for the full thumbnail url, which includes the host. +// Also include a full body url for loading sprite zips +// TODO retreiving the images through storage would allow us to remove this. +const includeFullUrls = (item: BackpackItemWithoutUrls, host: string): BackpackItem => ({ + ...item, + thumbnailUrl: `${host}/${item.thumbnail}`, + bodyUrl: `${host}/${item.body}` +}); + +export class LegacyBackpackStorage implements GUIBackpackStorage { + private host?: string; + private webStoreRegistered = false; + + // TODO: This is unsafe to call multiple times. It's fine in our usages for now, but should + // maybe be updated to remove the old webStore setting before adding the new one + setHostAndRegisterWebStore(host: string, scratchStorage: ScratchStorage): void { + this.host = host; + + if (!this.webStoreRegistered) { + const AssetType = scratchStorage.AssetType; + scratchStorage.addWebStore( + [AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], + this.getBackpackAssetURL.bind(this) + ); + this.webStoreRegistered = true; + } + } + + list(request: BackpackListItemsInput): Promise { + const host = this.host; + if (!host) { + return Promise.reject(new Error('Backpack host not set')); + } + + return new Promise((resolve, reject) => { + xhr({ + method: 'GET', + uri: `${host}/${request.username}?limit=${request.limit}&offset=${request.offset}`, + headers: {'x-token': request.token}, + json: true + }, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(new Error(String(response.statusCode))); + } + const items = response.body as BackpackItemWithoutUrls[]; + return resolve(items.map(item => includeFullUrls(item, host))); + }); + }); + } + + save(item: BackpackSaveItemInput, data: SerializableData): Promise { + const host = this.host; + if (!host) { + return Promise.reject(new Error('Backpack host not set')); + } + + return Promise.all([ + data.dataAsBase64(), + data.thumbnailAsBase64() + ]).then(([body, thumbnail]) => { + return new Promise((resolve, reject) => { + xhr({ + method: 'POST', + uri: `${host}/${item.username}`, + headers: {'x-token': item.token}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the type of the json param is wrong here + json: { + type: item.type, + mime: data.mimeType(), + name: item.name, + + body, + thumbnail + } as any + }, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(new Error(String(response.statusCode))); + } + return resolve(includeFullUrls(response.body as BackpackItemWithoutUrls, host)); + }); + }); + }); + } + + delete(item: BackpackDeleteItemInput): Promise { + const host = this.host; + if (!host) { + return Promise.reject(new Error('Backpack host not set')); + } + + return new Promise((resolve, reject) => { + xhr({ + method: 'DELETE', + uri: `${host}/${item.username}/${item.id}`, + headers: {'x-token': item.token} + }, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(new Error(String(response.statusCode))); + } + return resolve(); + }); + }); + } + + private getBackpackAssetURL(asset: Asset): string { + return `${this.host}/${asset.assetId}.${asset.dataFormat}`; + } +} diff --git a/packages/scratch-gui/src/lib/legacy-storage.ts b/packages/scratch-gui/src/lib/legacy-storage.ts index 33cb8ece0c..2e1f98626f 100644 --- a/packages/scratch-gui/src/lib/legacy-storage.ts +++ b/packages/scratch-gui/src/lib/legacy-storage.ts @@ -2,6 +2,7 @@ import {ScratchStorage, Asset} from 'scratch-storage'; import defaultProject from './default-project'; import {GUIStorage, TranslatorFunction} from '../gui-config'; +import {LegacyBackpackStorage} from './legacy-backpack-storage'; import saveProjectToServer from '../lib/save-project-to-server'; @@ -9,10 +10,10 @@ export class LegacyStorage implements GUIStorage { private projectHost?: string; private projectToken?: string; private assetHost?: string; - private backpackHost?: string; private translator?: TranslatorFunction; readonly scratchStorage = new ScratchStorage(); + readonly backpackStorage = new LegacyBackpackStorage(); constructor () { this.cacheDefaultProject(this.scratchStorage); @@ -46,22 +47,11 @@ export class LegacyStorage implements GUIStorage { setTranslatorFunction (translator: TranslatorFunction): void { this.translator = translator; - // TODO: Verify that this is correct this.cacheDefaultProject(this.scratchStorage); } setBackpackHost (host: string): void { - const shouldAddSource = !this.backpackHost; - if (shouldAddSource) { - const AssetType = this.scratchStorage.AssetType; - - this.scratchStorage.addWebStore( - [AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], - this.getBackpackAssetURL.bind(this) - ); - } - - this.backpackHost = host; + this.backpackStorage.setHostAndRegisterWebStore(host, this.scratchStorage); } saveProject ( @@ -147,8 +137,4 @@ export class LegacyStorage implements GUIStorage { withCredentials: true }; } - - private getBackpackAssetURL (asset) { - return `${this.backpackHost}/${asset.assetId}.${asset.dataFormat}`; - } } From d11a7416f199647d860ee60838af82356612182c Mon Sep 17 00:00:00 2001 From: Georgi Angelov Date: Fri, 6 Feb 2026 15:41:50 +0200 Subject: [PATCH 2/7] feat: allow configuring the backpack authorization header type --- .../src/lib/legacy-backpack-storage.ts | 16 +++++++++++++--- packages/scratch-gui/src/lib/legacy-storage.ts | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts index e7080c3b1c..a3a9c32190 100644 --- a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts +++ b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts @@ -24,6 +24,10 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { private host?: string; private webStoreRegistered = false; + constructor( + private tokenHeader: 'x-token' | 'authorization' + ) {} + // TODO: This is unsafe to call multiple times. It's fine in our usages for now, but should // maybe be updated to remove the old webStore setting before adding the new one setHostAndRegisterWebStore(host: string, scratchStorage: ScratchStorage): void { @@ -49,7 +53,9 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { xhr({ method: 'GET', uri: `${host}/${request.username}?limit=${request.limit}&offset=${request.offset}`, - headers: {'x-token': request.token}, + headers: this.tokenHeader === 'x-token' ? + {'x-token': request.token} : + {Authorization: `Bearer ${request.token}`}, json: true }, (error, response) => { if (error || response.statusCode !== 200) { @@ -75,7 +81,9 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { xhr({ method: 'POST', uri: `${host}/${item.username}`, - headers: {'x-token': item.token}, + headers: this.tokenHeader === 'x-token' ? + {'x-token': item.token} : + {Authorization: `Bearer ${item.token}`}, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the type of the json param is wrong here json: { type: item.type, @@ -105,7 +113,9 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { xhr({ method: 'DELETE', uri: `${host}/${item.username}/${item.id}`, - headers: {'x-token': item.token} + headers: this.tokenHeader === 'x-token' ? + {'x-token': item.token} : + {Authorization: `Bearer ${item.token}`} }, (error, response) => { if (error || response.statusCode !== 200) { return reject(new Error(String(response.statusCode))); diff --git a/packages/scratch-gui/src/lib/legacy-storage.ts b/packages/scratch-gui/src/lib/legacy-storage.ts index 2e1f98626f..fb8f5d321d 100644 --- a/packages/scratch-gui/src/lib/legacy-storage.ts +++ b/packages/scratch-gui/src/lib/legacy-storage.ts @@ -13,7 +13,7 @@ export class LegacyStorage implements GUIStorage { private translator?: TranslatorFunction; readonly scratchStorage = new ScratchStorage(); - readonly backpackStorage = new LegacyBackpackStorage(); + readonly backpackStorage = new LegacyBackpackStorage('x-token'); constructor () { this.cacheDefaultProject(this.scratchStorage); From 33b0df0dc391493a93923ba68c2432ec46226062 Mon Sep 17 00:00:00 2001 From: Georgi Angelov Date: Fri, 6 Feb 2026 16:09:00 +0200 Subject: [PATCH 3/7] feat: make it so that session can be passed externally to the backpack --- .../scratch-gui/src/containers/backpack.jsx | 22 +++++++--- packages/scratch-gui/src/gui-config.ts | 30 ++++--------- .../src/lib/legacy-backpack-storage.ts | 42 +++++++++++++++---- 3 files changed, 57 insertions(+), 37 deletions(-) diff --git a/packages/scratch-gui/src/containers/backpack.jsx b/packages/scratch-gui/src/containers/backpack.jsx index 8d7ea6b23f..b1a0d431c3 100644 --- a/packages/scratch-gui/src/containers/backpack.jsx +++ b/packages/scratch-gui/src/containers/backpack.jsx @@ -49,11 +49,27 @@ class Backpack extends React.Component { if (props.host) { props.storage.setBackpackHost?.(props.host); } + // Set initial session + this.updateBackpackSession(props); } componentDidMount () { this.props.vm.addListener('BLOCK_DRAG_END', this.handleBlockDragEnd); this.props.vm.addListener('BLOCK_DRAG_UPDATE', this.handleBlockDragUpdate); } + componentDidUpdate (prevProps) { + // Update session when credentials change + if (prevProps.username !== this.props.username || prevProps.token !== this.props.token) { + this.updateBackpackSession(this.props); + } + } + updateBackpackSession (props) { + const {username, token} = props; + if (username && token) { + props.storage.backpackStorage?.setSession?.({username, token}); + } else { + props.storage.backpackStorage?.setSession?.(null); + } + } componentWillUnmount () { this.props.vm.removeListener('BLOCK_DRAG_END', this.handleBlockDragEnd); this.props.vm.removeListener('BLOCK_DRAG_UPDATE', this.handleBlockDragUpdate); @@ -112,8 +128,6 @@ class Backpack extends React.Component { const serializableData = new PayloadSerializableData(payload); return backpackStorage.save( { - token: this.props.token, - username: this.props.username, type: serializableData.getType(), name: serializableData.getName() }, @@ -135,8 +149,6 @@ class Backpack extends React.Component { handleDelete (id) { this.setState({loading: true}, () => { this.props.storage.backpackStorage.delete({ - token: this.props.token, - username: this.props.username, id: id }) .then(() => { @@ -155,8 +167,6 @@ class Backpack extends React.Component { if (this.props.token && this.props.username) { this.setState({loading: true, error: false}, () => { this.props.storage.backpackStorage.list({ - token: this.props.token, - username: this.props.username, offset: this.state.contents.length, limit: this.state.itemsPerPage }) diff --git a/packages/scratch-gui/src/gui-config.ts b/packages/scratch-gui/src/gui-config.ts index 163d5f3a17..42883ce792 100644 --- a/packages/scratch-gui/src/gui-config.ts +++ b/packages/scratch-gui/src/gui-config.ts @@ -38,33 +38,20 @@ export interface GUIBackpackStorage { list(request: BackpackListItemsInput): Promise; save(item: BackpackSaveItemInput, data: SerializableData): Promise; delete(item: BackpackDeleteItemInput): Promise; + setSession?(session: BackpackSession | null): void; } -export interface BackpackListItemsInput { - /** - * The username of the currently-logged in user - */ - username: string, +export interface BackpackSession { + username: string; + token: string; +} - /** - * The auth token given to GUI in props - */ - token: string, +export interface BackpackListItemsInput { limit: number, offset: number } export interface BackpackSaveItemInput { - /** - * The username of the currently-logged in user - */ - username: string, - - /** - * The auth token given to GUI in props - */ - token: string, - /** * Type of backpack object */ @@ -83,9 +70,7 @@ export interface SerializableData { } export interface BackpackDeleteItemInput { - id: string, - username: string, - token: string + id: string } export type BackpackItemType = 'costume' | 'sound' | 'script' | 'sprite'; @@ -151,6 +136,7 @@ export const GUIBackpackStoragePropType = PropTypes.shape({ list: PropTypes.func.isRequired, save: PropTypes.func.isRequired, delete: PropTypes.func.isRequired, + setSession: PropTypes.func, }); export const GUIStoragePropType = PropTypes.shape({ diff --git a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts index a3a9c32190..23c4a3e1bc 100644 --- a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts +++ b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts @@ -6,6 +6,7 @@ import { BackpackSaveItemInput, BackpackDeleteItemInput, BackpackItem, + BackpackSession, SerializableData } from '../gui-config'; @@ -23,11 +24,19 @@ const includeFullUrls = (item: BackpackItemWithoutUrls, host: string): BackpackI export class LegacyBackpackStorage implements GUIBackpackStorage { private host?: string; private webStoreRegistered = false; + private session?: BackpackSession; constructor( private tokenHeader: 'x-token' | 'authorization' ) {} + /** + * Set the session for backpack API requests. + */ + setSession(session: BackpackSession | null): void { + this.session = session ?? undefined; + } + // TODO: This is unsafe to call multiple times. It's fine in our usages for now, but should // maybe be updated to remove the old webStore setting before adding the new one setHostAndRegisterWebStore(host: string, scratchStorage: ScratchStorage): void { @@ -49,13 +58,18 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { return Promise.reject(new Error('Backpack host not set')); } + const session = this.session; + if (!session) { + return Promise.reject(new Error('Backpack credentials not set')); + } + return new Promise((resolve, reject) => { xhr({ method: 'GET', - uri: `${host}/${request.username}?limit=${request.limit}&offset=${request.offset}`, + uri: `${host}/${session.username}?limit=${request.limit}&offset=${request.offset}`, headers: this.tokenHeader === 'x-token' ? - {'x-token': request.token} : - {Authorization: `Bearer ${request.token}`}, + {'x-token': session.token} : + {Authorization: `Bearer ${session.token}`}, json: true }, (error, response) => { if (error || response.statusCode !== 200) { @@ -73,6 +87,11 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { return Promise.reject(new Error('Backpack host not set')); } + const session = this.session; + if (!session) { + return Promise.reject(new Error('Backpack credentials not set')); + } + return Promise.all([ data.dataAsBase64(), data.thumbnailAsBase64() @@ -80,10 +99,10 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { return new Promise((resolve, reject) => { xhr({ method: 'POST', - uri: `${host}/${item.username}`, + uri: `${host}/${session.username}`, headers: this.tokenHeader === 'x-token' ? - {'x-token': item.token} : - {Authorization: `Bearer ${item.token}`}, + {'x-token': session.token} : + {Authorization: `Bearer ${session.token}`}, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the type of the json param is wrong here json: { type: item.type, @@ -109,13 +128,18 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { return Promise.reject(new Error('Backpack host not set')); } + const session = this.session; + if (!session) { + return Promise.reject(new Error('Backpack credentials not set')); + } + return new Promise((resolve, reject) => { xhr({ method: 'DELETE', - uri: `${host}/${item.username}/${item.id}`, + uri: `${host}/${session.username}/${item.id}`, headers: this.tokenHeader === 'x-token' ? - {'x-token': item.token} : - {Authorization: `Bearer ${item.token}`} + {'x-token': session.token} : + {Authorization: `Bearer ${session.token}`} }, (error, response) => { if (error || response.statusCode !== 200) { return reject(new Error(String(response.statusCode))); From edd946c09373abe36396bcd6049a7de3b5464850 Mon Sep 17 00:00:00 2001 From: Georgi Angelov Date: Fri, 6 Feb 2026 16:30:58 +0200 Subject: [PATCH 4/7] chore: allow token for backpack service to be computed using an async function --- .../scratch-gui/src/containers/backpack.jsx | 6 +- packages/scratch-gui/src/gui-config.ts | 9 +-- .../src/lib/legacy-backpack-storage.ts | 81 ++++++++++++------- .../scratch-gui/src/lib/legacy-storage.ts | 14 +++- 4 files changed, 69 insertions(+), 41 deletions(-) diff --git a/packages/scratch-gui/src/containers/backpack.jsx b/packages/scratch-gui/src/containers/backpack.jsx index b1a0d431c3..a622dbed17 100644 --- a/packages/scratch-gui/src/containers/backpack.jsx +++ b/packages/scratch-gui/src/containers/backpack.jsx @@ -67,7 +67,7 @@ class Backpack extends React.Component { if (username && token) { props.storage.backpackStorage?.setSession?.({username, token}); } else { - props.storage.backpackStorage?.setSession?.(null); + props.storage.backpackStorage?.setSession?.(undefined); } } componentWillUnmount () { @@ -148,9 +148,7 @@ class Backpack extends React.Component { } handleDelete (id) { this.setState({loading: true}, () => { - this.props.storage.backpackStorage.delete({ - id: id - }) + this.props.storage.backpackStorage.delete(id) .then(() => { this.setState({ loading: false, diff --git a/packages/scratch-gui/src/gui-config.ts b/packages/scratch-gui/src/gui-config.ts index 42883ce792..5da8cb8caa 100644 --- a/packages/scratch-gui/src/gui-config.ts +++ b/packages/scratch-gui/src/gui-config.ts @@ -35,10 +35,11 @@ export interface GUIStorage { } export interface GUIBackpackStorage { + setSession?(session: BackpackSession | undefined): void; + list(request: BackpackListItemsInput): Promise; save(item: BackpackSaveItemInput, data: SerializableData): Promise; - delete(item: BackpackDeleteItemInput): Promise; - setSession?(session: BackpackSession | null): void; + delete(id: string): Promise; } export interface BackpackSession { @@ -69,10 +70,6 @@ export interface SerializableData { thumbnailAsBase64(): Promise } -export interface BackpackDeleteItemInput { - id: string -} - export type BackpackItemType = 'costume' | 'sound' | 'script' | 'sprite'; export interface BackpackItem { diff --git a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts index 23c4a3e1bc..72fb6b34c1 100644 --- a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts +++ b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts @@ -4,7 +4,6 @@ import { GUIBackpackStorage, BackpackListItemsInput, BackpackSaveItemInput, - BackpackDeleteItemInput, BackpackItem, BackpackSession, SerializableData @@ -21,19 +20,50 @@ const includeFullUrls = (item: BackpackItemWithoutUrls, host: string): BackpackI bodyUrl: `${host}/${item.body}` }); +export interface LegacyBackpackStorageConfig { + /** + * Reads the current authentication session - necessary for making a backpack request. + * + * It can be called with a missing session if the session was not set on the Redux store. + * In general, the session will be missing when the Standalone version of the editor is used. + */ + readAuth(session: BackpackSession | undefined): Promise +} + +export interface LegacyBackpackAuth { + /** + * The username of the user. This is part of the request URL so it's mandatory + */ + username: string, + + /** + * The authentication type - only these two are supported by this backpack service. + */ + authType: 'x-token' | 'jwt' + + /** + * The token to be provided as authentication + */ + authToken: string +} + export class LegacyBackpackStorage implements GUIBackpackStorage { private host?: string; private webStoreRegistered = false; private session?: BackpackSession; constructor( - private tokenHeader: 'x-token' | 'authorization' + private config: LegacyBackpackStorageConfig ) {} /** * Set the session for backpack API requests. + * + * This is only used by the non-standalone version of the editor, where the session + * is taken directly from scratch-www's Redux store. In all other cases this will be + * missing. */ - setSession(session: BackpackSession | null): void { + setSession(session: BackpackSession | undefined): void { this.session = session ?? undefined; } @@ -52,24 +82,21 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { } } - list(request: BackpackListItemsInput): Promise { + async list(request: BackpackListItemsInput): Promise { const host = this.host; if (!host) { return Promise.reject(new Error('Backpack host not set')); } - const session = this.session; - if (!session) { - return Promise.reject(new Error('Backpack credentials not set')); - } + const auth = await this.config.readAuth(this.session); return new Promise((resolve, reject) => { xhr({ method: 'GET', - uri: `${host}/${session.username}?limit=${request.limit}&offset=${request.offset}`, - headers: this.tokenHeader === 'x-token' ? - {'x-token': session.token} : - {Authorization: `Bearer ${session.token}`}, + uri: `${host}/${auth.username}?limit=${request.limit}&offset=${request.offset}`, + headers: auth.authType === 'x-token' ? + {'x-token': auth.authToken} : + {Authorization: `Bearer ${auth.authToken}`}, json: true }, (error, response) => { if (error || response.statusCode !== 200) { @@ -81,16 +108,13 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { }); } - save(item: BackpackSaveItemInput, data: SerializableData): Promise { + async save(item: BackpackSaveItemInput, data: SerializableData): Promise { const host = this.host; if (!host) { return Promise.reject(new Error('Backpack host not set')); } - const session = this.session; - if (!session) { - return Promise.reject(new Error('Backpack credentials not set')); - } + const auth = await this.config.readAuth(this.session); return Promise.all([ data.dataAsBase64(), @@ -99,10 +123,10 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { return new Promise((resolve, reject) => { xhr({ method: 'POST', - uri: `${host}/${session.username}`, - headers: this.tokenHeader === 'x-token' ? - {'x-token': session.token} : - {Authorization: `Bearer ${session.token}`}, + uri: `${host}/${auth.username}`, + headers: auth.authType === 'x-token' ? + {'x-token': auth.authToken} : + {Authorization: `Bearer ${auth.authToken}`}, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the type of the json param is wrong here json: { type: item.type, @@ -122,24 +146,21 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { }); } - delete(item: BackpackDeleteItemInput): Promise { + async delete(id: string): Promise { const host = this.host; if (!host) { return Promise.reject(new Error('Backpack host not set')); } - const session = this.session; - if (!session) { - return Promise.reject(new Error('Backpack credentials not set')); - } + const auth = await this.config.readAuth(this.session); return new Promise((resolve, reject) => { xhr({ method: 'DELETE', - uri: `${host}/${session.username}/${item.id}`, - headers: this.tokenHeader === 'x-token' ? - {'x-token': session.token} : - {Authorization: `Bearer ${session.token}`} + uri: `${host}/${auth.username}/${id}`, + headers: auth.authType === 'x-token' ? + {'x-token': auth.authToken} : + {Authorization: `Bearer ${auth.authToken}`}, }, (error, response) => { if (error || response.statusCode !== 200) { return reject(new Error(String(response.statusCode))); diff --git a/packages/scratch-gui/src/lib/legacy-storage.ts b/packages/scratch-gui/src/lib/legacy-storage.ts index fb8f5d321d..e4d6f18cdb 100644 --- a/packages/scratch-gui/src/lib/legacy-storage.ts +++ b/packages/scratch-gui/src/lib/legacy-storage.ts @@ -13,7 +13,19 @@ export class LegacyStorage implements GUIStorage { private translator?: TranslatorFunction; readonly scratchStorage = new ScratchStorage(); - readonly backpackStorage = new LegacyBackpackStorage('x-token'); + readonly backpackStorage = new LegacyBackpackStorage({ + readAuth(session) { + if (!session) { + return Promise.reject(new Error('missing session')); + } + + return Promise.resolve({ + username: session.username, + authType: 'x-token', + authToken: session.token + }) + }, + }); constructor () { this.cacheDefaultProject(this.scratchStorage); From 34ffe9143bf595591029c08780523fe7197a86aa Mon Sep 17 00:00:00 2001 From: Georgi Angelov Date: Tue, 10 Feb 2026 11:35:22 +0200 Subject: [PATCH 5/7] chore: skip the session check for loading the backpack If the previous code ended up in this state - it was already invalid since the backpack was shown but it would say "No items" instead of indicating to the user that they are logged out --- .../scratch-gui/src/containers/backpack.jsx | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/scratch-gui/src/containers/backpack.jsx b/packages/scratch-gui/src/containers/backpack.jsx index a622dbed17..6d7bff3f33 100644 --- a/packages/scratch-gui/src/containers/backpack.jsx +++ b/packages/scratch-gui/src/containers/backpack.jsx @@ -162,25 +162,23 @@ class Backpack extends React.Component { }); } getContents () { - if (this.props.token && this.props.username) { - this.setState({loading: true, error: false}, () => { - this.props.storage.backpackStorage.list({ - offset: this.state.contents.length, - limit: this.state.itemsPerPage - }) - .then(contents => { - this.setState({ - contents: this.state.contents.concat(contents), - moreToLoad: contents.length === this.state.itemsPerPage, - loading: false - }); - }) - .catch(error => { - this.setState({error: true, loading: false}); - throw error; + this.setState({loading: true, error: false}, () => { + this.props.storage.backpackStorage.list({ + offset: this.state.contents.length, + limit: this.state.itemsPerPage + }) + .then(contents => { + this.setState({ + contents: this.state.contents.concat(contents), + moreToLoad: contents.length === this.state.itemsPerPage, + loading: false }); - }); - } + }) + .catch(error => { + this.setState({error: true, loading: false}); + throw error; + }); + }); } handleBlockDragUpdate (isOutsideWorkspace) { this.setState({ From c6b2152a552ea1cc4324fc2c41921083b9005f47 Mon Sep 17 00:00:00 2001 From: Georgi Angelov Date: Tue, 10 Feb 2026 11:56:51 +0200 Subject: [PATCH 6/7] chore: fix lint errors --- .../scratch-gui/src/components/gui/gui.jsx | 4 +- .../scratch-gui/src/containers/backpack.jsx | 10 +-- packages/scratch-gui/src/gui-config.ts | 4 +- .../lib/backpack/payload-serializable-data.ts | 12 ++-- .../src/lib/legacy-backpack-storage.ts | 67 +++++++++---------- .../scratch-gui/src/lib/legacy-storage.ts | 6 +- 6 files changed, 50 insertions(+), 53 deletions(-) diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index de7a3eab00..27a4b7e948 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -689,11 +689,11 @@ GUIComponent.defaultProps = { const mapStateToProps = state => ({ // This is the button's mode, as opposed to the actual current state - backpackConfigured: !!state.scratchGui.config.storage?.backpackStorage, blocksId: state.scratchGui.timeTravel.year.toString(), stageSizeMode: state.scratchGui.stageSize.stageSize, colorMode: state.scratchGui.settings.colorMode, - theme: state.scratchGui.settings.theme + theme: state.scratchGui.settings.theme, + backpackConfigured: !!state.scratchGui.config.storage?.backpackStorage }); const mapDispatchToProps = dispatch => ({ diff --git a/packages/scratch-gui/src/containers/backpack.jsx b/packages/scratch-gui/src/containers/backpack.jsx index 6d7bff3f33..1b7a7ac2df 100644 --- a/packages/scratch-gui/src/containers/backpack.jsx +++ b/packages/scratch-gui/src/containers/backpack.jsx @@ -62,18 +62,18 @@ class Backpack extends React.Component { this.updateBackpackSession(this.props); } } + componentWillUnmount () { + this.props.vm.removeListener('BLOCK_DRAG_END', this.handleBlockDragEnd); + this.props.vm.removeListener('BLOCK_DRAG_UPDATE', this.handleBlockDragUpdate); + } updateBackpackSession (props) { const {username, token} = props; if (username && token) { props.storage.backpackStorage?.setSession?.({username, token}); } else { - props.storage.backpackStorage?.setSession?.(undefined); + props.storage.backpackStorage?.setSession?.(null); } } - componentWillUnmount () { - this.props.vm.removeListener('BLOCK_DRAG_END', this.handleBlockDragEnd); - this.props.vm.removeListener('BLOCK_DRAG_UPDATE', this.handleBlockDragUpdate); - } handleToggle () { const newState = !this.state.expanded; this.setState({expanded: newState, contents: []}, () => { diff --git a/packages/scratch-gui/src/gui-config.ts b/packages/scratch-gui/src/gui-config.ts index 5da8cb8caa..5b43ae3049 100644 --- a/packages/scratch-gui/src/gui-config.ts +++ b/packages/scratch-gui/src/gui-config.ts @@ -35,7 +35,7 @@ export interface GUIStorage { } export interface GUIBackpackStorage { - setSession?(session: BackpackSession | undefined): void; + setSession?(session: BackpackSession | null | undefined): void; list(request: BackpackListItemsInput): Promise; save(item: BackpackSaveItemInput, data: SerializableData): Promise; @@ -133,7 +133,7 @@ export const GUIBackpackStoragePropType = PropTypes.shape({ list: PropTypes.func.isRequired, save: PropTypes.func.isRequired, delete: PropTypes.func.isRequired, - setSession: PropTypes.func, + setSession: PropTypes.func }); export const GUIStoragePropType = PropTypes.shape({ diff --git a/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts b/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts index 3296b76dc6..e56d6aaa49 100644 --- a/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts +++ b/packages/scratch-gui/src/lib/backpack/payload-serializable-data.ts @@ -19,33 +19,33 @@ export interface BackpackPayload { export class PayloadSerializableData implements SerializableData { private payload: BackpackPayload; - constructor(payload: BackpackPayload) { + constructor (payload: BackpackPayload) { this.payload = payload; } - mimeType(): string { + mimeType (): string { return this.payload.mime; } - dataAsBase64(): Promise { + dataAsBase64 (): Promise { return Promise.resolve(this.payload.body); } - thumbnailAsBase64(): Promise { + thumbnailAsBase64 (): Promise { return Promise.resolve(this.payload.thumbnail); } /** * Returns the type of the backpack item */ - getType(): BackpackItemType { + getType (): BackpackItemType { return this.payload.type; } /** * Returns the name of the backpack item */ - getName(): string { + getName (): string { return this.payload.name; } } diff --git a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts index 72fb6b34c1..f785b0c9b1 100644 --- a/packages/scratch-gui/src/lib/legacy-backpack-storage.ts +++ b/packages/scratch-gui/src/lib/legacy-backpack-storage.ts @@ -27,7 +27,7 @@ export interface LegacyBackpackStorageConfig { * It can be called with a missing session if the session was not set on the Redux store. * In general, the session will be missing when the Standalone version of the editor is used. */ - readAuth(session: BackpackSession | undefined): Promise + readAuth(session: BackpackSession | null | undefined): Promise } export interface LegacyBackpackAuth { @@ -50,9 +50,9 @@ export interface LegacyBackpackAuth { export class LegacyBackpackStorage implements GUIBackpackStorage { private host?: string; private webStoreRegistered = false; - private session?: BackpackSession; + private session: BackpackSession | null | undefined = null; - constructor( + constructor ( private config: LegacyBackpackStorageConfig ) {} @@ -63,13 +63,13 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { * is taken directly from scratch-www's Redux store. In all other cases this will be * missing. */ - setSession(session: BackpackSession | undefined): void { - this.session = session ?? undefined; + setSession (session: BackpackSession | null | undefined): void { + this.session = session; } // TODO: This is unsafe to call multiple times. It's fine in our usages for now, but should // maybe be updated to remove the old webStore setting before adding the new one - setHostAndRegisterWebStore(host: string, scratchStorage: ScratchStorage): void { + setHostAndRegisterWebStore (host: string, scratchStorage: ScratchStorage): void { this.host = host; if (!this.webStoreRegistered) { @@ -82,7 +82,7 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { } } - async list(request: BackpackListItemsInput): Promise { + async list (request: BackpackListItemsInput): Promise { const host = this.host; if (!host) { return Promise.reject(new Error('Backpack host not set')); @@ -108,7 +108,7 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { }); } - async save(item: BackpackSaveItemInput, data: SerializableData): Promise { + async save (item: BackpackSaveItemInput, data: SerializableData): Promise { const host = this.host; if (!host) { return Promise.reject(new Error('Backpack host not set')); @@ -119,34 +119,31 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { return Promise.all([ data.dataAsBase64(), data.thumbnailAsBase64() - ]).then(([body, thumbnail]) => { - return new Promise((resolve, reject) => { - xhr({ - method: 'POST', - uri: `${host}/${auth.username}`, - headers: auth.authType === 'x-token' ? - {'x-token': auth.authToken} : - {Authorization: `Bearer ${auth.authToken}`}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the type of the json param is wrong here - json: { - type: item.type, - mime: data.mimeType(), - name: item.name, - - body, - thumbnail - } as any - }, (error, response) => { - if (error || response.statusCode !== 200) { - return reject(new Error(String(response.statusCode))); - } - return resolve(includeFullUrls(response.body as BackpackItemWithoutUrls, host)); - }); + ]).then(([body, thumbnail]) => new Promise((resolve, reject) => { + xhr({ + method: 'POST', + uri: `${host}/${auth.username}`, + headers: auth.authType === 'x-token' ? + {'x-token': auth.authToken} : + {Authorization: `Bearer ${auth.authToken}`}, + json: { + type: item.type, + mime: data.mimeType(), + name: item.name, + + body, + thumbnail + } as any // The type of the json param is wrong + }, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(new Error(String(response.statusCode))); + } + return resolve(includeFullUrls(response.body as BackpackItemWithoutUrls, host)); }); - }); + })); } - async delete(id: string): Promise { + async delete (id: string): Promise { const host = this.host; if (!host) { return Promise.reject(new Error('Backpack host not set')); @@ -160,7 +157,7 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { uri: `${host}/${auth.username}/${id}`, headers: auth.authType === 'x-token' ? {'x-token': auth.authToken} : - {Authorization: `Bearer ${auth.authToken}`}, + {Authorization: `Bearer ${auth.authToken}`} }, (error, response) => { if (error || response.statusCode !== 200) { return reject(new Error(String(response.statusCode))); @@ -170,7 +167,7 @@ export class LegacyBackpackStorage implements GUIBackpackStorage { }); } - private getBackpackAssetURL(asset: Asset): string { + private getBackpackAssetURL (asset: Asset): string { return `${this.host}/${asset.assetId}.${asset.dataFormat}`; } } diff --git a/packages/scratch-gui/src/lib/legacy-storage.ts b/packages/scratch-gui/src/lib/legacy-storage.ts index e4d6f18cdb..f68ff2b83b 100644 --- a/packages/scratch-gui/src/lib/legacy-storage.ts +++ b/packages/scratch-gui/src/lib/legacy-storage.ts @@ -14,7 +14,7 @@ export class LegacyStorage implements GUIStorage { readonly scratchStorage = new ScratchStorage(); readonly backpackStorage = new LegacyBackpackStorage({ - readAuth(session) { + readAuth (session) { if (!session) { return Promise.reject(new Error('missing session')); } @@ -23,8 +23,8 @@ export class LegacyStorage implements GUIStorage { username: session.username, authType: 'x-token', authToken: session.token - }) - }, + }); + } }); constructor () { From 42458e1277b059abe9767d1955229d2c8de2e4cf Mon Sep 17 00:00:00 2001 From: Georgi Angelov Date: Tue, 10 Feb 2026 12:51:54 +0200 Subject: [PATCH 7/7] chore: add a guard, just in case --- packages/scratch-gui/src/containers/backpack.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/scratch-gui/src/containers/backpack.jsx b/packages/scratch-gui/src/containers/backpack.jsx index 1b7a7ac2df..2c07cd6689 100644 --- a/packages/scratch-gui/src/containers/backpack.jsx +++ b/packages/scratch-gui/src/containers/backpack.jsx @@ -125,6 +125,12 @@ class Backpack extends React.Component { return payload; }) .then(payload => { + if (!backpackStorage) { + // Shouldn't happen as this component shouldn't be rendered without a backpack, but + // adding this just in case + return; + } + const serializableData = new PayloadSerializableData(payload); return backpackStorage.save( {