diff --git a/.editorconfig b/.editorconfig index e84613dd..380697df 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,7 @@ trim_trailing_whitespace = true [*.{js,html}] indent_style = space + +[.circleci/config.yml] +indent_size = 2 +indent_style = space diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..20a33530 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Deploy playground + +on: + workflow_dispatch: + push: + branches: [develop] + +concurrency: + group: "deploy" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Build playground + run: npm run build + - name: Build docs + run: npm run docs + # We expect to see syntax errors from the old jsdoc cli not understanding some of our syntax + # It will still generate what it can, so it's safe to ignore the error + continue-on-error: true + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./playground/ + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 461c6fb0..ea853b4f 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,25 +1,20 @@ -# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions - -name: Node.js CI +name: CI on: push: - branches: [ develop ] pull_request: - branches: [ develop ] jobs: build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup Node.js - uses: actions/setup-node@v1 - with: - node-version: 12.x - - run: npm install - - run: npm run build - # - run: npm run test + - uses: actions/checkout@v4 + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run build + - run: npm run test diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..6f7f377b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v16 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d3935968..00000000 --- a/.travis.yml +++ /dev/null @@ -1,78 +0,0 @@ -language: node_js -node_js: -- 8 -- 10 -env: - global: - - NODE_ENV=production - - NPM_TAG=latest - - RELEASE_TIMESTAMP="$(date +'%Y%m%d%H%M%S')" - matrix: - - NPM_SCRIPT="tap:unit -- --jobs=4" - - NPM_SCRIPT="tap:integration -- --jobs=4" -cache: - directories: - - "$HOME/.npm" -install: -- npm ci --production=false -script: npm run $NPM_SCRIPT -jobs: - include: - - env: NPM_SCRIPT=lint - node_js: 8 - - env: NPM_SCRIPT=build - node_js: 8 - if: not (type != pull_request AND (branch =~ /^(develop|master|hotfix\/)/)) - - stage: release - node_js: 8 - env: NPM_SCRIPT=build - before_deploy: - - > - if [ -z "$BEFORE_DEPLOY_RAN" ]; then - VPKG=$($(npm bin)/json -f package.json version) - export RELEASE_VERSION=${VPKG}-prerelease.${RELEASE_TIMESTAMP} - npm --no-git-tag-version version $RELEASE_VERSION - if [[ "$TRAVIS_BRANCH" == hotfix/* ]]; then # double brackets are important for matching the wildcard - export NPM_TAG=hotfix - fi - git config --global user.email "$(git log --pretty=format:"%ae" -n1)" - git config --global user.name "$(git log --pretty=format:"%an" -n1)" - export BEFORE_DEPLOY_RAN=true - fi - deploy: - - provider: npm - on: - branch: - - master - - develop - - hotfix/* - condition: $TRAVIS_EVENT_TYPE != cron - skip_cleanup: true - email: $NPM_EMAIL - api_key: $NPM_TOKEN - tag: $NPM_TAG - - provider: script - on: - branch: - - master - - develop - - hotfix/* - condition: $TRAVIS_EVENT_TYPE != cron - skip_cleanup: true - script: if npm info | grep -q $RELEASE_VERSION; then git tag $RELEASE_VERSION && git push https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git $RELEASE_VERSION; fi - - provider: script - on: - all_branches: true - condition: $TRAVIS_EVENT_TYPE != cron - skip_cleanup: true - script: npm run --silent deploy -- -x -r $GH_PAGES_REPO - - provider: script - on: - branch: develop - condition: $TRAVIS_EVENT_TYPE == cron - skip_cleanup: true - script: npm run i18n:src && npm run i18n:push -stages: -- test -- name: release - if: type != pull_request AND (branch =~ /^(develop|master|hotfix\/)/) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2885f98e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2621 @@ +We don't use this file. Please see the git commit history. + + diff --git a/COPYING b/COPYING deleted file mode 100644 index f288702d..00000000 --- a/COPYING +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/COPYING.LESSER b/COPYING.LESSER deleted file mode 100644 index 0a041280..00000000 --- a/COPYING.LESSER +++ /dev/null @@ -1,165 +0,0 @@ - GNU LESSER GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - This version of the GNU Lesser General Public License incorporates -the terms and conditions of version 3 of the GNU General Public -License, supplemented by the additional permissions listed below. - - 0. Additional Definitions. - - As used herein, "this License" refers to version 3 of the GNU Lesser -General Public License, and the "GNU GPL" refers to version 3 of the GNU -General Public License. - - "The Library" refers to a covered work governed by this License, -other than an Application or a Combined Work as defined below. - - An "Application" is any work that makes use of an interface provided -by the Library, but which is not otherwise based on the Library. -Defining a subclass of a class defined by the Library is deemed a mode -of using an interface provided by the Library. - - A "Combined Work" is a work produced by combining or linking an -Application with the Library. The particular version of the Library -with which the Combined Work was made is also called the "Linked -Version". - - The "Minimal Corresponding Source" for a Combined Work means the -Corresponding Source for the Combined Work, excluding any source code -for portions of the Combined Work that, considered in isolation, are -based on the Application, and not on the Linked Version. - - The "Corresponding Application Code" for a Combined Work means the -object code and/or source code for the Application, including any data -and utility programs needed for reproducing the Combined Work from the -Application, but excluding the System Libraries of the Combined Work. - - 1. Exception to Section 3 of the GNU GPL. - - You may convey a covered work under sections 3 and 4 of this License -without being bound by section 3 of the GNU GPL. - - 2. Conveying Modified Versions. - - If you modify a copy of the Library, and, in your modifications, a -facility refers to a function or data to be supplied by an Application -that uses the facility (other than as an argument passed when the -facility is invoked), then you may convey a copy of the modified -version: - - a) under this License, provided that you make a good faith effort to - ensure that, in the event an Application does not supply the - function or data, the facility still operates, and performs - whatever part of its purpose remains meaningful, or - - b) under the GNU GPL, with none of the additional permissions of - this License applicable to that copy. - - 3. Object Code Incorporating Material from Library Header Files. - - The object code form of an Application may incorporate material from -a header file that is part of the Library. You may convey such object -code under terms of your choice, provided that, if the incorporated -material is not limited to numerical parameters, data structure -layouts and accessors, or small macros, inline functions and templates -(ten or fewer lines in length), you do both of the following: - - a) Give prominent notice with each copy of the object code that the - Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the object code with a copy of the GNU GPL and this license - document. - - 4. Combined Works. - - You may convey a Combined Work under terms of your choice that, -taken together, effectively do not restrict modification of the -portions of the Library contained in the Combined Work and reverse -engineering for debugging such modifications, if you also do each of -the following: - - a) Give prominent notice with each copy of the Combined Work that - the Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the Combined Work with a copy of the GNU GPL and this license - document. - - c) For a Combined Work that displays copyright notices during - execution, include the copyright notice for the Library among - these notices, as well as a reference directing the user to the - copies of the GNU GPL and this license document. - - d) Do one of the following: - - 0) Convey the Minimal Corresponding Source under the terms of this - License, and the Corresponding Application Code in a form - suitable for, and under terms that permit, the user to - recombine or relink the Application with a modified version of - the Linked Version to produce a modified Combined Work, in the - manner specified by section 6 of the GNU GPL for conveying - Corresponding Source. - - 1) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (a) uses at run time - a copy of the Library already present on the user's computer - system, and (b) will operate properly with a modified version - of the Library that is interface-compatible with the Linked - Version. - - e) Provide Installation Information, but only if you would otherwise - be required to provide such information under section 6 of the - GNU GPL, and only to the extent that such information is - necessary to install and execute a modified version of the - Combined Work produced by recombining or relinking the - Application with a modified version of the Linked Version. (If - you use option 4d0, the Installation Information must accompany - the Minimal Corresponding Source and Corresponding Application - Code. If you use option 4d1, you must provide the Installation - Information in the manner specified by section 6 of the GNU GPL - for conveying Corresponding Source.) - - 5. Combined Libraries. - - You may place library facilities that are a work based on the -Library side by side in a single library together with other library -facilities that are not Applications and are not covered by this -License, and convey such a combined library under terms of your -choice, if you do both of the following: - - a) Accompany the combined library with a copy of the same work based - on the Library, uncombined with any other library facilities, - conveyed under the terms of this License. - - b) Give prominent notice with the combined library that part of it - is a work based on the Library, and explaining where to find the - accompanying uncombined form of the same work. - - 6. Revised Versions of the GNU Lesser General Public License. - - The Free Software Foundation may publish revised and/or new versions -of the GNU Lesser General Public License from time to time. Such new -versions will be similar in spirit to the present version, but may -differ in detail to address new problems or concerns. - - Each version is given a distinguishing version number. If the -Library as you received it specifies that a certain numbered version -of the GNU Lesser General Public License "or any later version" -applies to it, you have the option of following the terms and -conditions either of that published version or of any later version -published by the Free Software Foundation. If the Library as you -received it does not specify a version number of the GNU Lesser -General Public License, you may choose any version of the GNU Lesser -General Public License ever published by the Free Software Foundation. - - If the Library as you received it specifies that a proxy can decide -whether future versions of the GNU Lesser General Public License shall -apply, that proxy's public statement of acceptance of any version is -permanent authorization for you to choose that version for the -Library. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..14e2f777 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index d78b6b51..ed220ad9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,115 @@ ##### This repo is forked from [LLK/scratch-vm](https://github.com/LLK/scratch-vm/) and merged with [TurboWarp/scratch-vm ](https://github.com/TurboWarp/scratch-vm) +## scratch-vm +#### Scratch VM is a library for representing, running, and maintaining the state of computer programs written using [Scratch Blocks](https://github.com/scratchfoundation/scratch-blocks). +## Installation +This requires you to have Git and Node.js installed. +To set up a development environment to edit scratch-vm yourself: +```bash +git clone https://github.com/scratchfoundation/scratch-vm.git +cd scratch-vm +npm install +``` +## Development Server +This requires Node.js to be installed. +For convenience, we've included a development server with the VM. This is sometimes useful when running in an environment that's loading remote resources (e.g., SVGs from the Scratch server). If you would like to use your modified VM with the full Scratch 3.0 GUI, [follow the instructions to link the VM to the GUI](https://github.com/scratchfoundation/scratch-gui/wiki/Getting-Started). + +## Running the Development Server +Open a Command Prompt or Terminal in the repository and run: +```bash +npm start +``` + +## Playground +To view the Playground, make sure the dev server's running and go to [http://localhost:8073/playground/](http://localhost:8073/playground/) - you will be directed to the playground, which demonstrates various tools and internal state. + +![VM Playground Screenshot](https://i.imgur.com/nOCNqEc.gif) + + +## Standalone Build +```bash +npm run build +``` + +```html + + +``` + +## How to include in a Node.js App +For an extended setup example, check out the /src/playground directory, which includes a fully running VM instance. +```js +var VirtualMachine = require('scratch-vm'); +var vm = new VirtualMachine(); + +// Block events +Scratch.workspace.addChangeListener(vm.blockListener); + +// Run threads +vm.start(); +``` + +## Abstract Syntax Tree + +#### Overview +The Virtual Machine constructs and maintains the state of an [Abstract Syntax Tree](https://en.wikipedia.org/wiki/Abstract_syntax_tree) (AST) by listening to events emitted by the [scratch-blocks](https://github.com/scratchfoundation/scratch-blocks) workspace via the `blockListener`. Each target (code-running object, for example, a sprite) keeps an AST for its blocks. At any time, the current state of an AST can be viewed by inspecting the `vm.runtime.targets[...].blocks` object. + +#### Anatomy of a Block +The VM's block representation contains all the important information for execution and storage. Here's an example representing the "when key pressed" script on a workspace: +```json +{ + "_blocks": { + "Q]PK~yJ@BTV8Y~FfISeo": { + "id": "Q]PK~yJ@BTV8Y~FfISeo", + "opcode": "event_whenkeypressed", + "inputs": { + }, + "fields": { + "KEY_OPTION": { + "name": "KEY_OPTION", + "value": "space" + } + }, + "next": null, + "topLevel": true, + "parent": null, + "shadow": false, + "x": -69.333333333333, + "y": 174 + } + }, + "_scripts": [ + "Q]PK~yJ@BTV8Y~FfISeo" + ] +} +``` + +## Testing +```bash +npm test +``` + +```bash +npm run coverage +``` + +## Publishing to GitHub Pages +```bash +npm run deploy +``` + +This will push the currently built playground to the gh-pages branch of the +currently tracked remote. If you would like to change where to push to, add +a repo url argument: +```bash +npm run deploy -- -r +``` + +## Donate +We provide [Scratch](https://scratch.mit.edu) free of charge, and want to keep it that way! Please consider making a [donation](https://secure.donationpay.org/scratchfoundation/) to support our continued engineering, design, community, and resource development efforts. Donations of any size are appreciated. Thank you! diff --git a/docs/extensions.md b/docs/extensions.md index 602398c0..f047caea 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -48,7 +48,7 @@ ways. ## Defining an Extension Scratch extensions are defined as a single Javascript class which accepts either a reference to the Scratch -[VM](https://github.com/llk/scratch-vm) runtime or a "runtime proxy" which handles communication with the Scratch VM +[VM](https://github.com/scratchfoundation/scratch-vm) runtime or a "runtime proxy" which handles communication with the Scratch VM across a well defined worker boundary (i.e. the sandbox). ```js diff --git a/package.json b/package.json index 2de06b89..b2af9fa4 100644 --- a/package.json +++ b/package.json @@ -4,32 +4,40 @@ "description": "Virtual Machine for Scratch 3.0 merged tw-vm", "author": "Massachusetts Institute of Technology", "license": "LGPL-3.0-only", - "homepage": "https://github.com/LLK/scratch-vm#readme", + "homepage": "https://github.com/Gandi-IDE/scratch-vm#readme", "repository": { "type": "git", - "url": "git+ssh://git@github.com/LLK/scratch-vm.git", - "sha": "8a60efb93cf8ba7f0ea8c6bc8a549961d631d40b" + "url": "https://github.com/Gandi-IDE/scratch-vm.git", + "sha": "53074e31f5d657476353addfb2b6331d71194c3b" }, "main": "./src/index.js", "browser": "./src/index.js", "scripts": { - "build": "npm run docs && webpack --progress --colors --bail", + "build": "webpack --progress --colors --bail", "coverage": "tap ./test/{unit,integration}/*.js --coverage --coverage-report=lcov", - "deploy": "touch playground/.nojekyll && gh-pages -t -d playground -m \"Build for $(git log --pretty=format:%H -n1)\"", - "docs": "exit 0 || jsdoc -c .jsdoc.json", + "docs": "jsdoc -c .jsdoc.json", "i18n:src": "mkdirp translations/core && format-message extract --out-file translations/core/en.json src/extensions/**/index.js", "i18n:push": "tx-push-src scratch-editor extensions translations/core/en.json", - "lint": "exit 0 || eslint . && format-message lint src/**/*.js", + "lint": "eslint .", "prepublish": "in-publish && npm run build || not-in-publish", "start": "webpack-dev-server", - "tap:all": "tap ./test/{unit,integration}/*.js", + "tap:all": "tap --no-timeout ./test/{unit,integration}/*.js", "tap:unit": "tap ./test/unit/*.js", "tap:integration": "tap ./test/integration/*.js", - "test": "npm run lint && npm run docs && npm run tap:all", + "test": "npm run lint && npm run tap", "watch": "webpack --progress --colors --watch", "version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"" }, + "tap": { + "branches": 60, + "functions": 70, + "lines": 70, + "statements": 70 + }, "dependencies": { + "@turbowarp/json": "^0.1.2", + "@turbowarp/jszip": "^3.11.0", + "@turbowarp/nanolog": "^0.2.0", "@vernier/godirect": "1.5.0", "arraybuffer-loader": "^1.0.6", "atob": "2.1.2", @@ -46,46 +54,47 @@ "scratch-sb1-converter": "0.2.7", "scratch-translate-extension-languages": "0.0.20191118205314", "text-encoding": "0.7.0", + "uuid": "8.3.2", "worker-loader": "^1.1.1" }, "peerDependencies": { - "scratch-svg-renderer": "^0.2.0-prerelease" + "@turbowarp/scratch-svg-renderer": "^1.0.0-202312300007-62fe825" }, "devDependencies": { "@babel/core": "7.13.10", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-transform-runtime": "^7.19.6", "@babel/preset-env": "7.14.8", + "@turbowarp/scratch-svg-renderer": "^1.0.202407162159", "adm-zip": "0.4.11", "babel-eslint": "10.1.0", "babel-loader": "8.2.2", "callsite": "1.0.0", "copy-webpack-plugin": "4.5.4", "docdash": "1.2.0", - "eslint": "5.3.0", - "eslint-config-scratch": "5.1.0", + "eslint": "8.55.0", + "eslint-config-scratch": "9.0.3", "expose-loader": "0.7.5", "file-loader": "2.0.0", "format-message-cli": "6.2.0", - "gh-pages": "1.2.0", "in-publish": "2.0.1", + "js-md5": "0.7.3", "jsdoc": "3.6.6", "json": "^9.0.4", "lodash.defaultsdeep": "4.6.1", "pngjs": "3.3.3", - "scratch-audio": "0.1.0-prerelease.20200528195344", - "scratch-blocks": "0.1.0-prerelease.20211110095305", - "scratch-l10n": "3.14.20211125031554", - "scratch-render": "0.1.0-prerelease.20211028200436", - "scratch-render-fonts": "1.0.0-prerelease.20210401210003", - "scratch-storage": "^2.1.0", - "scratch-svg-renderer": "0.2.0-prerelease.20210727023023", + "scratch-audio": "0.1.0-prerelease.20231221012053", + "scratch-blocks": "0.1.0-prerelease.20230527085947", + "scratch-l10n": "3.16.20231222031921", + "scratch-render": "0.1.0-prerelease.20231220210403", + "scratch-render-fonts": "1.0.0-prerelease.20231017225105", + "scratch-storage": "^2.3.205", "script-loader": "0.7.2", "stats.js": "0.17.0", - "tap": "12.0.1", + "tap": "16.2.0", "tiny-worker": "2.3.0", "uglifyjs-webpack-plugin": "1.2.7", - "webpack": "4.46.0", + "webpack": "4.47.0", "webpack-cli": "3.1.0", "webpack-dev-server": "3.11.2" } diff --git a/release.config.js b/release.config.js new file mode 100644 index 00000000..87af40a0 --- /dev/null +++ b/release.config.js @@ -0,0 +1,13 @@ +module.exports = { + extends: 'scratch-semantic-release-config', + branches: [ + { + name: 'develop' + // default channel + }, + { + name: 'hotfix/*', + channel: 'hotfix' + } + ] +}; diff --git a/src/blocks/gandi_core_example.js b/src/blocks/gandi_core_example.js new file mode 100644 index 00000000..492a0fc0 --- /dev/null +++ b/src/blocks/gandi_core_example.js @@ -0,0 +1,256 @@ +const BlockType = require('../extension-support/block-type'); +const ArgumentType = require('../extension-support/argument-type'); + +/* eslint-disable-next-line max-len */ +const blockIconURI = 'data:image/svg+xml,%3Csvg id="rotate-counter-clockwise" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%233d79cc;%7D.cls-2%7Bfill:%23fff;%7D%3C/style%3E%3C/defs%3E%3Ctitle%3Erotate-counter-clockwise%3C/title%3E%3Cpath class="cls-1" d="M22.68,12.2a1.6,1.6,0,0,1-1.27.63H13.72a1.59,1.59,0,0,1-1.16-2.58l1.12-1.41a4.82,4.82,0,0,0-3.14-.77,4.31,4.31,0,0,0-2,.8,4.25,4.25,0,0,0-1.34,1.73,5.06,5.06,0,0,0,.54,4.62A5.58,5.58,0,0,0,12,17.74h0a2.26,2.26,0,0,1-.16,4.52A10.25,10.25,0,0,1,3.74,18,10.14,10.14,0,0,1,2.25,8.78,9.7,9.7,0,0,1,5.08,4.64,9.92,9.92,0,0,1,9.66,2.5a10.66,10.66,0,0,1,7.72,1.68l1.08-1.35a1.57,1.57,0,0,1,1.24-.6,1.6,1.6,0,0,1,1.54,1.21l1.7,7.37A1.57,1.57,0,0,1,22.68,12.2Z"/%3E%3Cpath class="cls-2" d="M21.38,11.83H13.77a.59.59,0,0,1-.43-1l1.75-2.19a5.9,5.9,0,0,0-4.7-1.58,5.07,5.07,0,0,0-4.11,3.17A6,6,0,0,0,7,15.77a6.51,6.51,0,0,0,5,2.92,1.31,1.31,0,0,1-.08,2.62,9.3,9.3,0,0,1-7.35-3.82A9.16,9.16,0,0,1,3.17,9.12,8.51,8.51,0,0,1,5.71,5.4,8.76,8.76,0,0,1,9.82,3.48a9.71,9.71,0,0,1,7.75,2.07l1.67-2.1a.59.59,0,0,1,1,.21L22,11.08A.59.59,0,0,1,21.38,11.83Z"/%3E%3C/svg%3E'; + +/** + * An example core block implemented using the extension spec. + * This is not loaded as part of the core blocks in the VM but it is provided + * and used as part of tests. + */ +class Scratch3CoreExample { + + constructor(runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + this.NS = 'coreExample'; + } + handleCCWHat (args, util) { + console.log('handleCCWHat', args); + return true; + } + triggerCCWHat (args, util) { + console.log('triggerCCWHat', args); + util.startHatsWithParams('coreExample_handleCCWHat', {parameters: {Msg: args.Msg}, fields: {Data: args.Data}}); + + } + + + /** + * @returns {object} metadata for this extension and its blocks. + */ + getInfo () { + const handleCCWHat = { + opcode: 'handleCCWHat', + text: 'ccw hat with [Data] [Msg]', + blockType: BlockType.HAT, + isEdgeActivated: false, + arguments: { + Data: { + type: ArgumentType.STRING, + menu: 'hatMenu' + }, + Msg: { + type: 'ccw_hat_parameter' + } + } + }; + + const triggerCCWHat = { + opcode: 'triggerCCWHat', + text: 'triggerCCWHat [Data] [Msg]', + blockType: BlockType.COMMAND, + arguments: { + Data: { + type: ArgumentType.STRING, + menu: 'hatMenu' + }, + Msg: { + type: ArgumentType.STRING, + defaultValue: 'key' + } + } + }; + + const makeVarBtn = { + func: 'MAKE_A_VARIABLE', + blockType: BlockType.BUTTON, + text: 'make a variable (CoreEx)' + }; + + const exampleOpcode = { + opcode: 'exampleOpcode', + blockType: BlockType.REPORTER, + text: 'example block' + }; + + const exampleWithInlineImage = { + opcode: 'exampleWithInlineImage', + blockType: BlockType.COMMAND, + text: 'block with image [CLOCKWISE] inline', + arguments: { + CLOCKWISE: { + type: ArgumentType.IMAGE, + dataURI: blockIconURI + } + } + }; + + const dynamicBlock = { + opcode: 'dynamicBlock', + blockType: BlockType.REPORTER, + text: 'dynamic Block [B]', + isDynamic: true, + arguments: { + // A: { + // type: ArgumentType.STRING, + // defaultValue: '1', + // dynamicArguments: { + // hideAddButton: false, + // hideDeleteButton: false, + // seperator: ',', + // defaultValues: '1' + // } + // type: ArgumentType.STRING, + // defaultValue: '1' + // }, + B: { + type: ArgumentType.STRING, + menu: 'dynamicMenu' + } + } + }; + + const staticBlock = { + opcode: 'staticBlockOp', + blockType: BlockType.REPORTER, + text: 'staticBlock' + }; + + const arrayBuilderBlock = { + opcode: 'arrayBuilderBlock', + blockType: BlockType.REPORTER, + text: 'arrayBuilderBlock [A] [B]', + arguments: { + A: { + type: ArgumentType.ARRAY, + mutation: { + items: 2, + movable: false, + hideAddButton: true + } + }, + B: { + type: ArgumentType.ARRAY, + mutation: { + items: 2, + movable: false, + hideAddButton: true + } + } + } + }; + + const menuBlock = { + opcode: 'menuBlock', + blockType: BlockType.COMMAND, + text: 'menuBlock [DATA]', + arguments: { + DATA: { + type: ArgumentType.STRING, + menu: 'dynamicMenu' + } + } + }; + + const button = { + blockType: 'button', + text: 'updateExtension', + onClick: this.updateExtension.bind(this) + }; + + return { + id: this.NS, + name: 'CoreEx', // This string does not need to be translated as this extension is only used as an example. + blocks: [ + // button, + // arrayBuilderBlock, + // triggerCCWHat, + // handleCCWHat, + // makeVarBtn, + // exampleOpcode, + // exampleWithInlineImage, + dynamicBlock, + // staticBlock, + // menuBlock + ], + menus: { + // hatMenu: [ + // {text: '*', value: '*'}, + // {text: 'a', value: 'a'}, + // {text: 'b', value: 'b'} + // ] + dynamicMenu: {items: 'buildDynamicMenu'} + } + }; + } + + /** + * Example opcode just returns the name of the stage target. + * @returns {string} The name of the first target in the project. + */ + exampleOpcode () { + const stage = this.runtime.getTargetForStage(); + return stage ? stage.getName() : 'no stage yet'; + } + + exampleWithInlineImage (args) { + return; + } + staticBlockOp (args) { + + } + + menuBlock (args) { + console.log('menuBlock', ...args); + } + + dynamicBlock (args) { + console.log('dynamic block', args); + return 'dynamic block'; + } + + buildDynamicMenu () { + return [{text: '1', value: '1'}]; + } + + arrayBuilderBlock (args) { + console.log('A :', args.A); + console.log('B :', args.B); + } + + updateExtension () { + const dynamicBlock = { + opcode: 'dynamicBlock', + blockType: BlockType.REPORTER, + text: 'dynamic Block [A][B][C]', + isDynamic: true, + disableMonitor: true, + arguments: { + A: { + type: ArgumentType.STRING, + defaultValue: '1' + }, + B: { + type: ArgumentType.STRING, + defaultValue: '2' + }, + C: { + type: ArgumentType.STRING, + defaultValue: '3' + } + } + }; + + const newInfo = this.getInfo(); + newInfo.blocks = newInfo.blocks.concat(dynamicBlock); + const categoryInfo = this.runtime._blockInfo.find(info => info.id === this.NS); + (categoryInfo ? this.runtime._refreshExtensionPrimitives : this.runtime._registerExtensionPrimitives).bind(this.runtime)(newInfo); + } +} + +module.exports = Scratch3CoreExample; diff --git a/src/blocks/scratch3_control.js b/src/blocks/scratch3_control.js index f7d0828b..ebf19515 100644 --- a/src/blocks/scratch3_control.js +++ b/src/blocks/scratch3_control.js @@ -12,7 +12,7 @@ class Scratch3ControlBlocks { * The "counter" block value. For compatibility with 2.0. * @type {number} */ - this._counter = 0; + this._counter = 0; // used by compiler this.runtime.on('RUNTIME_DISPOSED', this.clearCounter.bind(this)); } diff --git a/src/blocks/scratch3_core_example.js b/src/blocks/scratch3_core_example.js index 492a0fc0..4e63f1b0 100644 --- a/src/blocks/scratch3_core_example.js +++ b/src/blocks/scratch3_core_example.js @@ -10,182 +10,44 @@ const blockIconURI = 'data:image/svg+xml,%3Csvg id="rotate-counter-clockwise" xm * and used as part of tests. */ class Scratch3CoreExample { - - constructor(runtime) { + constructor (runtime) { /** * The runtime instantiating this block package. * @type {Runtime} */ this.runtime = runtime; - this.NS = 'coreExample'; - } - handleCCWHat (args, util) { - console.log('handleCCWHat', args); - return true; - } - triggerCCWHat (args, util) { - console.log('triggerCCWHat', args); - util.startHatsWithParams('coreExample_handleCCWHat', {parameters: {Msg: args.Msg}, fields: {Data: args.Data}}); - } - /** * @returns {object} metadata for this extension and its blocks. */ getInfo () { - const handleCCWHat = { - opcode: 'handleCCWHat', - text: 'ccw hat with [Data] [Msg]', - blockType: BlockType.HAT, - isEdgeActivated: false, - arguments: { - Data: { - type: ArgumentType.STRING, - menu: 'hatMenu' - }, - Msg: { - type: 'ccw_hat_parameter' - } - } - }; - - const triggerCCWHat = { - opcode: 'triggerCCWHat', - text: 'triggerCCWHat [Data] [Msg]', - blockType: BlockType.COMMAND, - arguments: { - Data: { - type: ArgumentType.STRING, - menu: 'hatMenu' + return { + id: 'coreExample', + name: 'CoreEx', // This string does not need to be translated as this extension is only used as an example. + blocks: [ + { + func: 'MAKE_A_VARIABLE', + blockType: BlockType.BUTTON, + text: 'make a variable (CoreEx)' }, - Msg: { - type: ArgumentType.STRING, - defaultValue: 'key' - } - } - }; - - const makeVarBtn = { - func: 'MAKE_A_VARIABLE', - blockType: BlockType.BUTTON, - text: 'make a variable (CoreEx)' - }; - - const exampleOpcode = { - opcode: 'exampleOpcode', - blockType: BlockType.REPORTER, - text: 'example block' - }; - - const exampleWithInlineImage = { - opcode: 'exampleWithInlineImage', - blockType: BlockType.COMMAND, - text: 'block with image [CLOCKWISE] inline', - arguments: { - CLOCKWISE: { - type: ArgumentType.IMAGE, - dataURI: blockIconURI - } - } - }; - - const dynamicBlock = { - opcode: 'dynamicBlock', - blockType: BlockType.REPORTER, - text: 'dynamic Block [B]', - isDynamic: true, - arguments: { - // A: { - // type: ArgumentType.STRING, - // defaultValue: '1', - // dynamicArguments: { - // hideAddButton: false, - // hideDeleteButton: false, - // seperator: ',', - // defaultValues: '1' - // } - // type: ArgumentType.STRING, - // defaultValue: '1' - // }, - B: { - type: ArgumentType.STRING, - menu: 'dynamicMenu' - } - } - }; - - const staticBlock = { - opcode: 'staticBlockOp', - blockType: BlockType.REPORTER, - text: 'staticBlock' - }; - - const arrayBuilderBlock = { - opcode: 'arrayBuilderBlock', - blockType: BlockType.REPORTER, - text: 'arrayBuilderBlock [A] [B]', - arguments: { - A: { - type: ArgumentType.ARRAY, - mutation: { - items: 2, - movable: false, - hideAddButton: true - } + { + opcode: 'exampleOpcode', + blockType: BlockType.REPORTER, + text: 'example block' }, - B: { - type: ArgumentType.ARRAY, - mutation: { - items: 2, - movable: false, - hideAddButton: true + { + opcode: 'exampleWithInlineImage', + blockType: BlockType.COMMAND, + text: 'block with image [CLOCKWISE] inline', + arguments: { + CLOCKWISE: { + type: ArgumentType.IMAGE, + dataURI: blockIconURI + } } } - } - }; - - const menuBlock = { - opcode: 'menuBlock', - blockType: BlockType.COMMAND, - text: 'menuBlock [DATA]', - arguments: { - DATA: { - type: ArgumentType.STRING, - menu: 'dynamicMenu' - } - } - }; - - const button = { - blockType: 'button', - text: 'updateExtension', - onClick: this.updateExtension.bind(this) - }; - - return { - id: this.NS, - name: 'CoreEx', // This string does not need to be translated as this extension is only used as an example. - blocks: [ - // button, - // arrayBuilderBlock, - // triggerCCWHat, - // handleCCWHat, - // makeVarBtn, - // exampleOpcode, - // exampleWithInlineImage, - dynamicBlock, - // staticBlock, - // menuBlock - ], - menus: { - // hatMenu: [ - // {text: '*', value: '*'}, - // {text: 'a', value: 'a'}, - // {text: 'b', value: 'b'} - // ] - dynamicMenu: {items: 'buildDynamicMenu'} - } + ] }; } @@ -198,59 +60,10 @@ class Scratch3CoreExample { return stage ? stage.getName() : 'no stage yet'; } - exampleWithInlineImage (args) { + exampleWithInlineImage () { return; } - staticBlockOp (args) { - } - - menuBlock (args) { - console.log('menuBlock', ...args); - } - - dynamicBlock (args) { - console.log('dynamic block', args); - return 'dynamic block'; - } - - buildDynamicMenu () { - return [{text: '1', value: '1'}]; - } - - arrayBuilderBlock (args) { - console.log('A :', args.A); - console.log('B :', args.B); - } - - updateExtension () { - const dynamicBlock = { - opcode: 'dynamicBlock', - blockType: BlockType.REPORTER, - text: 'dynamic Block [A][B][C]', - isDynamic: true, - disableMonitor: true, - arguments: { - A: { - type: ArgumentType.STRING, - defaultValue: '1' - }, - B: { - type: ArgumentType.STRING, - defaultValue: '2' - }, - C: { - type: ArgumentType.STRING, - defaultValue: '3' - } - } - }; - - const newInfo = this.getInfo(); - newInfo.blocks = newInfo.blocks.concat(dynamicBlock); - const categoryInfo = this.runtime._blockInfo.find(info => info.id === this.NS); - (categoryInfo ? this.runtime._refreshExtensionPrimitives : this.runtime._registerExtensionPrimitives).bind(this.runtime)(newInfo); - } } module.exports = Scratch3CoreExample; diff --git a/src/blocks/scratch3_looks.js b/src/blocks/scratch3_looks.js index dc8fb743..60e922e0 100644 --- a/src/blocks/scratch3_looks.js +++ b/src/blocks/scratch3_looks.js @@ -1,6 +1,5 @@ const Cast = require('../util/cast'); const Clone = require('../util/clone'); -const RenderedTarget = require('../sprites/rendered-target'); const uid = require('../util/uid'); const StageLayering = require('../engine/stage-layering'); const getMonitorIdForBlockWithArgs = require('../util/get-monitor-id'); @@ -134,7 +133,7 @@ class Scratch3LooksBlocks { bubbleState.skinId = null; this.runtime.requestRedraw(); } - target.removeListener(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this._onTargetChanged); + target.onTargetVisualChange = null; } /** @@ -230,7 +229,7 @@ class Scratch3LooksBlocks { if (bubbleState.skinId) { this.runtime.renderer.updateTextSkin(bubbleState.skinId, type, text, onSpriteRight, [0, 0]); } else { - target.addListener(RenderedTarget.EVENT_TARGET_VISUAL_CHANGE, this._onTargetChanged); + target.onTargetVisualChange = this._onTargetChanged; bubbleState.drawableId = this.runtime.renderer.createDrawable(StageLayering.SPRITE_LAYER); bubbleState.skinId = this.runtime.renderer.createTextSkin(type, text, bubbleState.onSpriteRight, [0, 0]); this.runtime.renderer.updateDrawableSkinId(bubbleState.drawableId, bubbleState.skinId); @@ -250,7 +249,7 @@ class Scratch3LooksBlocks { // Non-integers should be rounded to 2 decimal places (no more, no less), unless they're small enough that // rounding would display them as 0.00. This matches 2.0's behavior: - // https://github.com/LLK/scratch-flash/blob/2e4a402ceb205a042887f54b26eebe1c2e6da6c0/src/scratch/ScratchSprite.as#L579-L585 + // https://github.com/scratchfoundation/scratch-flash/blob/2e4a402ceb205a042887f54b26eebe1c2e6da6c0/src/scratch/ScratchSprite.as#L579-L585 if (typeof text === 'number' && Math.abs(text) >= 0.01 && text % 1 !== 0) { text = text.toFixed(2); @@ -544,7 +543,7 @@ class Scratch3LooksBlocks { changeEffect (args, util) { const effect = Cast.toString(args.EFFECT).toLowerCase(); const change = Cast.toNumber(args.CHANGE); - if (!util.target.effects.hasOwnProperty(effect)) return; + if (!Object.prototype.hasOwnProperty.call(util.target.effects, effect)) return; let newValue = change + util.target.effects[effect]; newValue = this.clampEffect(effect, newValue); util.target.setEffect(effect, newValue); diff --git a/src/blocks/scratch3_operators.js b/src/blocks/scratch3_operators.js index a02b2f79..a2a5ab4b 100644 --- a/src/blocks/scratch3_operators.js +++ b/src/blocks/scratch3_operators.js @@ -139,8 +139,8 @@ class Scratch3OperatorsBlocks { case 'floor': return Math.floor(n); case 'ceiling': return Math.ceil(n); case 'sqrt': return Math.sqrt(n); - case 'sin': return parseFloat(Math.sin((Math.PI * n) / 180).toFixed(10)); - case 'cos': return parseFloat(Math.cos((Math.PI * n) / 180).toFixed(10)); + case 'sin': return Math.round(Math.sin((Math.PI * n) / 180) * 1e10) / 1e10; + case 'cos': return Math.round(Math.cos((Math.PI * n) / 180) * 1e10) / 1e10; case 'tan': return MathUtil.tan(n); case 'asin': return (Math.asin(n) * 180) / Math.PI; case 'acos': return (Math.acos(n) * 180) / Math.PI; diff --git a/src/blocks/scratch3_procedures.js b/src/blocks/scratch3_procedures.js index 37e6554e..07c016ee 100644 --- a/src/blocks/scratch3_procedures.js +++ b/src/blocks/scratch3_procedures.js @@ -16,15 +16,14 @@ class Scratch3ProcedureBlocks { procedures_definition: this.definition, procedures_call: this.call, procedures_call_with_return: this.callWithReturn, + procedures_return: this.return, argument_reporter_string_number: this.argumentReporterStringNumber, argument_reporter_boolean: this.argumentReporterBoolean, - // CCW customize - procedures_return: this.proceduresReturn, ccw_hat_parameter: this.ccwHatParameter }; } - proceduresReturn (args, util) { + return (args, util) { util.stopThisScript(); // If used outside of a custom block, there may be no stack frame. if (util.thread.peekStackFrame()) { @@ -36,7 +35,7 @@ class Scratch3ProcedureBlocks { // No-op: execute the blocks. } - _callProcedure (args, util) { + _callProcedure (args, util, isReporter = false) { const procedureCode = args.mutation.proccode; const isGlobal = args.mutation.isglobal === 'true'; let paramNamesIdsAndDefaults; @@ -54,7 +53,9 @@ class Scratch3ProcedureBlocks { // block is dragged between sprites without the definition. // Match Scratch 2.0 behavior and noop. if (paramNamesIdsAndDefaults === null) { - util.stackFrame.executed = true; + if (isReporter) { + return ''; + } return; } @@ -65,13 +66,27 @@ class Scratch3ProcedureBlocks { // at earlier stack frames for the values of a given parameter (#1729) util.initParams(); for (let i = 0; i < paramIds.length; i++) { - if (args.hasOwnProperty(paramIds[i])) { + if (Object.prototype.hasOwnProperty.call(args, paramIds[i])) { util.pushParam(paramNames[i], args[paramIds[i]]); } else { util.pushParam(paramNames[i], paramDefaults[i]); } } + const addonBlock = util.runtime.getAddonBlock(procedureCode); + if (addonBlock) { + const result = addonBlock.callback(util.thread.getAllparams(), util); + if (util.thread.status === 1 /* STATUS_PROMISE_WAIT */) { + // If the addon block is using STATUS_PROMISE_WAIT to force us to sleep, + // make sure to not re-run this block when we resume. + util.stackFrame.executed = true; + } + return result; + } util.stackFrame.executed = true; + if (isReporter) { + util.thread.peekStackFrame().waitingReporter = true; + util.stackFrame.returnValue = ''; // default return value + } // CCW: pass global target to procedure if isGlobal === true util.startProcedure(procedureCode, globalTarget); } @@ -95,14 +110,12 @@ class Scratch3ProcedureBlocks { delete stackFrame.executed; return returnValue; } - util.thread.peekStackFrame().waitingReporter = true; - util.stackFrame.returnValue = ''; // default return value - this._callProcedure(args, util); + return this._callProcedure(args, util, true); } call (args, util) { if (!util.stackFrame.executed) { - this._callProcedure(args, util); + return this._callProcedure(args, util, false); } } diff --git a/src/blocks/scratch3_sensing.js b/src/blocks/scratch3_sensing.js index 72035379..7794bfcd 100644 --- a/src/blocks/scratch3_sensing.js +++ b/src/blocks/scratch3_sensing.js @@ -81,12 +81,24 @@ class Scratch3SensingBlocks { sensing_answer: { getId: () => 'answer' }, + sensing_mousedown: { + getId: () => 'mousedown' + }, + sensing_mousex: { + getId: () => 'mousex' + }, + sensing_mousey: { + getId: () => 'mousey' + }, sensing_loudness: { getId: () => 'loudness' }, sensing_timer: { getId: () => 'timer' }, + sensing_dayssince2000: { + getId: () => 'dayssince2000' + }, sensing_current: { // This is different from the default toolbox xml id in order to support // importing multiple monitors from the same opcode from sb2 files, diff --git a/src/blocks/scratch3_sound.js b/src/blocks/scratch3_sound.js index 8e8c7465..544f69a2 100644 --- a/src/blocks/scratch3_sound.js +++ b/src/blocks/scratch3_sound.js @@ -90,6 +90,17 @@ class Scratch3SoundBlocks { }; } + /** The minimum and maximum values for sound effects when miscellaneous limits are removed. */ + static get LARGER_EFFECT_RANGE () { + return { + // scratch-audio throws if pitch is too big because some math results in Infinity + pitch: {min: -1000, max: 1000}, + + // No reason for these to go beyond 100 + pan: {min: -100, max: 100} + }; + } + /** * @param {Target} target - collect sound state for this target. * @returns {SoundState} the mutable sound state associated with that target. This will be created if necessary. @@ -267,7 +278,7 @@ class Scratch3SoundBlocks { const value = Cast.toNumber(args.VALUE); const soundState = this._getSoundState(util.target); - if (!soundState.effects.hasOwnProperty(effect)) return; + if (!Object.prototype.hasOwnProperty.call(soundState.effects, effect)) return; if (change) { soundState.effects[effect] += value; @@ -276,16 +287,19 @@ class Scratch3SoundBlocks { } const miscLimits = this.runtime.runtimeOptions.miscLimits; - if (miscLimits) { - const {min, max} = Scratch3SoundBlocks.EFFECT_RANGE[effect]; - soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], min, max); - } + const {min, max} = miscLimits ? + Scratch3SoundBlocks.EFFECT_RANGE[effect] : + Scratch3SoundBlocks.LARGER_EFFECT_RANGE[effect]; + soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], min, max); this._syncEffectsForTarget(util.target); if (miscLimits) { // Yield until the next tick. return Promise.resolve(); } + + // Requesting a redraw makes sure that "forever: change pitch by 1" still work but without + // yielding unnecessarily in other cases this.runtime.requestRedraw(); } @@ -303,7 +317,7 @@ class Scratch3SoundBlocks { _clearEffectsForTarget (target) { const soundState = this._getSoundState(target); for (const effect in soundState.effects) { - if (!soundState.effects.hasOwnProperty(effect)) continue; + if (!Object.prototype.hasOwnProperty.call(soundState.effects, effect)) continue; soundState.effects[effect] = 0; } this._syncEffectsForTarget(target); diff --git a/src/cli/index.js b/src/cli/index.js index 525f01b5..51cdeb44 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -1,6 +1,7 @@ const fs = require('fs'); const VirtualMachine = require('../index'); +/* eslint-env node */ /* eslint-disable no-console */ const file = process.argv[2]; @@ -34,7 +35,7 @@ const runProject = async buffer => { }, 50); }); vm.stopAll(); - vm.stop(); + vm.quit(); }; runProject(fs.readFileSync(file)); diff --git a/src/compiler/compat-block-utility.js b/src/compiler/compat-block-utility.js index 4cd305c8..6fedba1d 100644 --- a/src/compiler/compat-block-utility.js +++ b/src/compiler/compat-block-utility.js @@ -1,26 +1,19 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - const BlockUtility = require('../engine/block-utility'); class CompatibilityLayerBlockUtility extends BlockUtility { - // Branching operations are not supported. - startBranch () { - throw new Error('startBranch is not supported by this BlockUtility'); + constructor () { + super(); + this._startedBranch = null; + } + + get stackFrame () { + return this.thread.compatibilityStackFrame; + } + + startBranch (branchNumber, isLoop) { + this._startedBranch = [branchNumber, isLoop]; } + startProcedure () { throw new Error('startProcedure is not supported by this BlockUtility'); } @@ -35,6 +28,14 @@ class CompatibilityLayerBlockUtility extends BlockUtility { getParam () { throw new Error('getParam is not supported by this BlockUtility'); } + + init (thread, fakeBlockId, stackFrame) { + this.thread = thread; + this.sequencer = thread.target.runtime.sequencer; + this._startedBranch = null; + thread.stack[0] = fakeBlockId; + thread.compatibilityStackFrame = stackFrame; + } } // Export a single instance to be reused. diff --git a/src/compiler/compat-blocks.js b/src/compiler/compat-blocks.js index 0c77b5d2..1c9d8f5a 100644 --- a/src/compiler/compat-blocks.js +++ b/src/compiler/compat-blocks.js @@ -1,19 +1,3 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - /** * @fileoverview List of blocks to be supported in the compiler compatibility layer. * This is only for native blocks. Extensions should not be listed here. @@ -22,19 +6,21 @@ // Please keep these lists alphabetical. const stacked = [ - 'control_clear_counter', - 'control_incr_counter', 'looks_changestretchby', + 'looks_hideallsprites', 'looks_say', 'looks_sayforsecs', 'looks_setstretchto', 'looks_switchbackdroptoandwait', 'looks_think', 'looks_thinkforsecs', + 'motion_align_scene', 'motion_glidesecstoxy', 'motion_glideto', 'motion_goto', 'motion_pointtowards', + 'motion_scroll_right', + 'motion_scroll_up', 'sensing_askandwait', 'sensing_setdragmode', 'sound_changeeffectby', @@ -48,9 +34,11 @@ const stacked = [ ]; const inputs = [ - 'control_get_counter', + 'motion_xscroll', + 'motion_yscroll', 'sensing_loud', 'sensing_loudness', + 'sensing_userid', 'sound_volume' ]; diff --git a/src/compiler/compile.js b/src/compiler/compile.js index 4193a211..b5edb813 100644 --- a/src/compiler/compile.js +++ b/src/compiler/compile.js @@ -1,20 +1,4 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - -const IRGenerator = require('./irgen'); +const {IRGenerator} = require('./irgen'); const JSGenerator = require('./jsgen'); const compile = thread => { @@ -45,7 +29,8 @@ const compile = thread => { return { startingFunction: entry, - procedures + procedures, + executableHat: ir.entry.executableHat }; }; diff --git a/src/compiler/environment.js b/src/compiler/environment.js index a1130496..75b300a6 100644 --- a/src/compiler/environment.js +++ b/src/compiler/environment.js @@ -1,19 +1,3 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - /* eslint-disable no-eval */ /** diff --git a/src/compiler/intermediate.js b/src/compiler/intermediate.js index 8c21840f..f2a09ff2 100644 --- a/src/compiler/intermediate.js +++ b/src/compiler/intermediate.js @@ -1,19 +1,3 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - /** * @fileoverview Common intermediates shared amongst parts of the compiler. */ @@ -43,7 +27,13 @@ class IntermediateScript { this.isProcedure = false; /** - * The name of this procedure, if any. + * This procedure's variant, if any. + * @type {string} + */ + this.procedureVariant = ''; + + /** + * This procedure's code, if any. * @type {string} */ this.procedureCode = ''; @@ -86,9 +76,16 @@ class IntermediateScript { */ this.cachedCompileResult = null; - // CCW: for global procedure compilation - // global procedure target + /** + * global procedure target + * @type {Target|null} + */ this.target = null; + /** + * Whether the top block of this script is an executable hat. + * @type {boolean} + */ + this.executableHat = false; } } diff --git a/src/compiler/irgen.js b/src/compiler/irgen.js index ae526a73..f81e9fa2 100644 --- a/src/compiler/irgen.js +++ b/src/compiler/irgen.js @@ -1,19 +1,3 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - const Cast = require('../util/cast'); const StringUtil = require('../util/string-util'); const BlockType = require('../extension-support/block-type'); @@ -47,7 +31,6 @@ const createVariableData = (scope, varObj) => ({ isCloud: varObj.isCloud }); - /** * @param {string} code * @param {boolean} warp @@ -97,10 +80,14 @@ class ScriptTreeGenerator { * @private */ this.variableCache = {}; + + this.usesTimer = false; } setProcedureVariant (procedureVariant, globalTarget) { const procedureCode = parseProcedureCode(procedureVariant); + + this.script.procedureVariant = procedureVariant; this.script.procedureCode = procedureCode; this.script.isProcedure = true; this.script.yields = false; @@ -188,20 +175,11 @@ class ScriptTreeGenerator { */ descendInput (block) { switch (block.opcode) { - case 'colour_picker': { - const color = block.fields.COLOUR.value; - const hex = color.substr(1); - if (/^[0-9a-f]{6,8}$/.test(hex)) { - return { - kind: 'constant', - value: Number.parseInt(hex, 16) - }; - } + case 'colour_picker': return { kind: 'constant', - value: color + value: block.fields.COLOUR.value }; - } case 'math_angle': case 'math_integer': case 'math_number': @@ -270,6 +248,11 @@ class ScriptTreeGenerator { }; } + case 'control_get_counter': + return { + kind: 'counter.get' + }; + case 'data_variable': return { kind: 'var.get', @@ -304,6 +287,17 @@ class ScriptTreeGenerator { list: this.descendVariable(block, 'LIST', LIST_TYPE) }; + case 'event_broadcast_menu': { + const broadcastOption = block.fields.BROADCAST_OPTION; + const broadcastVariable = this.target.lookupBroadcastMsg(broadcastOption.id, broadcastOption.value); + // TODO: empty string probably isn't the correct fallback + const broadcastName = broadcastVariable ? broadcastVariable.name : ''; + return { + kind: 'constant', + value: broadcastName + }; + } + case 'looks_backdropnumbername': if (block.fields.NUMBER_NAME.value === 'number') { return { @@ -567,6 +561,9 @@ class ScriptTreeGenerator { right: this.descendInputOfBlock(block, 'NUM2') }; + case 'procedures_call': + return this.descendProcedure(block); + case 'sensing_answer': return { kind: 'sensing.answer' @@ -645,6 +642,7 @@ class ScriptTreeGenerator { object: this.descendInputOfBlock(block, 'OBJECT') }; case 'sensing_timer': + this.usesTimer = true; return { kind: 'timer.get' }; @@ -703,6 +701,8 @@ class ScriptTreeGenerator { value: block.fields[fields[0]].value }; } + + log.warn(`IR: Unknown input: ${block.opcode}`, block); throw new Error(`IR: Unknown input: ${block.opcode}`); } } @@ -727,6 +727,10 @@ class ScriptTreeGenerator { whenTrue: this.descendSubstack(block, 'SUBSTACK'), whenFalse: [] }; + case 'control_clear_counter': + return { + kind: 'counter.clear' + }; case 'control_create_clone_of': return { kind: 'control.createClone', @@ -769,6 +773,10 @@ class ScriptTreeGenerator { whenTrue: this.descendSubstack(block, 'SUBSTACK'), whenFalse: this.descendSubstack(block, 'SUBSTACK2') }; + case 'control_incr_counter': + return { + kind: 'counter.increment' + }; case 'control_repeat': this.analyzeLoop(); return { @@ -776,16 +784,26 @@ class ScriptTreeGenerator { times: this.descendInputOfBlock(block, 'TIMES'), do: this.descendSubstack(block, 'SUBSTACK') }; - case 'control_repeat_until': + case 'control_repeat_until': { this.analyzeLoop(); + // Dirty hack: automatically enable warp timer for this block if it uses timer + // This fixes project that do things like "repeat until timer > 0.5" + this.usesTimer = false; + const condition = this.descendInputOfBlock(block, 'CONDITION'); + const needsWarpTimer = this.usesTimer; + if (needsWarpTimer) { + this.script.yields = true; + } return { kind: 'control.while', condition: { kind: 'op.not', - operand: this.descendInputOfBlock(block, 'CONDITION') + operand: condition }, - do: this.descendSubstack(block, 'SUBSTACK') + do: this.descendSubstack(block, 'SUBSTACK'), + warpTimer: needsWarpTimer }; + } case 'control_stop': { const level = block.fields.STOP_OPTION.value; if (level === 'all') { @@ -823,7 +841,9 @@ class ScriptTreeGenerator { return { kind: 'control.while', condition: this.descendInputOfBlock(block, 'CONDITION'), - do: this.descendSubstack(block, 'SUBSTACK') + do: this.descendSubstack(block, 'SUBSTACK'), + // We should consider analyzing this like we do for control_repeat_until + warpTimer: false }; case 'data_addtolist': @@ -994,31 +1014,13 @@ class ScriptTreeGenerator { case 'motion_changexby': return { - kind: 'motion.setXY', - x: { - kind: 'op.add', - left: { - kind: 'motion.x' - }, - right: this.descendInputOfBlock(block, 'DX') - }, - y: { - kind: 'motion.y' - } + kind: 'motion.changeX', + dx: this.descendInputOfBlock(block, 'DX') }; case 'motion_changeyby': return { - kind: 'motion.setXY', - x: { - kind: 'motion.x' - }, - y: { - kind: 'op.add', - left: { - kind: 'motion.y' - }, - right: this.descendInputOfBlock(block, 'DY') - } + kind: 'motion.changeY', + dy: this.descendInputOfBlock(block, 'DY') }; case 'motion_gotoxy': return { @@ -1052,18 +1054,12 @@ class ScriptTreeGenerator { }; case 'motion_setx': return { - kind: 'motion.setXY', - x: this.descendInputOfBlock(block, 'X'), - y: { - kind: 'motion.y' - } + kind: 'motion.setX', + x: this.descendInputOfBlock(block, 'X') }; case 'motion_sety': return { - kind: 'motion.setXY', - x: { - kind: 'motion.x' - }, + kind: 'motion.setY', y: this.descendInputOfBlock(block, 'Y') }; case 'motion_turnleft': @@ -1154,13 +1150,15 @@ class ScriptTreeGenerator { }; case 'procedures_call': return this.descendProcedure(block); + case 'procedures_return': + return { + kind: 'procedures.return', + value: this.descendInputOfBlock(block, 'RETURN') + }; case 'sensing_resettimer': return { kind: 'timer.reset' }; - case 'procedures_return': - return {kind: 'procedures.return', value: this.descendInputOfBlock(block, 'RETURN')}; - default: { const opcodeFunction = this.runtime.getOpcodeFunction(block.opcode); if (opcodeFunction) { @@ -1172,24 +1170,15 @@ class ScriptTreeGenerator { const blockInfo = this.getBlockInfo(block.opcode); if (blockInfo) { const type = blockInfo.info.blockType; - if (type === BlockType.COMMAND) { + if (type === BlockType.COMMAND || type === BlockType.CONDITIONAL || type === BlockType.LOOP) { return this.descendCompatLayer(block); } } } - // When this thread was triggered by a stack click, attempt to compile as an input. - // TODO: perhaps this should be moved to generate()? - if (this.thread.stackClick) { - try { - const inputNode = this.descendInput(block); - return { - kind: 'visualReport', - input: inputNode - }; - } catch (e) { - // Ignore - } + const asVisualReport = this.descendVisualReport(block); + if (asVisualReport) { + return asVisualReport; } log.warn(`IR: Unknown stacked block: ${block.opcode}`, block); @@ -1260,7 +1249,7 @@ class ScriptTreeGenerator { } } - if (this.variableCache.hasOwnProperty(id)) { + if (Object.prototype.hasOwnProperty.call(this.variableCache, id)) { return this.variableCache[id]; } @@ -1271,8 +1260,70 @@ class ScriptTreeGenerator { return data; } + /** + * @param {string} id The ID of the variable. + * @param {string} name The name of the variable. + * @param {''|'list'} type The variable type. + * @private + * @returns {*} A parsed variable object. + */ + _descendVariable (id, name, type) { + const target = this.target; + const stage = this.stage; + + // Look for by ID in target... + if (Object.prototype.hasOwnProperty.call(target.variables, id)) { + return createVariableData('target', target.variables[id]); + } + + // Look for by ID in stage... + if (!target.isStage) { + if (stage && Object.prototype.hasOwnProperty.call(stage.variables, id)) { + return createVariableData('stage', stage.variables[id]); + } + } + + // Look for by name and type in target... + for (const varId in target.variables) { + if (Object.prototype.hasOwnProperty.call(target.variables, varId)) { + const currVar = target.variables[varId]; + if (currVar.name === name && currVar.type === type) { + return createVariableData('target', currVar); + } + } + } + + // Look for by name and type in stage... + if (!target.isStage && stage) { + for (const varId in stage.variables) { + if (Object.prototype.hasOwnProperty.call(stage.variables, varId)) { + const currVar = stage.variables[varId]; + if (currVar.name === name && currVar.type === type) { + return createVariableData('stage', currVar); + } + } + } + } + + // Create it locally... + const newVariable = new Variable(id, name, type, false, target.id); + target.variables[id] = newVariable; + + if (target.sprite) { + // Create the variable in all instances of this sprite. + // This is necessary because the script cache is shared between clones. + // sprite.clones has all instances of this sprite including the original and all clones + for (const clone of target.sprite.clones) { + if (!Object.prototype.hasOwnProperty.call(clone.variables, id)) { + clone.variables[id] = new Variable(id, name, type, false, target.id); + } + } + } + + return createVariableData('target', newVariable); + } + descendProcedure (block) { - // setting of yields will be handled later in the analysis phase const procedureCode = block.mutation.proccode; const isGlobal = block.mutation.isglobal === 'true'; if (procedureCode === 'tw:debugger;') { @@ -1288,6 +1339,32 @@ class ScriptTreeGenerator { }; } + const [paramNames, paramIds, paramDefaults] = paramNamesIdsAndDefaults; + + const addonBlock = this.runtime.getAddonBlock(procedureCode); + if (addonBlock) { + this.script.yields = true; + const args = {}; + for (let i = 0; i < paramIds.length; i++) { + let value; + if (block.inputs[paramIds[i]] && block.inputs[paramIds[i]].block) { + value = this.descendInputOfBlock(block, paramIds[i]); + } else { + value = { + kind: 'constant', + value: paramDefaults[i] + }; + } + args[paramNames[i]] = value; + } + return { + kind: 'addons.call', + code: procedureCode, + arguments: args, + blockId: block.id + }; + } + // These are guaranteed to exist by previous checks let definitionId = blockContainer.getProcedureDefinition(procedureCode); let definitionBlock; @@ -1295,12 +1372,23 @@ class ScriptTreeGenerator { if (definitionId) { definitionBlock = blockContainer.getBlock(definitionId); - innerDefinition = blockContainer.getBlock(definitionBlock.inputs.custom_block.block); + if (definitionBlock) { + innerDefinition = blockContainer.getBlock(definitionBlock.inputs.custom_block.block); + } } else { + // TODO: maybe we don't need check if procedureCode is global procedures + // this.script.target indicates whether it is a global procedures let globalTarget; [definitionId, globalTarget] = blockContainer.getGlobalProcedureAndTarget(procedureCode); - definitionBlock = globalTarget.blocks.getBlock(definitionId); - innerDefinition = globalTarget.blocks.getBlock(definitionBlock.inputs.custom_block.block); + if (definitionId && globalTarget) { + definitionBlock = globalTarget.blocks.getBlock(definitionId); + innerDefinition = globalTarget.blocks.getBlock(definitionBlock.inputs.custom_block.block); + } + } + if (!definitionBlock) { + return { + kind: 'noop' + }; } let isWarp = this.script.isWarp; @@ -1321,8 +1409,6 @@ class ScriptTreeGenerator { this.script.dependedProcedures.push(variant); } - const [paramNames, paramIds, paramDefaults] = paramNamesIdsAndDefaults; - // Non-warp direct recursion yields. if (!this.script.isWarp) { if (procedureCode === this.script.procedureCode) { @@ -1352,69 +1438,6 @@ class ScriptTreeGenerator { }; } - /** - * @param {string} id The ID of the variable. - * @param {string} name The name of the variable. - * @param {''|'list'} type The variable type. - * @private - * @returns {*} A parsed variable object. - */ - _descendVariable (id, name, type) { - const target = this.target; - const stage = this.stage; - - // Look for by ID in target... - if (target.variables.hasOwnProperty(id)) { - return createVariableData('target', target.variables[id]); - } - - // Look for by ID in stage... - if (!target.isStage) { - if (stage && stage.variables.hasOwnProperty(id)) { - return createVariableData('stage', stage.variables[id]); - } - } - - // Look for by name and type in target... - for (const varId in target.variables) { - if (target.variables.hasOwnProperty(varId)) { - const currVar = target.variables[varId]; - if (currVar.name === name && currVar.type === type) { - return createVariableData('target', currVar); - } - } - } - - // Look for by name and type in stage... - if (!target.isStage && stage) { - for (const varId in stage.variables) { - if (stage.variables.hasOwnProperty(varId)) { - const currVar = stage.variables[varId]; - if (currVar.name === name && currVar.type === type) { - return createVariableData('stage', currVar); - } - } - } - } - - // Create it locally... - const newVariable = new Variable(id, name, type, false, target.id); - target.variables[id] = newVariable; - - if (target.sprite) { - // Create the variable in all instances of this sprite. - // This is necessary because the script cache is shared between clones. - // sprite.clones has all instances of this sprite including the original and all clones - for (const clone of target.sprite.clones) { - if (!clone.variables.hasOwnProperty(id)) { - clone.variables[id] = new Variable(id, name, type, false, target.id); - } - } - } - - return createVariableData('target', newVariable); - } - /** * Descend into a block that uses the compatibility layer. * @param {*} block The block to use the compatibility layer for. @@ -1423,22 +1446,43 @@ class ScriptTreeGenerator { */ descendCompatLayer (block) { this.script.yields = true; + const inputs = {}; - const fields = {}; for (const name of Object.keys(block.inputs)) { - inputs[name] = this.descendInputOfBlock(block, name); + if (!name.startsWith('SUBSTACK')) { + inputs[name] = this.descendInputOfBlock(block, name); + } } + + const fields = {}; for (const name of Object.keys(block.fields)) { fields[name] = block.fields[name].value; } if (block.mutation?.blockInfo?.isDynamic) { inputs.mutation = {kind: 'gandi.blockMutation', value: block.mutation}; } + + const blockInfo = this.getBlockInfo(block.opcode); + const blockType = (blockInfo && blockInfo.info && blockInfo.info.blockType) || BlockType.COMMAND; + const substacks = {}; + if (blockType === BlockType.CONDITIONAL || blockType === BlockType.LOOP) { + for (const inputName in block.inputs) { + if (!inputName.startsWith('SUBSTACK')) continue; + const branchNum = inputName === 'SUBSTACK' ? 1 : +inputName.substring('SUBSTACK'.length); + if (!isNaN(branchNum)) { + substacks[branchNum] = this.descendSubstack(block, inputName); + } + } + } + return { kind: 'compat', + id: block.id, opcode: block.opcode, + blockType, inputs, - fields + fields, + substacks }; } @@ -1479,6 +1523,72 @@ class ScriptTreeGenerator { } } + descendVisualReport (block) { + if (!this.thread.stackClick || block.next) { + return null; + } + try { + return { + kind: 'visualReport', + input: this.descendInput(block) + }; + } catch (e) { + return null; + } + } + + /** + * @param {Block} hatBlock + */ + walkHat (hatBlock) { + const nextBlock = hatBlock.next; + const opcode = hatBlock.opcode; + const hatInfo = this.runtime._hats[opcode]; + + if (this.thread.stackClick) { + // We still need to treat the hat as a normal block (so executableHat should be false) for + // interpreter parity, but the reuslt is ignored. + const opcodeFunction = this.runtime.getOpcodeFunction(opcode); + if (opcodeFunction) { + return [ + this.descendCompatLayer(hatBlock), + ...this.walkStack(nextBlock) + ]; + } + return this.walkStack(nextBlock); + } + + if (hatInfo.edgeActivated) { + // Edge-activated HAT + this.script.yields = true; + this.script.executableHat = true; + return [ + { + kind: 'hat.edge', + id: hatBlock.id, + condition: this.descendCompatLayer(hatBlock) + }, + ...this.walkStack(nextBlock) + ]; + } + + const opcodeFunction = this.runtime.getOpcodeFunction(opcode); + if (opcodeFunction) { + // Predicate-based HAT + this.script.yields = true; + this.script.executableHat = true; + return [ + { + kind: 'hat.predicate', + condition: this.descendCompatLayer(hatBlock) + }, + ...this.walkStack(nextBlock) + ]; + } + + return this.walkStack(nextBlock); + } + /** * @param {string} topBlockId The ID of the top block of the script. * @returns {IntermediateScript} @@ -1508,24 +1618,26 @@ class ScriptTreeGenerator { this.readTopBlockComment(topBlock.comment); } - // If the top block is a hat, advance to its child. - let entryBlock; - if (this.runtime.getIsHat(topBlock.opcode) || topBlock.opcode === 'procedures_definition') { - if (this.runtime.getIsEdgeActivatedHat(topBlock.opcode)) { - throw new Error(`Not compiling an edge-activated hat: ${topBlock.opcode}`); - } - entryBlock = topBlock.next; + // We do need to evaluate empty hats + const hatInfo = this.runtime._hats[topBlock.opcode]; + const isHat = !!hatInfo; + if (isHat) { + this.script.stack = this.walkHat(topBlock); } else { - entryBlock = topBlockId; - } + // We don't evaluate the procedures_definition top block as it never does anything + // We also don't want it to be treated like a hat block + let entryBlock; + if (topBlock.opcode === 'procedures_definition') { + entryBlock = topBlock.next; + } else { + entryBlock = topBlockId; + } - if (!entryBlock) { - // This is an empty script. - return this.script; + if (entryBlock) { + this.script.stack = this.walkStack(entryBlock); + } } - this.script.stack = this.walkStack(entryBlock); - return this.script; } } @@ -1554,7 +1666,7 @@ class IRGenerator { addProcedureDependencies (dependencies) { for (const procedureVariant of dependencies) { - if (this.procedures.hasOwnProperty(procedureVariant)) { + if (Object.prototype.hasOwnProperty.call(this.procedures, procedureVariant)) { continue; } if (this.compilingProcedures.has(procedureVariant)) { @@ -1649,4 +1761,7 @@ class IRGenerator { } } -module.exports = IRGenerator; +module.exports = { + ScriptTreeGenerator, + IRGenerator +}; diff --git a/src/compiler/jsexecute.js b/src/compiler/jsexecute.js index d564510d..b7bff895 100644 --- a/src/compiler/jsexecute.js +++ b/src/compiler/jsexecute.js @@ -1,19 +1,3 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - /** * @fileoverview Runtime for scripts generated by jsgen */ @@ -21,12 +5,13 @@ /* eslint-disable no-unused-vars */ /* eslint-disable prefer-template */ /* eslint-disable valid-jsdoc */ +/* eslint-disable max-len */ const globalState = { Timer: require('../util/timer'), Cast: require('../util/cast'), log: require('../util/log'), - compatibilityLayerBlockUtility: require('./compat-block-utility'), + blockUtility: require('./compat-block-utility'), thread: null }; @@ -99,16 +84,24 @@ runtimeFunctions.waitThreads = `const waitThreads = function*(threads) { }`; /** - * Wait until a Promise resolves or rejects before continuing. + * waitPromise: Wait until a Promise resolves or rejects before continuing. * @param {Promise} promise The promise to wait for. * @returns {*} the value that the promise resolves to, otherwise undefined if the promise rejects */ /** - * Execute a scratch-vm primitive. + * isPromise: Determine if a value is Promise-like + * @param {unknown} promise The value to check + * @returns {promise is PromiseLike} True if the value is Promise-like (has a .then()) + */ + +/** + * executeInCompatibilityLayer: Execute a scratch-vm primitive. * @param {*} inputs The inputs to pass to the block. * @param {function} blockFunction The primitive's function. * @param {boolean} useFlags Whether to set flags (hasResumedFromPromise) + * @param {string} blockId Block ID to set on the emulated block utility. + * @param {*|null} branchInfo Extra information object for CONDITIONAL and LOOP blocks. See createBranchInfo(). * @returns {*} the value returned by the block, if any. */ runtimeFunctions.executeInCompatibilityLayer = `let hasResumedFromPromise = false; @@ -116,54 +109,67 @@ const waitPromise = function*(promise) { const thread = globalState.thread; let returnValue; + // enter STATUS_PROMISE_WAIT and yield + // this will stop script execution until the promise handlers reset the thread status + // because promise handlers might execute immediately, configure thread.status here + thread.status = 1; // STATUS_PROMISE_WAIT + promise .then(value => { returnValue = value; thread.status = 0; // STATUS_RUNNING - }) - .catch(error => { - thread.status = 0; // STATUS_RUNNING + }, error => { globalState.log.warn('Promise rejected in compiled script:', error); + returnValue = '' + error; + thread.status = 0; // STATUS_RUNNING }); - // enter STATUS_PROMISE_WAIT and yield - // this will stop script execution until the promise handlers reset the thread status - thread.status = 1; // STATUS_PROMISE_WAIT yield; return returnValue; }; -const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp,useFlags) { +const isPromise = value => ( + // see engine/execute.js + value !== null && + typeof value === 'object' && + typeof value.then === 'function' +); +const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, useFlags, blockId, branchInfo) { const thread = globalState.thread; - - // reset the stackframe - // we only ever use one stackframe at a time, so this shouldn't cause issues - thread.stackFrames[thread.stackFrames.length - 1].reuse(isWarp); + const blockUtility = globalState.blockUtility; + const stackFrame = branchInfo ? branchInfo.stackFrame : {}; + + const finish = (returnValue) => { + if (branchInfo) { + if (typeof returnValue === 'undefined' && blockUtility._startedBranch) { + branchInfo.isLoop = blockUtility._startedBranch[1]; + return blockUtility._startedBranch[0]; + } + branchInfo.isLoop = branchInfo.defaultIsLoop; + return returnValue; + } + return returnValue; + }; const executeBlock = () => { - const compatibilityLayerBlockUtility = globalState.compatibilityLayerBlockUtility; - compatibilityLayerBlockUtility.thread = thread; - compatibilityLayerBlockUtility.sequencer = thread.target.runtime.sequencer; - return blockFunction(inputs, compatibilityLayerBlockUtility); + blockUtility.init(thread, blockId, stackFrame); + return blockFunction(inputs, blockUtility); }; - const isPromise = value => ( - // see engine/execute.js - value !== null && - typeof value === 'object' && - typeof value.then === 'function' - ); - let returnValue = executeBlock(); - if (isPromise(returnValue)) { - returnValue = yield* waitPromise(returnValue); - if (useFlags) { - hasResumedFromPromise = true; - } + returnValue = finish(yield* waitPromise(returnValue)); + if (useFlags) hasResumedFromPromise = true; return returnValue; } + if (thread.status === 1 /* STATUS_PROMISE_WAIT */ || thread.status === 4 /* STATUS_DONE */) { + // Something external is forcing us to stop + yield; + // Make up a return value because whatever is forcing us to stop can't specify one + return ''; + } + while (thread.status === 2 /* STATUS_YIELD */ || thread.status === 3 /* STATUS_YIELD_TICK */) { // Yielded threads will run next iteration. if (thread.status === 2 /* STATUS_YIELD */) { @@ -178,44 +184,31 @@ const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp,useF } returnValue = executeBlock(); - if (isPromise(returnValue)) { - returnValue = yield* waitPromise(returnValue); - if (useFlags) { - hasResumedFromPromise = true; - } + returnValue = finish(yield* waitPromise(returnValue)); + if (useFlags) hasResumedFromPromise = true; return returnValue; } - } - // todo: do we have to do anything extra if status is STATUS_DONE? + if (thread.status === 1 /* STATUS_PROMISE_WAIT */ || thread.status === 4 /* STATUS_DONE */) { + yield; + return finish(''); + } + } - return returnValue; + return finish(returnValue); }`; /** - * Run an addon block. - * @param {string} procedureCode The block's procedure code - * @param {string} blockId The ID of the block being run - * @param {object} args The arguments to pass to the block + * @param {boolean} isLoop True if the block is a LOOP by default (can be overridden by startBranch() call) + * @returns {unknown} Branch info object for compatibility layer. */ -runtimeFunctions.callAddonBlock = `const callAddonBlock = function*(procedureCode, blockId, args) { - const thread = globalState.thread; - const addonBlock = thread.target.runtime.getAddonBlock(procedureCode); - if (addonBlock) { - const target = thread.target; - addonBlock.callback(args, { - // Shim enough of BlockUtility to make addons work - peekStack () { - return blockId; - }, - target - }); - if (thread.status === 1 /* STATUS_PROMISE_WAIT */) { - yield; - } - } -}`; +runtimeFunctions.createBranchInfo = `const createBranchInfo = (isLoop) => ({ + defaultIsLoop: isLoop, + isLoop: false, + branch: 0, + stackFrame: {} +});`; /** * End the current script. @@ -245,14 +238,32 @@ runtimeFunctions.toBoolean = `const toBoolean = value => { }`; /** - * Check if a value is considered whitespace. - * Similar to Cast.isWhiteSpace() - * @param {*} val Value to check - * @returns {boolean} true if the value is whitespace + * If a number is very close to a whole number, round to that whole number. + * @param {number} value Value to round + * @returns {number} Rounded number or original number */ -baseRuntime += `const isWhiteSpace = val => ( - val === null || (typeof val === 'string' && val.trim().length === 0) -);`; +runtimeFunctions.limitPrecision = `const limitPrecision = value => { + const rounded = Math.round(value); + const delta = value - rounded; + return (Math.abs(delta) < 1e-9) ? rounded : value; +}`; + +/** + * Used internally by the compare family of function. + * See similar method in cast.js. + * @param {*} val A value that evaluates to 0 in JS string-to-number conversation such as empty string, 0, or tab. + * @returns {boolean} True if the value should not be treated as the number zero. + */ +baseRuntime += `const isNotActuallyZero = val => { + if (typeof val !== 'string') return false; + for (let i = 0; i < val.length; i++) { + const code = val.charCodeAt(i); + if (code === 48 || code === 9) { + return false; + } + } + return true; +};`; /** * Determine if two values are equal. @@ -260,21 +271,14 @@ baseRuntime += `const isWhiteSpace = val => ( * @param {*} v2 Second value * @returns {boolean} true if v1 is equal to v2 */ -baseRuntime += `const compareEqual = (v1, v2) => { - let n1 = +v1; - let n2 = +v2; - if (n1 === 0 && isWhiteSpace(v1)) { - n1 = NaN; - } else if (n2 === 0 && isWhiteSpace(v2)) { - n2 = NaN; - } - if (isNaN(n1) || isNaN(n2)) { - const s1 = ('' + v1).toLowerCase(); - const s2 = ('' + v2).toLowerCase(); - return s1 === s2; - } +baseRuntime += `const compareEqualSlow = (v1, v2) => { + const n1 = +v1; + if (isNaN(n1) || (n1 === 0 && isNotActuallyZero(v1))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase(); + const n2 = +v2; + if (isNaN(n2) || (n2 === 0 && isNotActuallyZero(v2))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase(); return n1 === n2; -};`; +}; +const compareEqual = (v1, v2) => (typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) && !isNaN(v2) || v1 === v2) ? v1 === v2 : compareEqualSlow(v1, v2);`; /** * Determine if one value is greater than another. @@ -282,12 +286,12 @@ baseRuntime += `const compareEqual = (v1, v2) => { * @param {*} v2 Second value * @returns {boolean} true if v1 is greater than v2 */ -runtimeFunctions.compareGreaterThan = `const compareGreaterThan = (v1, v2) => { +runtimeFunctions.compareGreaterThan = `const compareGreaterThanSlow = (v1, v2) => { let n1 = +v1; let n2 = +v2; - if (n1 === 0 && isWhiteSpace(v1)) { + if (n1 === 0 && isNotActuallyZero(v1)) { n1 = NaN; - } else if (n2 === 0 && isWhiteSpace(v2)) { + } else if (n2 === 0 && isNotActuallyZero(v2)) { n2 = NaN; } if (isNaN(n1) || isNaN(n2)) { @@ -296,7 +300,8 @@ runtimeFunctions.compareGreaterThan = `const compareGreaterThan = (v1, v2) => { return s1 > s2; } return n1 > n2; -}`; +}; +const compareGreaterThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) ? v1 > v2 : compareGreaterThanSlow(v1, v2)`; /** * Determine if one value is less than another. @@ -304,12 +309,12 @@ runtimeFunctions.compareGreaterThan = `const compareGreaterThan = (v1, v2) => { * @param {*} v2 Second value * @returns {boolean} true if v1 is less than v2 */ -runtimeFunctions.compareLessThan = `const compareLessThan = (v1, v2) => { +runtimeFunctions.compareLessThan = `const compareLessThanSlow = (v1, v2) => { let n1 = +v1; let n2 = +v2; - if (n1 === 0 && isWhiteSpace(v1)) { + if (n1 === 0 && isNotActuallyZero(v1)) { n1 = NaN; - } else if (n2 === 0 && isWhiteSpace(v2)) { + } else if (n2 === 0 && isNotActuallyZero(v2)) { n2 = NaN; } if (isNaN(n1) || isNaN(n2)) { @@ -318,7 +323,8 @@ runtimeFunctions.compareLessThan = `const compareLessThan = (v1, v2) => { return s1 < s2; } return n1 < n2; -}`; +}; +const compareLessThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v2) ? v1 < v2 : compareLessThanSlow(v1, v2)`; /** * Generate a random integer. @@ -390,26 +396,27 @@ runtimeFunctions.distance = `const distance = menu => { * @param {number} length Length of the list. * @returns {number} 0 based list index, or -1 if invalid. */ -baseRuntime += `const listIndex = (index, length) => { - if (typeof index !== 'number') { - if (index === 'last') { - if (length > 0) { - return length - 1; - } - return -1; - } else if (index === 'random' || index === '*') { - if (length > 0) { - return (Math.random() * length) | 0; - } - return -1; +baseRuntime += `const listIndexSlow = (index, length) => { + if (index === 'last') { + return length - 1; + } else if (index === 'random' || index === 'any') { + if (length > 0) { + return (Math.random() * length) | 0; } - index = +index || 0; + return -1; } - index = index | 0; + index = (+index || 0) | 0; if (index < 1 || index > length) { return -1; } return index - 1; +}; +const listIndex = (index, length) => { + if (typeof index !== 'number') { + return listIndexSlow(index, length); + } + index = index | 0; + return index < 1 || index > length ? -1 : index - 1; };`; /** @@ -586,6 +593,14 @@ const execute = thread => { thread.generator.next(); }; +const threadStack = []; +const saveGlobalState = () => { + threadStack.push(globalState.thread); +}; +const restoreGlobalState = () => { + globalState.thread = threadStack.pop(); +}; + const insertRuntime = source => { let result = baseRuntime; for (const functionName of Object.keys(runtimeFunctions)) { @@ -603,16 +618,18 @@ const insertRuntime = source => { * @returns {*} The result of evaluating the string. */ const scopedEval = source => { + const withRuntime = insertRuntime(source); try { - const withRuntime = insertRuntime(source); return new Function('globalState', withRuntime)(globalState); } catch (e) { - globalState.log.error('was unable to compile script', source); + globalState.log.error('was unable to compile script', withRuntime); throw e; } }; execute.scopedEval = scopedEval; execute.runtimeFunctions = runtimeFunctions; +execute.saveGlobalState = saveGlobalState; +execute.restoreGlobalState = restoreGlobalState; module.exports = execute; diff --git a/src/compiler/jsgen.js b/src/compiler/jsgen.js index ea287032..95afeba8 100644 --- a/src/compiler/jsgen.js +++ b/src/compiler/jsgen.js @@ -1,24 +1,8 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - const log = require('../util/log'); const Cast = require('../util/cast'); +const BlockType = require('../extension-support/block-type'); const VariablePool = require('./variable-pool'); const jsexecute = require('./jsexecute'); -const {disableToString} = require('./util'); const environment = require('./environment'); // Imported for JSDoc types, not to actually use @@ -72,9 +56,11 @@ const generatorNameVariablePool = new VariablePool('gen'); * @property {() => string} asNumberOrNaN * @property {() => string} asString * @property {() => string} asBoolean + * @property {() => string} asColor * @property {() => string} asUnknown * @property {() => string} asSafe * @property {() => boolean} isAlwaysNumber + * @property {() => boolean} isAlwaysNumberOrNaN * @property {() => boolean} isNeverNumber */ @@ -117,6 +103,10 @@ class TypedInput { return `toBoolean(${this.source})`; } + asColor () { + return this.asUnknown(); + } + asUnknown () { return this.source; } @@ -133,6 +123,10 @@ class TypedInput { return this.type === TYPE_NUMBER; } + isAlwaysNumberOrNaN () { + return this.type === TYPE_NUMBER || this.type === TYPE_NUMBER_NAN; + } + isNeverNumber () { return false; } @@ -155,6 +149,10 @@ class ConstantInput { // Using the constant value allows numbers such as "010" to be interpreted as 8 (or SyntaxError in strict mode) instead of 10. return numberValue.toString(); } + // numberValue is one of 0, -0, or NaN + if (Object.is(numberValue, -0)) { + return '-0'; + } return '0'; } @@ -171,6 +169,15 @@ class ConstantInput { return Cast.toBoolean(this.constantValue).toString(); } + asColor () { + // Attempt to parse hex code at compilation time + if (/^#[0-9a-f]{6,8}$/i.test(this.constantValue)) { + const hex = this.constantValue.substr(1); + return Number.parseInt(hex, 16).toString(); + } + return this.asUnknown(); + } + asUnknown () { // Attempt to convert strings to numbers if it is unlikely to break things if (typeof this.constantValue === 'number') { @@ -203,6 +210,10 @@ class ConstantInput { return true; } + isAlwaysNumberOrNaN () { + return this.isAlwaysNumber(); + } + isNeverNumber () { return Number.isNaN(+this.constantValue); } @@ -267,6 +278,10 @@ class VariableInput { return `toBoolean(${this.source})`; } + asColor () { + return this.asUnknown(); + } + asUnknown () { return this.source; } @@ -282,6 +297,13 @@ class VariableInput { return false; } + isAlwaysNumberOrNaN () { + if (this._value) { + return this._value.isAlwaysNumberOrNaN(); + } + return false; + } + isNeverNumber () { if (this._value) { return this._value.isNeverNumber(); @@ -290,20 +312,6 @@ class VariableInput { } } -// Running toString() on any of these methods is a mistake. -disableToString(ConstantInput.prototype); -disableToString(ConstantInput.prototype.asNumber); -disableToString(ConstantInput.prototype.asString); -disableToString(ConstantInput.prototype.asBoolean); -disableToString(ConstantInput.prototype.asUnknown); -disableToString(ConstantInput.prototype.asSafe); -disableToString(TypedInput.prototype); -disableToString(TypedInput.prototype.asNumber); -disableToString(TypedInput.prototype.asString); -disableToString(TypedInput.prototype.asBoolean); -disableToString(TypedInput.prototype.asUnknown); -disableToString(TypedInput.prototype.asSafe); - const getNamesOfCostumesAndSounds = runtime => { const result = new Set(); for (const target of runtime.targets) { @@ -390,6 +398,7 @@ class JSGenerator { this._setupVariables = {}; this.descendedIntoModulo = false; + this.isInHat = false; this.debug = this.target.runtime.debug; } @@ -439,8 +448,12 @@ class JSGenerator { case 'args.ccw_hat_parameter': return new TypedInput(`(thread.hatParam ? thread.hatParam['${node.index}']: null)`, TYPE_UNKNOWN); + case 'addons.call': + return new TypedInput(`(${this.descendAddonCall(node)})`, TYPE_UNKNOWN); + case 'args.boolean': return new TypedInput(`toBoolean(p${node.index})`, TYPE_BOOLEAN); + case 'args.stringNumber': return new TypedInput(`p${node.index}`, TYPE_UNKNOWN); @@ -451,6 +464,9 @@ class JSGenerator { case 'constant': return this.safeConstantInput(node.value); + case 'counter.get': + return new TypedInput('runtime.ext_scratch3_control._counter', TYPE_NUMBER); + case 'keyboard.pressed': return new TypedInput(`runtime.ioDevices.keyboard.getKeyIsDown(${this.descendInput(node.key).asSafe()})`, TYPE_BOOLEAN); @@ -461,7 +477,7 @@ class JSGenerator { case 'list.get': { const index = this.descendInput(node.index); if (environment.supportsNullishCoalescing) { - if (index.isAlwaysNumber()) { + if (index.isAlwaysNumberOrNaN()) { return new TypedInput(`(${this.referenceVariable(node.list)}.value[(${index.asNumber()} | 0) - 1] ?? "")`, TYPE_UNKNOWN); } if (index instanceof ConstantInput && index.constantValue === 'last') { @@ -489,9 +505,9 @@ class JSGenerator { case 'motion.direction': return new TypedInput('target.direction', TYPE_NUMBER); case 'motion.x': - return new TypedInput('target.x', TYPE_NUMBER); + return new TypedInput('limitPrecision(target.x)', TYPE_NUMBER); case 'motion.y': - return new TypedInput('target.y', TYPE_NUMBER); + return new TypedInput('limitPrecision(target.y)', TYPE_NUMBER); case 'mouse.down': return new TypedInput('runtime.ioDevices.mouse.getIsDown()', TYPE_BOOLEAN); @@ -500,13 +516,17 @@ class JSGenerator { case 'mouse.y': return new TypedInput('runtime.ioDevices.mouse.getScratchY()', TYPE_NUMBER); + case 'noop': + return new TypedInput('""', TYPE_STRING); + case 'op.abs': return new TypedInput(`Math.abs(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'op.acos': // Needs to be marked as NaN because Math.acos(1.0001) === NaN return new TypedInput(`((Math.acos(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER_NAN); case 'op.add': - return new TypedInput(`(${this.descendInput(node.left).asNumber()} + ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER); + // Needs to be marked as NaN because Infinity + -Infinity === NaN + return new TypedInput(`(${this.descendInput(node.left).asNumber()} + ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); case 'op.and': return new TypedInput(`(${this.descendInput(node.left).asBoolean()} && ${this.descendInput(node.right).asBoolean()})`, TYPE_BOOLEAN); case 'op.asin': @@ -519,7 +539,7 @@ class JSGenerator { case 'op.contains': return new TypedInput(`(${this.descendInput(node.string).asString()}.toLowerCase().indexOf(${this.descendInput(node.contains).asString()}.toLowerCase()) !== -1)`, TYPE_BOOLEAN); case 'op.cos': - return new TypedInput(`(Math.round(Math.cos((Math.PI * ${this.descendInput(node.value).asNumber()}) / 180) * 1e10) / 1e10)`, TYPE_NUMBER); + return new TypedInput(`(Math.round(Math.cos((Math.PI * ${this.descendInput(node.value).asNumber()}) / 180) * 1e10) / 1e10)`, TYPE_NUMBER_NAN); case 'op.divide': // Needs to be marked as NaN because 0 / 0 === NaN return new TypedInput(`(${this.descendInput(node.left).asNumber()} / ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); @@ -553,14 +573,18 @@ class JSGenerator { case 'op.greater': { const left = this.descendInput(node.left); const right = this.descendInput(node.right); - // When both operands are known to never be numbers, only use string comparison to avoid all number parsing. + // When the left operand is a number and the right operand is a number or NaN, we can use > + if (left.isAlwaysNumber() && right.isAlwaysNumberOrNaN()) { + return new TypedInput(`(${left.asNumber()} > ${right.asNumberOrNaN()})`, TYPE_BOOLEAN); + } + // When the left operand is a number or NaN and the right operand is a number, we can negate <= + if (left.isAlwaysNumberOrNaN() && right.isAlwaysNumber()) { + return new TypedInput(`!(${left.asNumberOrNaN()} <= ${right.asNumber()})`, TYPE_BOOLEAN); + } + // When either operand is known to never be a number, avoid all number parsing. if (left.isNeverNumber() || right.isNeverNumber()) { return new TypedInput(`(${left.asString()}.toLowerCase() > ${right.asString()}.toLowerCase())`, TYPE_BOOLEAN); } - // When both operands are known to be numbers, we can use > - if (left.isAlwaysNumber() && right.isAlwaysNumber()) { - return new TypedInput(`(${left.asNumber()} > ${right.asNumber()})`, TYPE_BOOLEAN); - } // No compile-time optimizations possible - use fallback method. return new TypedInput(`compareGreaterThan(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN); } @@ -571,14 +595,18 @@ class JSGenerator { case 'op.less': { const left = this.descendInput(node.left); const right = this.descendInput(node.right); - // When both operands are known to never be numbers, only use string comparison to avoid all number parsing. + // When the left operand is a number or NaN and the right operand is a number, we can use < + if (left.isAlwaysNumberOrNaN() && right.isAlwaysNumber()) { + return new TypedInput(`(${left.asNumberOrNaN()} < ${right.asNumber()})`, TYPE_BOOLEAN); + } + // When the left operand is a number and the right operand is a number or NaN, we can negate >= + if (left.isAlwaysNumber() && right.isAlwaysNumberOrNaN()) { + return new TypedInput(`!(${left.asNumber()} >= ${right.asNumberOrNaN()})`, TYPE_BOOLEAN); + } + // When either operand is known to never be a number, avoid all number parsing. if (left.isNeverNumber() || right.isNeverNumber()) { return new TypedInput(`(${left.asString()}.toLowerCase() < ${right.asString()}.toLowerCase())`, TYPE_BOOLEAN); } - // When both operands are known to be numbers, we can use > - if (left.isAlwaysNumber() && right.isAlwaysNumber()) { - return new TypedInput(`(${left.asNumber()} < ${right.asNumber()})`, TYPE_BOOLEAN); - } // No compile-time optimizations possible - use fallback method. return new TypedInput(`compareLessThan(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN); } @@ -603,30 +631,73 @@ class JSGenerator { return new TypedInput(`(${this.descendInput(node.left).asBoolean()} || ${this.descendInput(node.right).asBoolean()})`, TYPE_BOOLEAN); case 'op.random': if (node.useInts) { + // Both inputs are ints, so we know neither are NaN return new TypedInput(`randomInt(${this.descendInput(node.low).asNumber()}, ${this.descendInput(node.high).asNumber()})`, TYPE_NUMBER); } if (node.useFloats) { - return new TypedInput(`randomFloat(${this.descendInput(node.low).asNumber()}, ${this.descendInput(node.high).asNumber()})`, TYPE_NUMBER); + return new TypedInput(`randomFloat(${this.descendInput(node.low).asNumber()}, ${this.descendInput(node.high).asNumber()})`, TYPE_NUMBER_NAN); } - return new TypedInput(`runtime.ext_scratch3_operators._random(${this.descendInput(node.low).asUnknown()}, ${this.descendInput(node.high).asUnknown()})`, TYPE_NUMBER); + return new TypedInput(`runtime.ext_scratch3_operators._random(${this.descendInput(node.low).asUnknown()}, ${this.descendInput(node.high).asUnknown()})`, TYPE_NUMBER_NAN); case 'op.round': return new TypedInput(`Math.round(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'op.sin': - return new TypedInput(`(Math.round(Math.sin((Math.PI * ${this.descendInput(node.value).asNumber()}) / 180) * 1e10) / 1e10)`, TYPE_NUMBER); + return new TypedInput(`(Math.round(Math.sin((Math.PI * ${this.descendInput(node.value).asNumber()}) / 180) * 1e10) / 1e10)`, TYPE_NUMBER_NAN); case 'op.sqrt': // Needs to be marked as NaN because Math.sqrt(-1) === NaN return new TypedInput(`Math.sqrt(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN); case 'op.subtract': - return new TypedInput(`(${this.descendInput(node.left).asNumber()} - ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER); + // Needs to be marked as NaN because Infinity - Infinity === NaN + return new TypedInput(`(${this.descendInput(node.left).asNumber()} - ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); case 'op.tan': - return new TypedInput(`Math.tan(${this.descendInput(node.value).asNumber()} * Math.PI / 180)`, TYPE_NUMBER); + return new TypedInput(`tan(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN); case 'op.10^': return new TypedInput(`(10 ** ${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); + //TODO - don't compatiable with tw procedures.call reporter + // we use procedures.callWithReturn instead + // should be compatible with tw procedures.call reporter in future + case 'procedures.call': { + const procedureCode = node.code; + const procedureVariant = node.variant; + const procedureData = this.ir.procedures[procedureVariant]; + if (procedureData.stack === null) { + // TODO still need to evaluate arguments for side effects + return new TypedInput('""', TYPE_STRING); + } + + // Recursion makes this complicated because: + // - We need to yield *between* each call in the same command block + // - We need to evaluate arguments *before* that yield happens + + const procedureReference = `thread.procedures["${sanitize(procedureVariant)}"]`; + const args = []; + for (const input of node.arguments) { + args.push(this.descendInput(input).asSafe()); + } + const joinedArgs = args.join(','); + + const yieldForRecursion = !this.isWarp && procedureCode === this.script.procedureCode; + const yieldForHat = this.isInHat; + if (yieldForRecursion || yieldForHat) { + const runtimeFunction = procedureData.yields ? 'yieldThenCallGenerator' : 'yieldThenCall'; + return new TypedInput(`(yield* ${runtimeFunction}(${procedureReference}, ${joinedArgs}))`, TYPE_UNKNOWN); + } + if (procedureData.yields) { + return new TypedInput(`(yield* ${procedureReference}(${joinedArgs}))`, TYPE_UNKNOWN); + } + return new TypedInput(`${procedureReference}(${joinedArgs})`, TYPE_UNKNOWN); + } + + case 'procedures.callWithReturn': { + const source = this.descendProcedure(node); + // if (!source) break; + return new TypedInput(source, TYPE_PROCEDURE_RETURN); + } + case 'sensing.answer': return new TypedInput(`runtime.ext_scratch3_sensing._answer`, TYPE_STRING); case 'sensing.colorTouchingColor': - return new TypedInput(`target.colorIsTouchingColor(colorToList(${this.descendInput(node.target).asUnknown()}), colorToList(${this.descendInput(node.mask).asUnknown()}))`, TYPE_BOOLEAN); + return new TypedInput(`target.colorIsTouchingColor(colorToList(${this.descendInput(node.target).asColor()}), colorToList(${this.descendInput(node.mask).asColor()}))`, TYPE_BOOLEAN); case 'sensing.date': return new TypedInput(`(new Date().getDate())`, TYPE_NUMBER); case 'sensing.dayofweek': @@ -642,14 +713,52 @@ class JSGenerator { return new TypedInput(`(new Date().getMinutes())`, TYPE_NUMBER); case 'sensing.month': return new TypedInput(`(new Date().getMonth() + 1)`, TYPE_NUMBER); - case 'sensing.of': - return new TypedInput(`runtime.ext_scratch3_sensing.getAttributeOf({OBJECT: ${this.descendInput(node.object).asString()}, PROPERTY: "${sanitize(node.property)}" })`, TYPE_UNKNOWN); + case 'sensing.of': { + const object = this.descendInput(node.object).asString(); + const property = node.property; + if (node.object.kind === 'constant') { + const isStage = node.object.value === '_stage_'; + // Note that if target isn't a stage, we can't assume it exists + const objectReference = isStage ? 'stage' : this.evaluateOnce(`runtime.getSpriteTargetByName(${object})`); + if (property === 'volume') { + return new TypedInput(`(${objectReference} ? ${objectReference}.volume : 0)`, TYPE_NUMBER); + } + if (isStage) { + switch (property) { + case 'background #': + // fallthrough for scratch 1.0 compatibility + case 'backdrop #': + return new TypedInput(`(${objectReference}.currentCostume + 1)`, TYPE_NUMBER); + case 'backdrop name': + return new TypedInput(`${objectReference}.getCostumes()[${objectReference}.currentCostume].name`, TYPE_STRING); + } + } else { + switch (property) { + case 'x position': + return new TypedInput(`(${objectReference} ? ${objectReference}.x : 0)`, TYPE_NUMBER); + case 'y position': + return new TypedInput(`(${objectReference} ? ${objectReference}.y : 0)`, TYPE_NUMBER); + case 'direction': + return new TypedInput(`(${objectReference} ? ${objectReference}.direction : 0)`, TYPE_NUMBER); + case 'costume #': + return new TypedInput(`(${objectReference} ? ${objectReference}.currentCostume + 1 : 0)`, TYPE_NUMBER); + case 'costume name': + return new TypedInput(`(${objectReference} ? ${objectReference}.getCostumes()[${objectReference}.currentCostume].name : 0)`, TYPE_UNKNOWN); + case 'size': + return new TypedInput(`(${objectReference} ? ${objectReference}.size : 0)`, TYPE_NUMBER); + } + } + const variableReference = this.evaluateOnce(`${objectReference} && ${objectReference}.lookupVariableByNameAndType("${sanitize(property)}", "", true)`); + return new TypedInput(`(${variableReference} ? ${variableReference}.value : 0)`, TYPE_UNKNOWN); + } + return new TypedInput(`runtime.ext_scratch3_sensing.getAttributeOf({OBJECT: ${object}, PROPERTY: "${sanitize(property)}" })`, TYPE_UNKNOWN); + } case 'sensing.second': return new TypedInput(`(new Date().getSeconds())`, TYPE_NUMBER); case 'sensing.touching': return new TypedInput(`target.isTouchingObject(${this.descendInput(node.object).asUnknown()})`, TYPE_BOOLEAN); case 'sensing.touchingColor': - return new TypedInput(`target.isTouchingColor(colorToList(${this.descendInput(node.color).asUnknown()}))`, TYPE_BOOLEAN); + return new TypedInput(`target.isTouchingColor(colorToList(${this.descendInput(node.color).asColor()}))`, TYPE_BOOLEAN); case 'sensing.username': return new TypedInput('runtime.ioDevices.userData.getUsername()', TYPE_STRING); case 'sensing.year': @@ -664,11 +773,6 @@ class JSGenerator { case 'var.get': return this.descendVariable(node.variable); - case 'procedures.callWithReturn': { - const source = this.descendProcedure(node); - // if (!source) break; - return new TypedInput(source, TYPE_PROCEDURE_RETURN); - } case 'noop': { return new TypedInput('""', TYPE_STRING); } @@ -684,20 +788,36 @@ class JSGenerator { descendStackedBlock (node) { switch (node.kind) { case 'addons.call': - this.source += `yield* callAddonBlock("${sanitize(node.code)}","${sanitize(node.blockId)}",{`; - this.yielded(); - for (const argumentName of Object.keys(node.arguments)) { - const argumentValue = node.arguments[argumentName]; - this.source += `"${sanitize(argumentName)}":${this.descendInput(argumentValue).asSafe()},`; - } - this.source += '});\n'; + this.source += `${this.descendAddonCall(node)};\n`; break; case 'compat': { // If the last command in a loop returns a promise, immediately continue to the next iteration. // If you don't do this, the loop effectively yields twice per iteration and will run at half-speed. const isLastInLoop = this.isLastBlockInLoop(); - this.source += `${this.generateCompatibilityLayerCall(node, isLastInLoop)};\n`; + + const blockType = node.blockType; + if (blockType === BlockType.COMMAND || blockType === BlockType.HAT) { + this.source += `${this.generateCompatibilityLayerCall(node, isLastInLoop)};\n`; + } else if (blockType === BlockType.CONDITIONAL || blockType === BlockType.LOOP) { + const branchVariable = this.localVariables.next(); + this.source += `const ${branchVariable} = createBranchInfo(${blockType === BlockType.LOOP});\n`; + this.source += `while (${branchVariable}.branch = +(${this.generateCompatibilityLayerCall(node, false, branchVariable)})) {\n`; + this.source += `switch (${branchVariable}.branch) {\n`; + for (const index in node.substacks) { + this.source += `case ${+index}: {\n`; + this.descendStack(node.substacks[index], new Frame(false)); + this.source += `break;\n`; + this.source += `}\n`; // close case + } + this.source += '}\n'; // close switch + this.source += `if (!${branchVariable}.isLoop) break;\n`; + this.yieldLoop(); + this.source += '}\n'; // close while + } else { + throw new Error(`Unknown block type: ${blockType}`); + } + if (isLastInLoop) { this.source += 'if (hasResumedFromPromise) {hasResumedFromPromise = false;continue;}\n'; } @@ -763,7 +883,7 @@ class JSGenerator { // always yield at least once, even on 0 second durations this.yieldNotWarp(); this.source += `while (thread.timer.timeElapsed() < ${duration}) {\n`; - this.yieldNotWarpOrStuck(); + this.yieldStuckOrNotWarp(); this.source += '}\n'; this.source += 'thread.timer = null;\n'; break; @@ -771,7 +891,7 @@ class JSGenerator { case 'control.waitUntil': { this.resetVariableInputs(); this.source += `while (!${this.descendInput(node.condition).asBoolean()}) {\n`; - this.yieldNotWarpOrStuck(); + this.yieldStuckOrNotWarp(); this.source += `}\n`; break; } @@ -779,10 +899,47 @@ class JSGenerator { this.resetVariableInputs(); this.source += `while (${this.descendInput(node.condition).asBoolean()}) {\n`; this.descendStack(node.do, new Frame(true)); - this.yieldLoop(); + if (node.warpTimer) { + this.yieldStuckOrNotWarp(); + } else { + this.yieldLoop(); + } this.source += `}\n`; break; + case 'counter.clear': + this.source += 'runtime.ext_scratch3_control._counter = 0;\n'; + break; + case 'counter.increment': + this.source += 'runtime.ext_scratch3_control._counter++;\n'; + break; + + case 'hat.edge': + this.isInHat = true; + this.source += '{\n'; + // For exact Scratch parity, evaluate the input before checking old edge state. + // Can matter if the input is not instantly evaluated. + this.source += `const resolvedValue = ${this.descendInput(node.condition).asBoolean()};\n`; + this.source += `const id = "${sanitize(node.id)}";\n`; + this.source += 'const hasOldEdgeValue = target.hasEdgeActivatedValue(id);\n'; + this.source += `const oldEdgeValue = target.updateEdgeActivatedValue(id, resolvedValue);\n`; + this.source += `const edgeWasActivated = hasOldEdgeValue ? (!oldEdgeValue && resolvedValue) : resolvedValue;\n`; + this.source += `if (!edgeWasActivated) {\n`; + this.retire(); + this.source += '}\n'; + this.source += 'yield;\n'; + this.source += '}\n'; + this.isInHat = false; + break; + case 'hat.predicate': + this.isInHat = true; + this.source += `if (!${this.descendInput(node.condition).asBoolean()}) {\n`; + this.retire(); + this.source += '}\n'; + this.source += 'yield;\n'; + this.isInHat = false; + break; + case 'event.broadcast': this.source += `startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} });\n`; this.resetVariableInputs(); @@ -876,13 +1033,15 @@ class JSGenerator { break; case 'looks.backwardLayers': - this.source += `target.goBackwardLayers(${this.descendInput(node.layers).asNumber()});\n`; + if (!this.target.isStage) { + this.source += `target.goBackwardLayers(${this.descendInput(node.layers).asNumber()});\n`; + } break; case 'looks.clearEffects': this.source += 'target.clearEffects();\n'; break; case 'looks.changeEffect': - if (this.target.effects.hasOwnProperty(node.effect)) { + if (Object.prototype.hasOwnProperty.call(this.target.effects, node.effect)) { this.source += `target.setEffect("${sanitize(node.effect)}", runtime.ext_scratch3_looks.clampEffect("${sanitize(node.effect)}", ${this.descendInput(node.value).asNumber()} + target.effects["${sanitize(node.effect)}"]));\n`; } break; @@ -890,13 +1049,19 @@ class JSGenerator { this.source += `target.setSize(target.size + ${this.descendInput(node.size).asNumber()});\n`; break; case 'looks.forwardLayers': - this.source += `target.goForwardLayers(${this.descendInput(node.layers).asNumber()});\n`; + if (!this.target.isStage) { + this.source += `target.goForwardLayers(${this.descendInput(node.layers).asNumber()});\n`; + } break; case 'looks.goToBack': - this.source += 'target.goToBack();\n'; + if (!this.target.isStage) { + this.source += 'target.goToBack();\n'; + } break; case 'looks.goToFront': - this.source += 'target.goToFront();\n'; + if (!this.target.isStage) { + this.source += 'target.goToFront();\n'; + } break; case 'looks.hide': this.source += 'target.setVisible(false);\n'; @@ -909,7 +1074,7 @@ class JSGenerator { this.source += 'target.setCostume(target.currentCostume + 1);\n'; break; case 'looks.setEffect': - if (this.target.effects.hasOwnProperty(node.effect)) { + if (Object.prototype.hasOwnProperty.call(this.target.effects, node.effect)) { this.source += `target.setEffect("${sanitize(node.effect)}", runtime.ext_scratch3_looks.clampEffect("${sanitize(node.effect)}", ${this.descendInput(node.value).asNumber()}));\n`; } break; @@ -927,6 +1092,12 @@ class JSGenerator { this.source += `runtime.ext_scratch3_looks._setCostume(target, ${this.descendInput(node.costume).asSafe()});\n`; break; + case 'motion.changeX': + this.source += `target.setXY(target.x + ${this.descendInput(node.dx).asNumber()}, target.y);\n`; + break; + case 'motion.changeY': + this.source += `target.setXY(target.x, target.y + ${this.descendInput(node.dy).asNumber()});\n`; + break; case 'motion.ifOnEdgeBounce': this.source += `runtime.ext_scratch3_motion._ifOnEdgeBounce(target);\n`; break; @@ -936,18 +1107,25 @@ class JSGenerator { case 'motion.setRotationStyle': this.source += `target.setRotationStyle("${sanitize(node.style)}");\n`; break; - case 'motion.setXY': + case 'motion.setX': // fallthrough + case 'motion.setY': // fallthrough + case 'motion.setXY': { this.descendedIntoModulo = false; - this.source += `target.setXY(${this.descendInput(node.x).asNumber()}, ${this.descendInput(node.y).asNumber()});\n`; + const x = 'x' in node ? this.descendInput(node.x).asNumber() : 'target.x'; + const y = 'y' in node ? this.descendInput(node.y).asNumber() : 'target.y'; + this.source += `target.setXY(${x}, ${y});\n`; if (this.descendedIntoModulo) { this.source += `if (target.interpolationData) target.interpolationData = null;\n`; } break; + } case 'motion.step': this.source += `runtime.ext_scratch3_motion._moveSteps(${this.descendInput(node.steps).asNumber()}, target);\n`; break; case 'motion.movegrid': this.source += `runtime.ext_scratch3_motion._moveSteps(${this.descendInput(node.grids).asNumber() * 40}, target);\n`; + + case 'noop': break; case 'noop': return new TypedInput('""', TYPE_STRING); @@ -977,7 +1155,7 @@ class JSGenerator { this.source += `${PEN_EXT}._setPenShadeToNumber(${this.descendInput(node.shade).asNumber()}, target);\n`; break; case 'pen.setColor': - this.source += `${PEN_EXT}._setPenColorToColor(${this.descendInput(node.color).asUnknown()}, target);\n`; + this.source += `${PEN_EXT}._setPenColorToColor(${this.descendInput(node.color).asColor()}, target);\n`; break; case 'pen.setParam': this.source += `${PEN_EXT}._setOrChangeColorParam(${this.descendInput(node.param).asString()}, ${this.descendInput(node.value).asNumber()}, ${PEN_STATE}, false);\n`; @@ -1029,9 +1207,13 @@ class JSGenerator { this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.variable.id)}", element: "checkbox", value: true }, runtime);\n`; break; - case 'visualReport': - this.source += `runtime.visualReport("${sanitize(this.script.topBlockId)}", ${this.descendInput(node.input).asUnknown()});\n`; + case 'visualReport': { + const value = this.localVariables.next(); + this.source += `const ${value} = ${this.descendInput(node.input).asUnknown()};`; + // blocks like legacy no-ops can return a literal `undefined` + this.source += `if (${value} !== undefined) runtime.visualReport("${sanitize(this.script.topBlockId)}", ${value});\n`; break; + } default: log.warn(`JS: Unknown stacked block: ${node.kind}`, node); @@ -1039,6 +1221,21 @@ class JSGenerator { } } + /** + * Compile a Record of input objects into a safe JS string. + * @param {Record} inputs + * @returns {string} + */ + descendInputRecord (inputs) { + let result = '{'; + for (const name of Object.keys(inputs)) { + const node = inputs[name]; + result += `"${sanitize(name)}":${this.descendInput(node).asSafe()},`; + } + result += '}'; + return result; + } + resetVariableInputs () { this.variableInputs = {}; } @@ -1061,7 +1258,7 @@ class JSGenerator { } descendVariable (variable) { - if (this.variableInputs.hasOwnProperty(variable.id)) { + if (Object.prototype.hasOwnProperty.call(this.variableInputs, variable.id)) { return this.variableInputs[variable.id]; } const input = new VariableInput(`${this.referenceVariable(variable)}.value`); @@ -1089,19 +1286,23 @@ class JSGenerator { const procedureReference = `thread.procedures["${sanitize(procedureVariant)}"]`; const yieldForRecursion = !this.isWarp && procedureCode === this.script.procedureCode; let source = ''; + + // for stack if (node.kind === 'procedures.call') { if (yieldForRecursion) { - source += 'yield;\n'; - this.yielded(); + this.yieldNotWarp(); } if (procedureData.yields) { source += 'yield* '; } - source += `${procedureReference}(${joinedArgs})`; + source += `${procedureReference}(${joinedArgs});`; return source; } + + // for input if (node.kind === 'procedures.callWithReturn'){ - if (yieldForRecursion) { + const yieldForHat = this.isInHat; + if (yieldForRecursion || yieldForHat) { const runtimeFunction = procedureData.yields ? 'yieldThenCallGenerator' : 'yieldThenCall'; return `yield* ${runtimeFunction}(${procedureReference}, ${joinedArgs})`; } @@ -1119,8 +1320,15 @@ class JSGenerator { return this.evaluateOnce(`stage.variables["${sanitize(variable.id)}"]`); } + descendAddonCall (node) { + const inputs = this.descendInputRecord(node.arguments); + const blockFunction = `runtime.getAddonBlock("${sanitize(node.code)}").callback`; + const blockId = `"${sanitize(node.blockId)}"`; + return `yield* executeInCompatibilityLayer(${inputs}, ${blockFunction}, ${this.isWarp}, false, ${blockId})`; + } + evaluateOnce (source) { - if (this._setupVariables.hasOwnProperty(source)) { + if (Object.prototype.hasOwnProperty.call(this._setupVariables, source)) { return this._setupVariables[source]; } const variable = this._setupVariablesPool.next(); @@ -1133,11 +1341,28 @@ class JSGenerator { // When in a procedure, return will only send us back to the previous procedure, so instead we yield back to the sequencer. // Outside of a procedure, return will correctly bring us back to the sequencer. if (this.isProcedure) { - this.source += 'retire();\n'; - this.source += 'yield;\n'; + this.source += 'retire(); yield;\n'; + } else { + this.source += 'retire(); return;\n'; + } + } + + stopScript () { + if (this.isProcedure) { + this.source += 'return "";\n'; } else { - this.source += 'retire();\n'; - this.source += 'return;\n'; + this.retire(); + } + } + + /** + * @param {string} valueJS JS code of value to return. + */ + stopScriptAndReturn (valueJS) { + if (this.isProcedure) { + this.source += `return ${valueJS};\n`; + } else { + this.retire(); } } @@ -1162,7 +1387,7 @@ class JSGenerator { yieldLoop () { if (this.warpTimer) { - this.yieldNotWarpOrStuck(); + this.yieldStuckOrNotWarp(); } else { this.yieldNotWarp(); } @@ -1181,7 +1406,7 @@ class JSGenerator { /** * Write JS to yield the current thread if warp mode is disabled or if the script seems to be stuck. */ - yieldNotWarpOrStuck () { + yieldStuckOrNotWarp () { if (this.isWarp) { this.source += 'if (isStuck()) yield;\n'; } else { @@ -1214,9 +1439,10 @@ class JSGenerator { * Generate a call into the compatibility layer. * @param {*} node The "compat" kind node to generate from. * @param {boolean} setFlags Whether flags should be set describing how this function was processed. + * @param {string|null} [frameName] Name of the stack frame variable, if any * @returns {string} The JS of the call. */ - generateCompatibilityLayerCall (node, setFlags) { + generateCompatibilityLayerCall (node, setFlags, frameName = null) { const opcode = node.opcode; let result = 'yield* executeInCompatibilityLayer({'; @@ -1231,7 +1457,7 @@ class JSGenerator { result += `"${sanitize(fieldName)}":"${sanitize(field)}",`; } const opcodeFunction = this.evaluateOnce(`runtime.getOpcodeFunction("${sanitize(opcode)}")`); - result += `}, ${opcodeFunction}, ${this.isWarp}, ${setFlags})`; + result += `}, ${opcodeFunction}, ${this.isWarp}, ${setFlags}, "${sanitize(node.id)}", ${frameName})`; return result; } @@ -1310,8 +1536,35 @@ class JSGenerator { log.info(`JS: ${this.target.getName()}: compiled ${this.script.procedureCode || 'script'}`, factory); } + if (JSGenerator.testingApparatus) { + JSGenerator.testingApparatus.report(this, factory); + } + return fn; } } +// For extensions. +JSGenerator.unstable_exports = { + TYPE_NUMBER, + TYPE_STRING, + TYPE_BOOLEAN, + TYPE_UNKNOWN, + TYPE_NUMBER_NAN, + factoryNameVariablePool, + functionNameVariablePool, + generatorNameVariablePool, + VariablePool, + PEN_EXT, + PEN_STATE, + TypedInput, + ConstantInput, + VariableInput, + Frame, + sanitize +}; + +// Test hook used by automated snapshot testing. +JSGenerator.testingApparatus = null; + module.exports = JSGenerator; diff --git a/src/compiler/util.js b/src/compiler/util.js deleted file mode 100644 index d3baee9b..00000000 --- a/src/compiler/util.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - -/** - * Disable the toString() method on an object by making it throw when called. - * This is useful if you want to make sure that you can't accidentally stringify a value that shouldn't be stringified. - * @param {*} obj Object to disable the toString() method on - */ -const disableToString = obj => { - obj.toString = () => { - throw new Error(`toString unexpectedly called on ${obj.name || 'object'}`); - }; -}; - -module.exports = { - disableToString -}; diff --git a/src/compiler/variable-pool.js b/src/compiler/variable-pool.js index b1b56f11..11f50594 100644 --- a/src/compiler/variable-pool.js +++ b/src/compiler/variable-pool.js @@ -1,19 +1,3 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - class VariablePool { /** * @param {string} prefix The prefix at the start of the variable name. diff --git a/src/dispatch/central-dispatch.js b/src/dispatch/central-dispatch.js index 23205abc..c64dc68a 100644 --- a/src/dispatch/central-dispatch.js +++ b/src/dispatch/central-dispatch.js @@ -55,6 +55,8 @@ class CentralDispatch extends SharedDispatch { throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`); } + // TODO: verify correct `this` after switching from apply to spread + // eslint-disable-next-line prefer-spread return provider[method].apply(provider, args); } throw new Error(`Provider not found for service: ${service}`); @@ -67,7 +69,7 @@ class CentralDispatch extends SharedDispatch { * @param {object} provider - a local object which provides this service. */ setServiceSync (service, provider) { - if (this.services.hasOwnProperty(service)) { + if (Object.prototype.hasOwnProperty.call(this.services, service)) { log.warn(`Central dispatch replacing existing service provider for ${service}`); } this.services[service] = provider; diff --git a/src/dispatch/shared-dispatch.js b/src/dispatch/shared-dispatch.js index fd8c77b2..6a1bdd04 100644 --- a/src/dispatch/shared-dispatch.js +++ b/src/dispatch/shared-dispatch.js @@ -87,6 +87,8 @@ class SharedDispatch { return this._remoteTransferCall(provider, service, method, transfer, ...args); } + // TODO: verify correct `this` after switching from apply to spread + // eslint-disable-next-line prefer-spread const result = provider[method].apply(provider, args); return Promise.resolve(result); } diff --git a/src/dispatch/worker-dispatch.js b/src/dispatch/worker-dispatch.js index 0cc18b34..ae2d9efa 100644 --- a/src/dispatch/worker-dispatch.js +++ b/src/dispatch/worker-dispatch.js @@ -59,11 +59,13 @@ class WorkerDispatch extends SharedDispatch { * @returns {Promise} - a promise which will resolve once the service is registered. */ setService (service, provider) { - if (this.services.hasOwnProperty(service)) { + if (Object.prototype.hasOwnProperty.call(this.services, service)) { log.warn(`Worker dispatch replacing existing service provider for ${service}`); } this.services[service] = provider; - return this.waitForConnection.then(() => this._remoteCall(centralDispatchService, 'dispatch', 'setService', service)); + return this.waitForConnection.then(() => ( + this._remoteCall(centralDispatchService, 'dispatch', 'setService', service) + )); } /** diff --git a/src/engine/adapter.js b/src/engine/adapter.js index 41a37e90..cd811006 100644 --- a/src/engine/adapter.js +++ b/src/engine/adapter.js @@ -196,7 +196,7 @@ const domToBlocksOrFrames = function (elementsDOM) { // Flatten elements object into a list. const elementsList = []; for (const b in elements) { - if (!elements.hasOwnProperty(b)) continue; + if (!Object.prototype.hasOwnProperty.call(elements, b)) continue; elementsList.push(elements[b]); } return elementsList; diff --git a/src/engine/block-utility.js b/src/engine/block-utility.js index b9fbb60b..f85e8cb8 100644 --- a/src/engine/block-utility.js +++ b/src/engine/block-utility.js @@ -151,11 +151,6 @@ class BlockUtility { * @param {string} procedureCode Procedure code for procedure to start. */ startProcedure (procedureCode, globalTarget) { - const addonBlock = this.runtime.getAddonBlock(procedureCode); - if (addonBlock) { - addonBlock.callback(this.thread.getAllparams(), this.thread); - return; - } this.sequencer.stepToProcedure(this.thread, procedureCode, globalTarget); } @@ -260,6 +255,8 @@ class BlockUtility { this.sequencer.runtime.ioDevices[device] && this.sequencer.runtime.ioDevices[device][func]) { const devObject = this.sequencer.runtime.ioDevices[device]; + // TODO: verify correct `this` after switching from apply to spread + // eslint-disable-next-line prefer-spread return devObject[func].apply(devObject, args); } } diff --git a/src/engine/blocks-runtime-cache.js b/src/engine/blocks-runtime-cache.js index cc30e834..3a3241e9 100644 --- a/src/engine/blocks-runtime-cache.js +++ b/src/engine/blocks-runtime-cache.js @@ -44,7 +44,7 @@ class RuntimeScriptCache { if (Object.keys(fields).length === 0) { const inputs = container.getInputs(block); for (const input in inputs) { - if (!inputs.hasOwnProperty(input)) continue; + if (!Object.prototype.hasOwnProperty.call(inputs, input)) continue; const id = inputs[input].block; const inputBlock = container.getBlock(id); const inputFields = container.getFields(inputBlock); diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 9711d8db..8633752b 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -121,7 +121,7 @@ class Blocks { * @returns {{success: boolean; value: any}|null} Cached success or error, or null if there is no cached value. */ getCachedCompileResult (blockId) { - if (this._cache.compiledScripts.hasOwnProperty(blockId)) { + if (Object.prototype.hasOwnProperty.call(this._cache.compiledScripts, blockId)) { return this._cache.compiledScripts[blockId]; } return null; @@ -308,7 +308,7 @@ class Blocks { } for (const id in this._blocks) { - if (!this._blocks.hasOwnProperty(id)) continue; + if (!Object.prototype.hasOwnProperty.call(this._blocks, id)) continue; const block = this._blocks[id]; if (block.opcode === 'procedures_definition') { // tw: make sure that populateProcedureCache is kept up to date with this method @@ -357,7 +357,7 @@ class Blocks { } for (const id in this._blocks) { - if (!this._blocks.hasOwnProperty(id)) continue; + if (!Object.prototype.hasOwnProperty.call(this._blocks, id)) continue; const block = this._blocks[id]; if (block.opcode === 'procedures_prototype' && block.mutation.proccode === name) { @@ -414,7 +414,7 @@ class Blocks { return; } for (const id in this._blocks) { - if (!this._blocks.hasOwnProperty(id)) continue; + if (!Object.prototype.hasOwnProperty.call(this._blocks, id)) continue; const block = this._blocks[id]; if (block.opcode === 'procedures_prototype') { @@ -593,8 +593,8 @@ class Blocks { case 'delete': // Don't accept delete events for missing blocks, // or shadow blocks being obscured. - if (!this._blocks.hasOwnProperty(e.blockId) || - this._blocks[e.blockId].shadow) { + if (!Object.prototype.hasOwnProperty.call(this._blocks, e.blockId) || + this._blocks[e.blockId].shadow) { return; } // Inform any runtime to forget about glows on this script. @@ -637,7 +637,7 @@ class Blocks { } break; case 'var_rename': - if (editingTarget && editingTarget.variables.hasOwnProperty(e.varId)) { + if (editingTarget && Object.prototype.hasOwnProperty.call(editingTarget.variables, e.varId)) { const originalTargetId = editingTarget.originalTargetId; const variable = editingTarget.variables[e.varId]; // This is a local variable, rename on the current target @@ -678,7 +678,7 @@ class Blocks { break; case 'var_delete': { this.resetCache(); // tw: more aggressive cache resetting - const target = (editingTarget && editingTarget.variables.hasOwnProperty(e.varId)) ? + const target = (editingTarget && Object.prototype.hasOwnProperty.call(editingTarget.variables, e.varId)) ? editingTarget : stage; target.deleteVariable(e.varId); this.emitProjectChanged(); @@ -710,24 +710,25 @@ class Blocks { if (this.runtime.getEditingTarget()) { const currTarget = this.runtime.getEditingTarget(); - if (!currTarget.comments.hasOwnProperty(e.commentId)) { + if (!Object.prototype.hasOwnProperty.call(currTarget.comments, e.commentId)) { log.warn(`Cannot change comment with id ${e.commentId} because it does not exist.`); return; } const comment = currTarget.comments[e.commentId]; const change = e.newContents_; const changedData = {}; - if (change.hasOwnProperty('minimized')) { + if (Object.prototype.hasOwnProperty.call(change, 'minimized')) { comment.minimized = change.minimized; changedData.minimized = comment.minimized; } - if (change.hasOwnProperty('width') && change.hasOwnProperty('height')) { + if (Object.prototype.hasOwnProperty.call(change, 'width') && + Object.prototype.hasOwnProperty.call(change, 'height')) { comment.width = change.width; comment.height = change.height; changedData.width = comment.width; changedData.height = comment.height; } - if (change.hasOwnProperty('text')) { + if (Object.prototype.hasOwnProperty.call(change, 'text')) { comment.text = change.text; changedData.text = comment.text; } @@ -742,7 +743,7 @@ class Blocks { case 'comment_move': if (this.runtime.getEditingTarget()) { const currTarget = this.runtime.getEditingTarget(); - if (currTarget && !currTarget.comments.hasOwnProperty(e.commentId)) { + if (currTarget && !Object.prototype.hasOwnProperty.call(currTarget.comments, e.commentId)) { log.warn(`Cannot change comment with id ${e.commentId} because it does not exist.`); return; } @@ -762,7 +763,7 @@ class Blocks { this.resetCache(); // tw: comments can affect compilation if (this.runtime.getEditingTarget()) { const currTarget = this.runtime.getEditingTarget(); - if (!currTarget.comments.hasOwnProperty(e.commentId)) { + if (!Object.prototype.hasOwnProperty.call(currTarget.comments, e.commentId)) { // If we're in this state, we have probably received // a delete event from a workspace that we switched from // (e.g. a delete event for a comment on sprite a's workspace @@ -846,7 +847,7 @@ class Blocks { createBlock (block, source) { // Does the block already exist? // Could happen, e.g., for an unobscured shadow. - if (this._blocks.hasOwnProperty(block.id)) { + if (Object.prototype.hasOwnProperty.call(this._blocks, block.id)) { return false; } // Create new block. @@ -867,11 +868,7 @@ class Blocks { // A new block was actually added to the block container, // emit a project changed event - // tw: Ignore creation of default project blocks - if (block.id !== 'Fj5[gB=S0qJiUu$/!nym' && block.id !== 'Z2l`f?]oj|=Nq/GH@G_u') { - this.emitProjectChanged(); - } - + this.emitProjectChanged(); return true; } @@ -986,8 +983,8 @@ class Blocks { } const isSpriteSpecific = isSpriteLocalVariable || - (this.runtime.monitorBlockInfo.hasOwnProperty(block.opcode) && - this.runtime.monitorBlockInfo[block.opcode].isSpriteSpecific); + (Object.prototype.hasOwnProperty.call(this.runtime.monitorBlockInfo, block.opcode) && + this.runtime.monitorBlockInfo[block.opcode].isSpriteSpecific); if (isSpriteSpecific) { // If creating a new sprite specific monitor, the only possible target is // the current editing one b/c you cannot dynamically create monitors. @@ -1053,7 +1050,7 @@ class Blocks { * @param {!object} e Blockly move event to be processed */ moveBlock (e) { - if (!this._blocks.hasOwnProperty(e.id)) { + if (!Object.prototype.hasOwnProperty.call(this._blocks, e.id)) { return; } @@ -1119,7 +1116,7 @@ class Blocks { // Moved to the new parent's input. // Don't obscure the shadow block. let oldShadow = null; - if (this._blocks[e.newParent].inputs.hasOwnProperty(e.newInput)) { + if (Object.prototype.hasOwnProperty.call(this._blocks[e.newParent].inputs, e.newInput)) { oldShadow = this._blocks[e.newParent].inputs[e.newInput].shadow; } @@ -1223,6 +1220,14 @@ class Blocks { this.emitProjectChanged(); } + /** + * Delete all blocks and their associated scripts. + */ + deleteAllBlocks () { + const blockIds = Object.keys(this._blocks); + blockIds.forEach(blockId => this.deleteBlock(blockId)); + } + /** * Returns a map of all references to variables or lists from blocks * in this block container. @@ -1276,6 +1281,7 @@ class Blocks { let varOrListField = null; let varType = null; let varName = null; + if (!blocks[blockId].fields) continue; if (blocks[blockId].fields.VARIABLE) { varOrListField = blocks[blockId].fields.VARIABLE; varType = Variable.SCALAR_TYPE; @@ -1417,7 +1423,7 @@ class Blocks { */ _getCostumeField (blockId) { const block = this.getBlock(blockId); - if (block && block.fields.hasOwnProperty('COSTUME')) { + if (block && Object.prototype.hasOwnProperty.call(block.fields, 'COSTUME')) { return block.fields.COSTUME; } return null; @@ -1432,7 +1438,7 @@ class Blocks { */ _getSoundField (blockId) { const block = this.getBlock(blockId); - if (block && block.fields.hasOwnProperty('SOUND_MENU')) { + if (block && Object.prototype.hasOwnProperty.call(block.fields, 'SOUND_MENU')) { return block.fields.SOUND_MENU; } return null; @@ -1447,7 +1453,7 @@ class Blocks { */ _getBackdropField (blockId) { const block = this.getBlock(blockId); - if (block && block.fields.hasOwnProperty('BACKDROP')) { + if (block && Object.prototype.hasOwnProperty.call(block.fields, 'BACKDROP')) { return block.fields.BACKDROP; } return null; @@ -1469,7 +1475,7 @@ class Blocks { 'DISTANCETOMENU', 'TOUCHINGOBJECTMENU', 'CLONE_OPTION']; for (let i = 0; i < spriteMenuNames.length; i++) { const menuName = spriteMenuNames[i]; - if (block.fields.hasOwnProperty(menuName)) { + if (Object.prototype.hasOwnProperty.call(block.fields, menuName)) { return block.fields[menuName]; } } @@ -1505,8 +1511,8 @@ class Blocks { const tagName = (block.shadow) ? 'shadow' : 'block'; let xmlString = `<${tagName} - id="${block.id}" - type="${block.opcode}" + id="${xmlEscape(block.id)}" + type="${xmlEscape(block.opcode)}" ${block.hidden ? `hidden="${block.hidden}"` : ''} ${block.locked ? `locked="${block.locked}"` : ''} ${block.topLevel ? `x="${block.x}" y="${block.y}"` : ''} @@ -1514,7 +1520,7 @@ class Blocks { const commentId = block.comment; if (commentId) { if (comments) { - if (comments.hasOwnProperty(commentId)) { + if (Object.prototype.hasOwnProperty.call(comments, commentId)) { xmlString += comments[commentId].toXML(); } else { log.warn(`Could not find comment with id: ${commentId} in provided comment descriptions.`); @@ -1529,11 +1535,11 @@ class Blocks { } // Add any inputs on this block. for (const input in block.inputs) { - if (!block.inputs.hasOwnProperty(input)) continue; + if (!Object.prototype.hasOwnProperty.call(block.inputs, input)) continue; const blockInput = block.inputs[input]; // Only encode a value tag if the value input is occupied. if (blockInput.block || blockInput.shadow) { - xmlString += ``; + xmlString += ``; if (blockInput.block) { xmlString += this.blockToXML(blockInput.block, comments, blocks); } @@ -1546,16 +1552,16 @@ class Blocks { } // Add any fields on this block. for (const field in block.fields) { - if (!block.fields.hasOwnProperty(field)) continue; + if (!Object.prototype.hasOwnProperty.call(block.fields, field)) continue; const blockField = block.fields[field]; - xmlString += ` { + handleReport(resolvedValue, sequencer, thread, blockCached, lastOperation); + // If it's a command block or a top level reporter in a stackClick. + // TW: Don't mangle the stack when we just finished executing a hat block. + // Hat block is always the top and first block of the script. There are no loops to find. + if (lastOperation && (!blockCached._isHat || thread.stackClick)) { + let stackFrame; + let nextBlockId; + let globalTarget; + do { + // In the case that the promise is the last block in the current thread stack + // We need to pop out repeatedly until we find the next block. + const popped = thread.popStack(); + if (popped === null) { + return; + } + nextBlockId = thread.target.blocks.getNextBlock(popped); + globalTarget = thread.getCurrentGlobalTarget(); + if (!nextBlockId && globalTarget) { + nextBlockId = globalTarget.blocks.getNextBlock(popped); + } + if (nextBlockId !== null) { + // A next block exists so break out this loop + break; + } + // Investigate the next block and if not in a loop, + // then repeat and pop the next item off the stack frame + stackFrame = thread.peekStackFrame(); + } while (stackFrame !== null && !stackFrame.isLoop); + + thread.pushStack(nextBlockId, globalTarget); + } +}; + const handlePromise = (primitiveReportedValue, sequencer, thread, blockCached, lastOperation) => { if (thread.status === Thread.STATUS_RUNNING) { // Primitive returned a promise; automatically yield thread. @@ -112,44 +154,11 @@ const handlePromise = (primitiveReportedValue, sequencer, thread, blockCached, l } // Promise handlers primitiveReportedValue.then(resolvedValue => { - handleReport(resolvedValue, sequencer, thread, blockCached, lastOperation); - // If it's a command block or a top level reporter in a stackClick. - if (lastOperation) { - let stackFrame; - let nextBlockId; - let globalTarget; - do { - globalTarget = thread.getCurrentGlobalTarget(); - - // In the case that the promise is the last block in the current thread stack - // We need to pop out repeatedly until we find the next block. - const popped = thread.popStack(); - if (popped === null) { - return; - } - nextBlockId = thread.target.blocks.getNextBlock(popped); - - if (!nextBlockId && globalTarget) { - nextBlockId = globalTarget.blocks.getNextBlock(popped); - } - - if (nextBlockId !== null) { - // A next block exists so break out this loop - break; - } - // Investigate the next block and if not in a loop, - // then repeat and pop the next item off the stack frame - stackFrame = thread.peekStackFrame(); - } while (stackFrame !== null && !stackFrame.isLoop); - - thread.pushStack(nextBlockId, globalTarget); - } + handlePromiseResolution(resolvedValue, sequencer, thread, blockCached, lastOperation); }, rejectionReason => { // Promise rejected: the primitive had some error. - // Log it and proceed. log.warn('Primitive rejected promise: ', rejectionReason); - thread.status = Thread.STATUS_RUNNING; - thread.popStack(); + handlePromiseResolution(`${rejectionReason}`, sequencer, thread, blockCached, lastOperation); }); }; @@ -295,6 +304,10 @@ class BlockCached { this._blockFunction = runtime.getOpcodeFunction(opcode); this._definedBlockFunction = typeof this._blockFunction !== 'undefined'; + const flowing = runtime._flowing[opcode]; + this._isConditional = !!(flowing && flowing.conditional); + this._isLoop = !!(flowing && flowing.loop); + // Store the current shadow value if there is a shadow value. const fieldKeys = Object.keys(fields); this._isShadowBlock = ( @@ -533,7 +546,7 @@ const execute = function (sequencer, thread) { const isPromiseReportedValue = isPromise(primitiveReportedValue); // If it's a promise, wait until promise resolves. - // CCW: procedures_call_with_return make stack frame waiting report like promise + // procedures_call_with_return make stack frame waiting report like promise if (isPromiseReportedValue || currentStackFrame.waitingReporter) { if (isPromiseReportedValue) { handlePromise(primitiveReportedValue, sequencer, thread, opCached, lastOperation); @@ -561,7 +574,7 @@ const execute = function (sequencer, thread) { }; }); - // We are waiting for a promise. Stop running this set of operations + // We are waiting to be resumed later. Stop running this set of operations // and continue them later after thawing the reported values. break; } else if (thread.status === Thread.STATUS_RUNNING) { @@ -582,6 +595,9 @@ const execute = function (sequencer, thread) { parentValues[inputName] = primitiveReportedValue; } } + } else if (thread.status === Thread.STATUS_DONE) { + // Nothing else to execute. + break; } } diff --git a/src/engine/runtime.js b/src/engine/runtime.js index e1c74c08..6f28eec2 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -1,5 +1,7 @@ const EventEmitter = require('events'); const {OrderedMap} = require('immutable'); +const ExtendedJSON = require('@turbowarp/json'); +const uuid = require('uuid'); const ArgumentType = require('../extension-support/argument-type'); const Blocks = require('./blocks'); @@ -8,6 +10,7 @@ const BlockType = require('../extension-support/block-type'); const Profiler = require('./profiler'); const Sequencer = require('./sequencer'); const execute = require('./execute.js'); +const compilerExecute = require('../compiler/jsexecute'); const ScratchBlocksConstants = require('./scratch-blocks-constants'); const TargetType = require('../extension-support/target-type'); const Thread = require('./thread'); @@ -17,7 +20,9 @@ const StageLayering = require('./stage-layering'); const Variable = require('./variable'); const xmlEscape = require('../util/xml-escape'); const ScratchLinkWebSocket = require('../util/scratch-link-websocket'); -const ExtendedJSON = require('../util/tw-extended-json'); +const FontManager = require('./tw-font-manager'); +const fetchWithTimeout = require('../util/fetch-with-timeout'); +const platform = require('./tw-platform.js'); // Virtual I/O devices. const Clock = require('../io/clock'); @@ -49,7 +54,7 @@ const defaultBlockPackages = { const interpolate = require('./tw-interpolate'); const {loadCostume} = require('../import/load-costume'); - +const FrameLoop = require('./tw-frame-loop'); const defaultExtensionColors = ['#0FBD8C', '#0DA57A', '#0B8E69']; @@ -134,7 +139,18 @@ const ArgumentTypeMap = (() => { fieldName: 'VALUE' } }; - //* * powered by xigua end */ + map[ArgumentType.COSTUME] = { + shadow: { + type: 'looks_costume', + fieldName: 'COSTUME' + } + }; + map[ArgumentType.SOUND] = { + shadow: { + type: 'sound_sounds_menu', + fieldName: 'SOUND_MENU' + } + }; return map; })(); @@ -150,6 +166,8 @@ const ArgumentTypeMap = (() => { * removing an existing cloud variable. * @property {function} hasCloudVariables A function to call to check that * the runtime has any cloud variables. + * @property {function} getNumberOfCloudVariables A function that returns the + * number of cloud variables in the project. */ /** @@ -179,11 +197,14 @@ const cloudDataManager = cloudOptions => { const hasCloudVariables = () => count > 0; + const getNumberOfCloudVariables = () => count; + return { canAddCloudVariable, addCloudVariable, removeCloudVariable, - hasCloudVariables + hasCloudVariables, + getNumberOfCloudVariables }; }; @@ -205,14 +226,6 @@ let stepThreadsProfilerId = -1; */ let rendererDrawProfilerId = -1; -// Use setTimeout to polyfill requestAnimationFrame in Node environments -const _requestAnimationFrame = typeof requestAnimationFrame === 'function' ? - requestAnimationFrame : - (f => setTimeout(f, 1000 / 60)); -const _cancelAnimationFrame = typeof requestAnimationFrame === 'function' ? - cancelAnimationFrame : - clearTimeout; - /** * Manages targets, scripts, and the sequencer. * @constructor @@ -286,6 +299,13 @@ class Runtime extends EventEmitter { */ this._hats = {}; + /** + * Map of opcode to information about whether the block's return value should be interpreted + * for control flow purposes. + * @type {Record} + */ + this._flowing = {}; + /** * A list of script block IDs that were glowing during the previous frame. * @type {!Array.} @@ -342,11 +362,9 @@ class Runtime extends EventEmitter { this.turboMode = false; /** - * A reference to the current runtime stepping interval, set - * by a `setInterval`. - * @type {!number} + * tw: Responsible for managing the VM's many timers. */ - this._steppingInterval = null; + this.frameLoop = new FrameLoop(this); /** * Current length of a step. @@ -354,7 +372,7 @@ class Runtime extends EventEmitter { * WORK_TIME. * @type {!number} */ - this.currentStepTime = null; + this.currentStepTime = 1000 / 30; // Set an intial value for this.currentMSecs this.updateCurrentMSecs(); @@ -398,7 +416,7 @@ class Runtime extends EventEmitter { this.profiler = null; this.cloudOptions = { - limit: 100 + limit: 10 }; const newCloudDataManager = cloudDataManager(this.cloudOptions); @@ -420,6 +438,12 @@ class Runtime extends EventEmitter { */ this.canAddCloudVariable = newCloudDataManager.canAddCloudVariable; + /** + * A function which returns the number of cloud variables in the runtime. + * @returns {number} + */ + this.getNumberOfCloudVariables = newCloudDataManager.getNumberOfCloudVariables; + /** * A function that tracks a new cloud variable in the runtime, * updating the cloud variable limit. Calling this function will @@ -458,10 +482,16 @@ class Runtime extends EventEmitter { this.gandi = new Gandi(this); this._stageTarget = null; + /** + * Metadata about the platform this VM is part of. + */ + this.platform = Object.assign({}, platform); + + this._initScratchLink(); - // 60 to match default of compatibility mode off - // scratch-gui will set this to 30 - this.framerate = 60; + this.resetRunId(); + + this._stageTarget = null; this.addonBlocks = {}; @@ -490,10 +520,71 @@ class Runtime extends EventEmitter { this.debug = false; - this._animationFrame = this._animationFrame.bind(this); - this._animationFrameId = null; this._lastStepTime = Date.now(); this.interpolationEnabled = false; + + this._defaultStoredSettings = this._generateAllProjectOptions(); + + /** + * TW: We support a "packaged runtime" mode. This can be used when: + * - there will never be an editor attached such as scratch-gui or scratch-blocks + * - the project will never be exported with saveProjectSb3() + * - original costume and sound data is not needed + * In this mode, the runtime is able to discard large amounts of data and avoid some processing + * to make projects load faster and use less memory. + * This is not designed to protect projects from copying as someone can still copy the data that + * gets fed into the runtime in the first place. + * This mode is used by the TurboWarp Packager. + */ + this.isPackaged = false; + + /** + * Contains information about the external communication methods that the scripts inside the project + * can use to send data from inside the project to an external server. + * Do not update this directly. Use Runtime.setExternalCommunicationMethod() instead. + */ + this.externalCommunicationMethods = { + cloudVariables: false, + customExtensions: false + }; + this.on(Runtime.HAS_CLOUD_DATA_UPDATE, enabled => { + this.setExternalCommunicationMethod('cloudVariables', enabled); + }); + + /** + * If set to true, features such as reading colors from the user's webcam will be disabled + * when the project has access to any external communication method to protect user privacy. + * Requires TurboWarp/scratch-render. + * Do not update this directly. Use Runtime.setEnforcePrivacy() instead. + */ + this.enforcePrivacy = true; + + /** + * Internal map of opaque identifiers to the callback to run that function. + * @type {Map} + */ + this.extensionButtons = new Map(); + + /** + * Responsible for managing custom fonts. + */ + this.fontManager = new FontManager(this); + + /** + * Maps extension ID to a JSON-serializable value. + * @type {Object.} + */ + this.extensionStorage = {}; + + /** + * Total number of scratch-storage load() requests since the runtime was created or cleared. + */ + this.totalAssetRequests = 0; + + /** + * Total number of finished or errored scratch-storage load() requests since the runtime was created or cleared. + */ + this.finishedAssetRequests = 0; } /** @@ -607,6 +698,14 @@ class Runtime extends EventEmitter { return 'INTERPOLATION_CHANGED'; } + /** + * Event name for stage size changing. + * @const {string} + */ + static get STAGE_SIZE_CHANGED () { + return 'STAGE_SIZE_CHANGED'; + } + /** * Event name for compiler errors. * @const {string} @@ -651,6 +750,28 @@ class Runtime extends EventEmitter { return 'PROJECT_ASSETS_ASYNC_LOAD_DONE'; } + /** + * Event called before any block is executed. + */ + static get BEFORE_EXECUTE () { + return 'BEFORE_EXECUTE'; + } + + /** + * Event called after every block in the project has been executed. + */ + static get AFTER_EXECUTE () { + return 'AFTER_EXECUTE'; + } + + /** + * Event name for reporting asset download progress. Fired with finished, total + * @const {string} + */ + static get ASSET_PROGRESS () { + return 'ASSET_PROGRESS'; + } + /** * Event name when the project is started (threads may not necessarily be * running). @@ -1067,6 +1188,13 @@ class Runtime extends EventEmitter { return 'BLOCKS_NEED_UPDATE'; } + /** + * Event name when platform name inside a project does not match the runtime. + */ + static get PLATFORM_MISMATCH () { + return 'PLATFORM_MISMATCH'; + } + /** * How rapidly we try to step threads by default, in ms. */ @@ -1125,14 +1253,14 @@ class Runtime extends EventEmitter { */ _registerBlockPackages () { for (const packageName in defaultBlockPackages) { - if (defaultBlockPackages.hasOwnProperty(packageName)) { + if (Object.prototype.hasOwnProperty.call(defaultBlockPackages, packageName)) { // @todo pass a different runtime depending on package privilege? const packageObject = new (defaultBlockPackages[packageName])(this); // Collect primitives from package. if (packageObject.getPrimitives) { const packagePrimitives = packageObject.getPrimitives(); for (const op in packagePrimitives) { - if (packagePrimitives.hasOwnProperty(op)) { + if (Object.prototype.hasOwnProperty.call(packagePrimitives, op)) { this._primitives[op] = packagePrimitives[op].bind(packageObject); } @@ -1142,7 +1270,7 @@ class Runtime extends EventEmitter { if (packageObject.getHats) { const packageHats = packageObject.getHats(); for (const hatName in packageHats) { - if (packageHats.hasOwnProperty(hatName)) { + if (Object.prototype.hasOwnProperty.call(packageHats, hatName)) { this._hats[hatName] = packageHats[hatName]; } } @@ -1173,7 +1301,7 @@ class Runtime extends EventEmitter { * @private */ _makeExtensionMenuId (menuName, extensionId) { - return `${extensionId}_menu_${xmlEscape(menuName)}`; + return `${extensionId}_menu_${menuName}`; } /** @@ -1225,7 +1353,7 @@ class Runtime extends EventEmitter { this._fillExtensionCategory(categoryInfo, extensionInfo); for (const fieldTypeName in categoryInfo.customFieldTypes) { - if (extensionInfo.customFieldTypes.hasOwnProperty(fieldTypeName)) { + if (Object.prototype.hasOwnProperty.call(extensionInfo.customFieldTypes, fieldTypeName)) { const fieldTypeInfo = categoryInfo.customFieldTypes[fieldTypeName]; // Emit events for custom field types from extension @@ -1313,7 +1441,7 @@ class Runtime extends EventEmitter { categoryInfo.menuInfo = {}; for (const menuName in extensionInfo.menus) { - if (extensionInfo.menus.hasOwnProperty(menuName)) { + if (Object.prototype.hasOwnProperty.call(extensionInfo.menus, menuName)) { const menuInfo = extensionInfo.menus[menuName]; const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuInfo, categoryInfo); categoryInfo.menus.push(convertedMenu); @@ -1321,7 +1449,7 @@ class Runtime extends EventEmitter { } } for (const fieldTypeName in extensionInfo.customFieldTypes) { - if (extensionInfo.customFieldTypes.hasOwnProperty(fieldTypeName)) { + if (Object.prototype.hasOwnProperty.call(extensionInfo.customFieldTypes, fieldTypeName)) { const fieldType = extensionInfo.customFieldTypes[fieldTypeName]; const fieldTypeInfo = this._buildCustomFieldInfo( fieldTypeName, @@ -1347,7 +1475,7 @@ class Runtime extends EventEmitter { description: 'Button to open extensions docsURI' }))}" ` + 'callbackKey="OPEN_DOCUMENTATION" ' + - `web-class="docs-uri-${xmlEscape(extensionInfo.docsURI)}">`; + `callbackData="${xmlEscape(extensionInfo.docsURI)}">`; const block = { info: {}, xml @@ -1372,6 +1500,16 @@ class Runtime extends EventEmitter { edgeActivated: blockInfo.isEdgeActivated, restartExistingThreads: blockInfo.shouldRestartExistingThreads }; + } else if (blockInfo.blockType === BlockType.CONDITIONAL) { + this._flowing[opcode] = { + conditional: true, + loop: false + }; + } else if (blockInfo.blockType === BlockType.LOOP) { + this._flowing[opcode] = { + conditional: false, + loop: true + }; } } } catch (e) { @@ -1511,8 +1649,16 @@ class Runtime extends EventEmitter { return this._convertLabelForScratchBlocks(blockInfo); } + if (blockInfo.blockType === BlockType.LABEL) { + return this._convertLabelForScratchBlocks(blockInfo); + } + if (blockInfo.blockType === BlockType.BUTTON) { - return this._convertButtonForScratchBlocks(blockInfo); + return this._convertButtonForScratchBlocks(blockInfo, categoryInfo); + } + + if (blockInfo.blockType === BlockType.XML) { + return this._convertXmlForScratchBlocks(blockInfo); } return this._convertBlockForScratchBlocks(blockInfo, categoryInfo); @@ -1532,10 +1678,11 @@ class Runtime extends EventEmitter { type: extendedOpcode, inputsInline: true, category: categoryInfo.name, - colour: categoryInfo.color1, - colourSecondary: categoryInfo.color2, - colourTertiary: categoryInfo.color3, - tooltip: blockInfo.tooltip + colour: blockInfo.color1 ?? categoryInfo.color1, + colourSecondary: blockInfo.color2 ?? categoryInfo.color2, + colourTertiary: blockInfo.color3 ?? categoryInfo.color3, + tooltip: blockInfo.tooltip, + extensions: [], }; const context = { // TODO: store this somewhere so that we can map args appropriately after translation. @@ -1554,8 +1701,21 @@ class Runtime extends EventEmitter { // the category block icon. const iconURI = blockInfo.blockIconURI || categoryInfo.blockIconURI; + // All extension blocks have from_extension + // blockJSON.extensions.push('from_extension'); + + // // Allow easily detecting which blocks use default colors + // if ( + // blockJSON.colour === defaultExtensionColors[0] && + // blockJSON.colourSecondary === defaultExtensionColors[1] && + // blockJSON.colourTertiary === defaultExtensionColors[2] + // ) { + // blockJSON.extensions.push('default_extension_colors'); + // } + if (iconURI) { - blockJSON.extensions = ['scratch_extension']; + // scratch_extension is a misleading name - this is for fixing the icon rendering + blockJSON.extensions.push('scratch_extension'); blockJSON.message0 = '%1 %2'; const iconJSON = { type: 'field_image', @@ -1581,7 +1741,7 @@ class Runtime extends EventEmitter { } break; case BlockType.REPORTER: - blockJSON.output = 'String'; // TODO: distinguish number & string here? + blockJSON.output = blockInfo.allowDropAnywhere ? null : 'String'; // TODO: distinguish number & string here? blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_ROUND; break; case BlockType.BOOLEAN: @@ -1590,7 +1750,7 @@ class Runtime extends EventEmitter { break; case BlockType.HAT: case BlockType.EVENT: - if (!blockInfo.hasOwnProperty('isEdgeActivated')) { + if (!Object.prototype.hasOwnProperty.call(blockInfo, 'isEdgeActivated')) { // if absent, this property defaults to true blockInfo.isEdgeActivated = true; } @@ -1640,17 +1800,22 @@ class Runtime extends EventEmitter { } } - if (blockInfo.blockType === BlockType.REPORTER) { + if (blockInfo.blockType === BlockType.REPORTER || blockInfo.blockType === BlockType.BOOLEAN) { if (!blockInfo.disableMonitor && context.inputList.length === 0) { blockJSON.checkboxInFlyout = true; } - } else if (blockInfo.blockType === BlockType.LOOP) { + } else if ( + blockInfo.branchIconURI || ( + blockInfo.blockType === BlockType.LOOP && + !Object.prototype.hasOwnProperty.call(blockInfo, 'branchIconURI') + ) + ) { // Add icon to the bottom right of a loop block blockJSON[`lastDummyAlign${outLineNum}`] = 'RIGHT'; blockJSON[`message${outLineNum}`] = '%1'; blockJSON[`args${outLineNum}`] = [{ type: 'field_image', - src: './static/blocks-media/repeat.svg', // TODO: use a constant or make this configurable? + src: blockInfo.branchIconURI ?? 'media://repeat.svg', width: 24, height: 24, alt: '*', // TODO remove this since we don't use collapsed blocks in scratch @@ -1661,7 +1826,15 @@ class Runtime extends EventEmitter { const mutation = blockInfo.isDynamic ? `` : ''; const inputs = context.inputList.join(''); - const blockXML = `${mutation}${inputs}`; + const blockXML = `${mutation}${inputs}`; + + if (blockInfo.extensions) { + for (const extension of blockInfo.extensions) { + if (!blockJSON.extensions.includes(extension)) { + blockJSON.extensions.push(extension); + } + } + } return { info: context.blockInfo, @@ -1705,6 +1878,19 @@ class Runtime extends EventEmitter { }; } + /** + * Generate a label between blocks categories or sub-categories. + * @param {ExtensionBlockMetadata} blockInfo - the block to convert + * @returns {ConvertedBlockInfo} - the converted & original block information + * @private + */ + _convertLabelForScratchBlocks (blockInfo) { + return { + info: blockInfo, + xml: `` + }; + } + /** * Convert a button for scratch-blocks. A button has no opcode but specifies a callback name in the `func` field. * @param {ExtensionBlockMetadata} buttonInfo - the button to convert @@ -1713,27 +1899,43 @@ class Runtime extends EventEmitter { * @returns {ConvertedBlockInfo} - the converted & original button information * @private */ - _convertButtonForScratchBlocks (buttonInfo) { - // for now we only support these pre-defined callbacks handled in scratch-blocks - const supportedCallbackKeys = ['MAKE_A_LIST', 'MAKE_A_PROCEDURE', 'MAKE_A_VARIABLE']; - if (supportedCallbackKeys.indexOf(buttonInfo.func) < 0) { - // log.error(`Custom button callbacks not supported yet: ${buttonInfo.func}`); - } - + _convertButtonForScratchBlocks (buttonInfo, categoryInfo) { const extensionMessageContext = this.makeMessageContextForTarget(); const buttonText = maybeFormatMessage(buttonInfo.text, extensionMessageContext); - // powered by xigua start - if (typeof buttonInfo.onClick === 'function' && this.scratchBlocks && !buttonInfo.func) { - const randomKey = buttonInfo.func = Math.random(); - this.scratchBlocks.mainWorkspace.registerButtonCallback(randomKey, buttonInfo.onClick); + const nativeCallbackKeys = ['MAKE_A_LIST', 'MAKE_A_PROCEDURE', 'MAKE_A_VARIABLE']; + if (nativeCallbackKeys.includes(buttonInfo.func)) { + return { + info: buttonInfo, + xml: `` + }; } - // powered by xigua end + let id = `${categoryInfo.id}_${buttonInfo.func}`; + let callFunc = buttonInfo.callFunc; + if (typeof buttonInfo.onClick === 'function') { + id = `${categoryInfo.id}_btn_OnClick_${Math.random()}`; + callFunc = buttonInfo.onClick; + } + this.extensionButtons.set(id, callFunc); return { info: buttonInfo, - xml: `` + xml: `` }; } + _convertXmlForScratchBlocks (xmlInfo) { + return { + info: xmlInfo, + xml: xmlInfo.xml + }; + } + + handleExtensionButtonPress (buttonData) { + const callback = this.extensionButtons.get(buttonData); + callback(); + } + /** * Helper for _convertPlaceholdes which handles inline images which are a specialized case of block "arguments". * @param {object} argInfo Metadata about the inline image as specified by the extension @@ -1767,9 +1969,6 @@ class Runtime extends EventEmitter { * @private */ _convertPlaceholders (context, match, placeholder) { - // Sanitize the placeholder to ensure valid XML - placeholder = placeholder.replace(/[<"&]/, '_'); - // Determine whether the argument type is one of the known standard field types const argInfo = context.blockInfo.arguments[placeholder] || {}; let argTypeInfo = ArgumentTypeMap[argInfo.type] || {}; @@ -1797,8 +1996,8 @@ class Runtime extends EventEmitter { }; let defaultValue = - typeof argInfo.defaultValue === 'undefined' ? '' : - xmlEscape(maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString()); + typeof argInfo.defaultValue === 'undefined' ? null : + maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString(); // in new ArgumentType CCW_HAT_PARAMETER // use placeholder as local reporter blocks name @@ -1837,19 +2036,19 @@ class Runtime extends EventEmitter { // is the ScratchBlocks name for a block input. if (valueName) { - context.inputList.push(``); + context.inputList.push(``); } // The is a placeholder for a reporter and is visible when there's no reporter in this input. // Boolean inputs don't need to specify a shadow in the XML. if (shadowType) { - context.inputList.push(``); + context.inputList.push(``); } // A displays a dynamic value: a user-editable text field, a drop-down menu, etc. // Leave out the field if defaultValue or fieldName are not specified - if (defaultValue && fieldName) { - context.inputList.push(`${defaultValue}`); + if (defaultValue !== null && fieldName) { + context.inputList.push(`${xmlEscape(defaultValue)}`); } if (shadowType) { @@ -1879,7 +2078,7 @@ class Runtime extends EventEmitter { */ getBlocksXML (target) { // eslint-disable-next-line max-len - return this._blockInfo/* powered by xigua start */.filter(({onlyVisibleOnShortcut}) => window.__XIGUA_SHORTCUT || Boolean(!onlyVisibleOnShortcut))/* powered by xigua end */.map(categoryInfo => { + return this._blockInfo/* powered by xigua start */.filter(({onlyVisibleOnShortcut}) => global.__XIGUA_SHORTCUT || Boolean(!onlyVisibleOnShortcut))/* powered by xigua end */.map(categoryInfo => { const {name, color1, color2} = categoryInfo; // Filter out blocks that aren't supposed to be shown on this target, as determined by the block info's // `hideFromPalette` and `filter` properties. @@ -1896,7 +2095,7 @@ class Runtime extends EventEmitter { return blockFilterIncludesTarget && !block.info.hideFromPalette; }); - const colorXML = `colour="${color1}" secondaryColour="${color2}"`; + const colorXML = `colour="${xmlEscape(color1)}" secondaryColour="${xmlEscape(color2)}"`; // Use a menu icon if there is one. Otherwise, use the block icon. If there's no icon, // the category menu will show its default colored circle. @@ -1907,7 +2106,7 @@ class Runtime extends EventEmitter { menuIconURI = categoryInfo.blockIconURI; } const menuIconXML = menuIconURI ? - `iconURI="${menuIconURI}"` : ''; + `iconURI="${xmlEscape(menuIconURI)}"` : ''; let statusButtonXML = ''; let extensionTipXML = ''; @@ -1918,17 +2117,19 @@ class Runtime extends EventEmitter { if (categoryInfo.warningTipText) { extensionTipXML = `warningTipText="${categoryInfo.warningTipText}"`; } + let xml = ``; + xml += paletteBlocks.map(block => block.xml).join(''); + xml += ''; + return { id: categoryInfo.id, - xml: ` - ${paletteBlocks.map(block => block.xml).join('')} - ` + xml }; }); } @@ -1941,6 +2142,38 @@ class Runtime extends EventEmitter { (result, categoryInfo) => result.concat(categoryInfo.blocks.map(blockInfo => blockInfo.json)), []); } + /** + * One-time initialization for Scratch Link support. + */ + _initScratchLink () { + // Check that we're actually in a real browser, not Node.js or JSDOM, and we have a valid-looking origin. + // note that `if (self?....)` will throw if `self` is undefined, so check for that first! + if (typeof self !== 'undefined' && + typeof document !== 'undefined' && + document.getElementById && + self.origin && + self.origin !== 'null' && // note this is a string comparison, not a null check + self.navigator && + self.navigator.userAgent && + !( + self.navigator.userAgent.includes('Node.js') || + self.navigator.userAgent.includes('jsdom') + ) + ) { + // Create a script tag for the Scratch Link browser extension, unless one already exists + const scriptElement = document.getElementById('scratch-link-extension-script'); + if (!scriptElement) { + const script = document.createElement('script'); + script.id = 'scratch-link-extension-script'; + document.body.appendChild(script); + + // Tell the browser extension to inject its script. + // If the extension isn't present or isn't active, this will do nothing. + self.postMessage('inject-scratch-link-script', self.origin); + } + } + } + /** * Get a scratch link socket. * @param {string} type Either BLE or BT @@ -1966,7 +2199,11 @@ class Runtime extends EventEmitter { * @returns {ScratchLinkSocket} The new scratch link socket (a WebSocket object) */ _defaultScratchLinkSocketFactory (type) { - return new ScratchLinkWebSocket(type); + const Scratch = self.Scratch; + const ScratchLinkSafariSocket = Scratch && Scratch.ScratchLinkSafariSocket; + // detect this every time in case the user turns on the extension after loading the page + const useSafariSocket = ScratchLinkSafariSocket && ScratchLinkSafariSocket.isSafariHelperCompatible(); + return useSafariSocket ? new ScratchLinkSafariSocket(type) : new ScratchLinkWebSocket(type); } /** @@ -2046,7 +2283,7 @@ class Runtime extends EventEmitter { * @return {boolean} True if the op is known to be a hat. */ getIsHat (opcode) { - return this._hats.hasOwnProperty(opcode); + return Object.prototype.hasOwnProperty.call(this._hats, opcode); } /** @@ -2055,7 +2292,7 @@ class Runtime extends EventEmitter { * @return {boolean} True if the op is known to be a edge-activated hat. */ getIsEdgeActivatedHat (opcode) { - return this._hats.hasOwnProperty(opcode) && + return Object.prototype.hasOwnProperty.call(this._hats, opcode) && this._hats[opcode].edgeActivated; } @@ -2066,7 +2303,6 @@ class Runtime extends EventEmitter { */ attachAudioEngine (audioEngine) { this.audioEngine = audioEngine; - require('./tw-experimental-audio-optimizations')(audioEngine); } /** @@ -2077,6 +2313,7 @@ class Runtime extends EventEmitter { this.renderer = renderer; this.renderer.setLayerGroupOrdering(StageLayering.LAYER_GROUPS); this.renderer.offscreenTouching = !this.runtimeOptions.fencing; + this.updatePrivacy(); } /** @@ -2095,6 +2332,31 @@ class Runtime extends EventEmitter { attachStorage (storage) { this.storage = storage; this.storage.onLoadCostumeError = this.storage.onLoadCostumeError || (() => {}); + + if (this.isPackaged) { + // In packaged runtime mode, generating real asset IDs is a waste of time. + // We do still want to preserve every asset having a unique ID. + const originalCreateAsset = storage.createAsset; + let assetIdCounter = 0; + // eslint-disable-next-line no-unused-vars + storage.createAsset = function packagedCreateAsset (assetType, dataFormat, data, assetId, generateId) { + if (!assetId) { + assetId = (++assetIdCounter).toString(); + } + return originalCreateAsset.call( + this, + assetType, + dataFormat, + data, + assetId, + // Never generate real asset ID + false + ); + }; + } + + fetchWithTimeout.setFetch(storage.scratchFetch.scratchFetch); + this.resetRunId(); } // powered by xigua start @@ -2319,7 +2581,7 @@ class Runtime extends EventEmitter { */ startHats (requestedHatOpcode, optMatchFields, optTarget, hatParam) { - if (!this._hats.hasOwnProperty(requestedHatOpcode)) { + if (!Object.prototype.hasOwnProperty.call(this._hats, requestedHatOpcode)) { // No known hat with this opcode. return; } @@ -2330,8 +2592,10 @@ class Runtime extends EventEmitter { for (const opts in optMatchFields) { + if (!Object.prototype.hasOwnProperty.call(optMatchFields, opts) || + typeof optMatchFields[opts] !== 'string') + continue; // The value of the field might be non-string data types - if (!optMatchFields.hasOwnProperty(opts) || typeof optMatchFields[opts] !== 'string') continue; optMatchFields[opts] = optMatchFields[opts].toUpperCase(); } @@ -2413,8 +2677,15 @@ class Runtime extends EventEmitter { // For compatibility with Scratch 2, edge triggered hats need to be processed before // threads are stepped. See ScratchRuntime.as for original implementation newThreads.forEach(thread => { - // tw: do not step compiled threads, the hat block can't be executed - if (!thread.isCompiled) { + if (thread.isCompiled) { + if (thread.executableHat) { + // It is quite likely that we are currently executing a block, so make sure + // that we leave the compiler's state intact at the end. + compilerExecute.saveGlobalState(); + compilerExecute(thread); + compilerExecute.restoreGlobalState(); + } + } else { execute(this.sequencer, thread); thread.goToNextBlock(); } @@ -2449,6 +2720,7 @@ class Runtime extends EventEmitter { }); this.targets.map(this.disposeTarget, this); + this.extensionStorage = {}; // tw: explicitly emit a MONITORS_UPDATE instead of relying on implicit behavior of _step() const emptyMonitorState = OrderedMap({}); if (!emptyMonitorState.equals(this._monitorState)) { @@ -2461,6 +2733,7 @@ class Runtime extends EventEmitter { if (this.renderer && this.renderer.resetBuiltinManager) { this.renderer.resetBuiltinManager(); } + this.fontManager.clear(); // @todo clear out extensions? turboMode? etc. // *********** Cloud ******************* @@ -2478,11 +2751,13 @@ class Runtime extends EventEmitter { const newCloudDataManager = cloudDataManager(this.cloudOptions); this.hasCloudData = newCloudDataManager.hasCloudVariables; this.canAddCloudVariable = newCloudDataManager.canAddCloudVariable; + this.getNumberOfCloudVariables = newCloudDataManager.getNumberOfCloudVariables; this.addCloudVariable = this._initializeAddCloudVariable(newCloudDataManager); this.removeCloudVariable = this._initializeRemoveCloudVariable(newCloudDataManager); this._blockInfo = []; this.gandi.clear(); + this.resetProgress(); } // powered by xigua start @@ -2598,6 +2873,19 @@ class Runtime extends EventEmitter { } } + /** + * Reset the Run ID. Call this any time the project logically starts, stops, or changes identity. + */ + resetRunId () { + if (!this.storage) { + // see also: attachStorage + return; + } + + const newRunId = uuid.v1(); + this.storage.scratchFetch.setMetadata(this.storage.scratchFetch.RequestMetadata.RunId, newRunId); + } + /** * Start all threads that start with the green flag. */ @@ -2625,7 +2913,7 @@ class Runtime extends EventEmitter { const newTargets = []; for (let i = 0; i < this.targets.length; i++) { this.targets[i].onStopAll(); - if (this.targets[i].hasOwnProperty('isOriginal') && + if (Object.prototype.hasOwnProperty.call(this.targets[i], 'isOriginal') && !this.targets[i].isOriginal) { this.targets[i].dispose(); } else { @@ -2640,11 +2928,11 @@ class Runtime extends EventEmitter { // Remove all remaining threads from executing in the next tick. this.threads = []; this.threadMap.clear(); - } - _animationFrame () { - this._animationFrameId = _requestAnimationFrame(this._animationFrame); + this.resetRunId(); + } + _renderInterpolatedPositions () { const frameStarted = this._lastStepTime; const now = Date.now(); const timeSinceStart = now - frameStarted; @@ -2688,7 +2976,7 @@ class Runtime extends EventEmitter { // Find all edge-activated hats, and add them to threads to be evaluated. for (const hatType in this._hats) { - if (!this._hats.hasOwnProperty(hatType)) continue; + if (!Object.prototype.hasOwnProperty.call(this._hats, hatType)) continue; const hat = this._hats[hatType]; if (hat.edgeActivated) { this.startHats(hatType); @@ -2702,10 +2990,12 @@ class Runtime extends EventEmitter { } this.profiler.start(stepThreadsProfilerId); } + this.emit(Runtime.BEFORE_EXECUTE); const doneThreads = this.sequencer.stepThreads(); if (this.profiler !== null) { this.profiler.stop(); } + this.emit(Runtime.AFTER_EXECUTE); this._updateGlows(doneThreads); // Add done threads so that even if a thread finishes within 1 frame, the green // flag will still indicate that a script ran. @@ -2724,7 +3014,9 @@ class Runtime extends EventEmitter { this.profiler.start(rendererDrawProfilerId); } // tw: do not draw if document is hidden or a rAF loop is running - if (!document.hidden && this._animationFrameId === null) { + // Checking for the animation frame loop is more reliable than using + // interpolationEnabled in some edge cases + if (!document.hidden && !this.frameLoop._interpolationAnimation) { this.renderer.draw(); } if (this.profiler !== null) { @@ -2809,14 +3101,12 @@ class Runtime extends EventEmitter { */ setFramerate (framerate) { // Setting framerate to anything greater than this is unnecessary and can break the sequencer - // Additonally, the JS spec says intervals can't run more than once every 4ms anyways + // Additionally, the JS spec says intervals can't run more than once every 4ms (250/s) anyways if (framerate > 250) framerate = 250; - this.framerate = framerate; - if (this._steppingInterval) { - clearInterval(this._steppingInterval); - this._steppingInterval = null; - this.start(); - } + // Convert negative framerates to 1FPS + // Note that 0 is a special value which means "matching device screen refresh rate" + if (framerate < 0) framerate = 1; + this.frameLoop.setFramerate(framerate); this.emit(Runtime.FRAMERATE_CHANGED, framerate); } @@ -2826,10 +3116,7 @@ class Runtime extends EventEmitter { */ setInterpolation (interpolationEnabled) { this.interpolationEnabled = interpolationEnabled; - if (this._steppingInterval) { - this.stop(); - this.start(); - } + this.frameLoop.setInterpolation(this.interpolationEnabled); this.emit(Runtime.INTERPOLATION_CHANGED, interpolationEnabled); } @@ -2870,6 +3157,59 @@ class Runtime extends EventEmitter { setIsPlayerOnly (isPlayerOnly) { this.isPlayerOnly = isPlayerOnly; } + /** + * Change width and height of stage. This will also inform the renderer of the new stage size. + * @param {number} width New stage width + * @param {number} height New stage height + */ + setStageSize (width, height) { + width = Math.round(Math.max(1, width)); + height = Math.round(Math.max(1, height)); + if (this.stageWidth !== width || this.stageHeight !== height) { + const deltaX = width - this.stageWidth; + const deltaY = height - this.stageHeight; + // Preserve monitor location relative to the center of the stage + if (this._monitorState.size > 0) { + const offsetX = deltaX / 2; + const offsetY = deltaY / 2; + for (const monitor of this._monitorState.valueSeq()) { + const newMonitor = monitor + .set('x', monitor.get('x') + offsetX) + .set('y', monitor.get('y') + offsetY); + this.requestUpdateMonitor(newMonitor); + } + this.emit(Runtime.MONITORS_UPDATE, this._monitorState); + } + + this.stageWidth = width; + this.stageHeight = height; + if (this.renderer) { + this.renderer.setStageSize( + -width / 2, + width / 2, + -height / 2, + height / 2 + ); + } + } + this.emit(Runtime.STAGE_SIZE_CHANGED, width, height); + } + + // eslint-disable-next-line no-unused-vars + setInEditor (inEditor) { + // no-op + } + + /** + * TW: Enable "packaged runtime" mode. This is a one-way operation. + */ + convertToPackagedRuntime () { + if (this.storage) { + throw new Error('convertToPackagedRuntime must be called before attachStorage'); + } + + this.isPackaged = true; + } /** * tw: Reset the cache of all block containers. @@ -2888,46 +3228,58 @@ class Runtime extends EventEmitter { * Add an "addon block" * @param {object} options Options object * @param {string} options.procedureCode The ID of the block - * @param {function} options.callback The callback, called with (args, BlockUtility) - * @param {string[]} options.arguments Names of the arguments accepted - * @param {string} options.color Primary color - * @param {string} options.secondaryColor Secondary color + * @param {function} options.callback The callback, called with (args, BlockUtility). May return a promise. + * @param {string[]} [options.arguments] Names of the arguments accepted. Optional if no arguments. + * @param {boolean} [options.hidden] True to not include this block in the block palette + * @param {1|2} [options.return] 1 for round reporter, 2 for boolean reported, leave empty for statement. */ addAddonBlock (options) { const procedureCode = options.procedureCode; - const names = options.arguments; - const ids = options.arguments.map((_, i) => `arg${i}`); - const defaults = options.arguments.map(() => ''); + + const argumentNames = options.arguments || []; + const names = argumentNames; + const ids = argumentNames.map((_, i) => `arg${i}`); + const defaults = argumentNames.map(() => ''); this.addonBlocks[procedureCode] = { namesIdsDefaults: [names, ids, defaults], ...options }; - const ID = 'a-b'; - let blockInfo = this._blockInfo.find(i => i.id === ID); - if (!blockInfo) { - blockInfo = { - id: ID, - name: 'Addons', - color1: options.color, - color2: options.secondaryColor, - color3: options.secondaryColor, - blocks: [], - customFieldTypes: {}, - menus: [] - }; - this._blockInfo.unshift(blockInfo); - } - blockInfo.blocks.push({ - info: {}, - xml: - '' - }); + if (!options.hidden) { + const ID = 'a-b'; + let blockInfo = this._blockInfo.find(i => i.id === ID); + if (!blockInfo) { + // eslint-disable-next-line max-len + const ICON = ''; + blockInfo = { + id: ID, + name: maybeFormatMessage({ + id: 'tw.blocks.addons', + default: 'Addons', + description: 'Name of the addon block category in the extension list' + }), + color1: '#29beb8', + color2: '#3aa8a4', + color3: '#3aa8a4', + menuIconURI: `data:image/svg+xml;,${encodeURIComponent(ICON)}`, + blocks: [], + customFieldTypes: {}, + menus: [] + }; + this._blockInfo.unshift(blockInfo); + } + blockInfo.blocks.push({ + info: {}, + xml: + '' + }); + } this.resetAllCaches(); } @@ -2987,20 +3339,47 @@ class Runtime extends EventEmitter { if (parsed.hq && this.renderer) { this.renderer.setUseHighQualityRender(true); } + const storedWidth = +parsed.width || this.stageWidth; + const storedHeight = +parsed.height || this.stageHeight; + if (storedWidth !== this.stageWidth || storedHeight !== this.stageHeight) { + this.setStageSize(storedWidth, storedHeight); + } } - generateProjectOptions () { - const options = {}; - options.framerate = this.framerate; - options.runtimeOptions = this.runtimeOptions; - options.interpolation = this.interpolationEnabled; - options.turbo = this.turboMode; - options.hq = this.renderer ? this.renderer.useHighQualityRender : false; - return options; + _generateAllProjectOptions () { + return { + framerate: this.frameLoop.framerate, + runtimeOptions: this.runtimeOptions, + interpolation: this.interpolationEnabled, + turbo: this.turboMode, + hq: this.renderer ? this.renderer.useHighQualityRender : false, + width: this.stageWidth, + height: this.stageHeight + }; + } + + generateDifferingProjectOptions () { + const difference = (oldObject, newObject) => { + const result = {}; + for (const key of Object.keys(newObject)) { + const newValue = newObject[key]; + const oldValue = oldObject[key]; + if (typeof newValue === 'object' && newValue) { + const valueDiffering = difference(oldValue, newValue); + if (Object.keys(valueDiffering).length > 0) { + result[key] = valueDiffering; + } + } else if (newValue !== oldValue) { + result[key] = newValue; + } + } + return result; + }; + return difference(this._defaultStoredSettings, this._generateAllProjectOptions()); } storeProjectOptions () { - const options = this.generateProjectOptions(); + const options = this.generateDifferingProjectOptions(); // TODO: translate const text = `Configuration for https://turbowarp.org/\nYou can move, resize, and minimize this comment, but don't edit it by hand. This comment can be deleted to remove the stored settings.\n${ExtendedJSON.stringify(options)}${COMMENT_CONFIG_MAGIC}`; const existingComment = this.findProjectOptionsComment(); @@ -3041,9 +3420,9 @@ class Runtime extends EventEmitter { */ _updateGlows (optExtraThreads) { const searchThreads = []; - searchThreads.push.apply(searchThreads, this.threads); + searchThreads.push(...this.threads); if (optExtraThreads) { - searchThreads.push.apply(searchThreads, optExtraThreads); + searchThreads.push(...optExtraThreads); } // Set of scripts that request a glow this frame. const requestedGlowsThisFrame = []; @@ -3368,10 +3747,11 @@ class Runtime extends EventEmitter { } /** - * Report that the project has loaded in the Virtual Machine. + * Handle that the project has loaded in the Virtual Machine. */ - emitProjectLoaded () { + handleProjectLoaded () { this.emit(Runtime.PROJECT_LOADED); + this.resetRunId(); } /** @@ -3614,37 +3994,27 @@ class Runtime extends EventEmitter { */ start () { // Do not start if we are already running - if (this._steppingInterval) return; - - if (this.interpolationEnabled) { - this._animationFrameId = _requestAnimationFrame(this._animationFrame); - } - - const interval = 1000 / this.framerate; - this.currentStepTime = interval; - this._steppingInterval = setInterval(() => { - this._step(); - }, interval); + if (this.frameLoop.running) return; + this.frameLoop.start(); this.emit(Runtime.RUNTIME_STARTED); } /** - * tw: Stop the tick loop - * Note: This only stops the loop. It will not stop any threads the next time the VM starts + * @deprecated Used by old versions of TurboWarp. Superceded by upstream's quit() */ stop () { - if (!this._steppingInterval) { - return; - } - clearInterval(this._steppingInterval); - this._steppingInterval = null; + this.quit(); + } - // tw: also cancel the animation frame loop - if (this._animationFrameId !== null) { - _cancelAnimationFrame(this._animationFrameId); - this._animationFrameId = null; + /** + * Quit the Runtime, clearing any handles which might keep the process alive. + * Do not use the runtime after calling this method. This method is meant for test shutdown. + */ + quit () { + if (!this.frameLoop.running) { + return; } - + this.frameLoop.stop(); this.emit(Runtime.RUNTIME_STOPPED); } @@ -3814,6 +4184,71 @@ class Runtime extends EventEmitter { // If the target cannot be found by id, return a rejected promise return Promise.reject(); } + + updatePrivacy () { + const enforceRestrictions = ( + this.enforcePrivacy && + Object.values(this.externalCommunicationMethods).some(i => i) + ); + if (this.renderer && this.renderer.setPrivateSkinAccess) { + this.renderer.setPrivateSkinAccess(!enforceRestrictions); + } + } + + /** + * @param {boolean} enabled True if restrictions should be enforced to protect user privacy. + */ + setEnforcePrivacy (enabled) { + this.enforcePrivacy = enabled; + this.updatePrivacy(); + } + + /** + * @param {string} method Name of the method in Runtime.externalCommunicationMethods + * @param {boolean} enabled True if the feature is enabled. + */ + setExternalCommunicationMethod (method, enabled) { + if (!Object.prototype.hasOwnProperty.call(this.externalCommunicationMethods, method)) { + throw new Error(`Unknown method: ${method}`); + } + this.externalCommunicationMethods[method] = enabled; + this.updatePrivacy(); + } + + emitAssetProgress () { + this.emit(Runtime.ASSET_PROGRESS, this.finishedAssetRequests, this.totalAssetRequests); + } + + resetProgress () { + this.finishedAssetRequests = 0; + this.totalAssetRequests = 0; + this.emitAssetProgress(); + } + + /** + * Wrap an asset loading promise with progress support. + * @template T + * @param {() => Promise} callback + * @returns {Promise} + */ + wrapAssetRequest (callback) { + this.totalAssetRequests++; + this.emitAssetProgress(); + + const onSuccess = result => { + this.finishedAssetRequests++; + this.emitAssetProgress(); + return result; + }; + + const onError = error => { + this.finishedAssetRequests++; + this.emitAssetProgress(); + throw error; + }; + + return callback().then(onSuccess, onError); + } } /** diff --git a/src/engine/sequencer.js b/src/engine/sequencer.js index 01652698..c512f456 100644 --- a/src/engine/sequencer.js +++ b/src/engine/sequencer.js @@ -71,7 +71,7 @@ class Sequencer { stepThreads () { // Work time is 75% of the thread stepping interval. const WORK_TIME = 0.75 * this.runtime.currentStepTime; - // For compatibility with Scatch 2, update the millisecond clock + // For compatibility with Scratch 2, update the millisecond clock // on the Runtime once per step (see Interpreter.as in Scratch 2 // for original use of `currentMSecs`) this.runtime.updateCurrentMSecs(); @@ -128,9 +128,6 @@ class Sequencer { } this.stepThread(activeThread); activeThread.warpTimer = null; - if (activeThread.isKilled) { - i--; // if the thread is removed from the list (killed), do not increase index - } } if (activeThread.status === Thread.STATUS_RUNNING) { numActiveThreads++; @@ -239,6 +236,9 @@ class Sequencer { } else if (thread.status === Thread.STATUS_YIELD_TICK) { // stepThreads will reset the thread to Thread.STATUS_RUNNING return; + } else if (thread.status === Thread.STATUS_DONE) { + // Nothing more to execute. + return; } // If no control flow has happened, switch to next block. if (thread.peekStack() === currentBlockId && !thread.peekStackFrame().waitingReporter) { diff --git a/src/engine/target.js b/src/engine/target.js index 69dcc385..0acce862 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -11,8 +11,6 @@ const StringUtil = require('../util/string-util'); const VariableUtil = require('../util/variable-util'); const formatMessage = require('format-message'); -require('../util/tw-emit-fast'); - /** * @fileoverview * A Target is an abstract "code-running" object for the Scratch VM. @@ -85,6 +83,12 @@ class Target extends EventEmitter { * @type {Object.} */ this._edgeActivatedHatValues = {}; + + /** + * Maps extension ID to a JSON-serializable value. + * @type {Object.} + */ + this.extensionStorage = {}; } /** @@ -116,7 +120,7 @@ class Target extends EventEmitter { } hasEdgeActivatedValue (blockId) { - return this._edgeActivatedHatValues.hasOwnProperty(blockId); + return Object.prototype.hasOwnProperty.call(this._edgeActivatedHatValues, blockId); } /** @@ -201,13 +205,13 @@ class Target extends EventEmitter { */ lookupVariableById (id) { // If we have a local copy, return it. - if (this.variables.hasOwnProperty(id)) { + if (Object.prototype.hasOwnProperty.call(this.variables, id)) { return this.variables[id]; } // If the stage has a global copy, return it. if (this.runtime && !this.isStage) { const stage = this.runtime.getTargetForStage(); - if (stage && stage.variables.hasOwnProperty(id)) { + if (stage && Object.prototype.hasOwnProperty.call(stage.variables, id)) { return stage.variables[id]; } } @@ -301,7 +305,7 @@ class Target extends EventEmitter { * Additional checks are made that the variable can be created as a cloud variable. */ createVariable (id, name, type, isCloud, isRemoteOperation) { - if (!this.variables.hasOwnProperty(id)) { + if (!Object.prototype.hasOwnProperty.call(this.variables, id)) { const newVariable = new Variable(id, name, type, false, this.id); if (isCloud && this.isStage && this.runtime.canAddCloudVariable()) { newVariable.isCloud = true; @@ -331,7 +335,7 @@ class Target extends EventEmitter { * @param {boolean} isRemoteOperation - set to true if this is a remote operation */ createComment (id, blockId, text, x, y, width, height, minimized, isRemoteOperation) { - if (!this.comments.hasOwnProperty(id)) { + if (!Object.prototype.hasOwnProperty.call(this.comments, id)) { const newComment = new Comment(id, text, x, y, width, height, minimized); if (blockId) { @@ -339,14 +343,13 @@ class Target extends EventEmitter { const blockWithComment = this.blocks.getBlock(blockId); if (blockWithComment) { blockWithComment.comment = id; - this.blocks._blocks[blockId] = blockWithComment; } else { log.warn(`Could not find block with id ${blockId } associated with commentId: ${id}`); } } this.comments[id] = newComment; - + if (!isRemoteOperation) { this.runtime.emitTargetCommentsChanged(this.originalTargetId, ['add', id, newComment]); } @@ -380,7 +383,7 @@ class Target extends EventEmitter { * @param {string} newName New name for the variable. */ renameVariable (id, newName) { - if (this.variables.hasOwnProperty(id)) { + if (Object.prototype.hasOwnProperty.call(this.variables, id)) { const variable = this.variables[id]; if (variable.id === id) { const oldName = variable.name; @@ -432,7 +435,7 @@ class Target extends EventEmitter { * @param {boolean} isRemoteOperation - set to true if this is a remote operation */ deleteVariable (id, isRemoteOperation) { - if (this.variables.hasOwnProperty(id)) { + if (Object.prototype.hasOwnProperty.call(this.variables, id)) { // Get info about the variable before deleting it const deletedVariableName = this.variables[id].name; const deletedVariableType = this.variables[id].type; @@ -500,7 +503,7 @@ class Target extends EventEmitter { * the original variable was not found. */ duplicateVariable (id, optKeepOriginalId, targetId) { - if (this.variables.hasOwnProperty(id)) { + if (Object.prototype.hasOwnProperty.call(this.variables, id)) { const originalVariable = this.variables[id]; const newVariable = new Variable( optKeepOriginalId ? id : null, // conditionally keep original id or generate a new one @@ -835,7 +838,7 @@ class Target extends EventEmitter { const unreferencedLocalVarIds = []; if (Object.keys(this.variables).length > 0) { for (const localVarId in this.variables) { - if (!this.variables.hasOwnProperty(localVarId)) continue; + if (!Object.prototype.hasOwnProperty.call(this.variables, localVarId)) continue; if (!allReferences[localVarId]) unreferencedLocalVarIds.push(localVarId); } } @@ -860,7 +863,7 @@ class Target extends EventEmitter { if (this.lookupVariableById(varId)) { // Found a variable with the id in either the target or the stage, // figure out which one. - if (this.variables.hasOwnProperty(varId)) { + if (Object.prototype.hasOwnProperty.call(this.variables, varId)) { // If the target has the variable, then check whether the stage // has one with the same name and type. If it does, then rename // this target specific variable so that there is a distinction. @@ -934,6 +937,11 @@ class Target extends EventEmitter { } } + // compatible with extensions + emitFast (...args) { + this.emit(args); + } + } module.exports = Target; diff --git a/src/engine/thread.js b/src/engine/thread.js index 5775dcf0..84099c06 100644 --- a/src/engine/thread.js +++ b/src/engine/thread.js @@ -63,6 +63,13 @@ class _StackFrame { * @type {Object} */ this.executionContext = null; + + /** + * Internal block object being executed. This is *not* the same as the object found + * in target.blocks. + * @type {object} + */ + this.op = null; } /** @@ -79,6 +86,8 @@ class _StackFrame { this.waitingReporter = null; this.params = null; this.executionContext = null; + this.op = null; + return this; } @@ -196,11 +205,6 @@ class Thread { // compiler data // these values only make sense if isCompiled == true this.timer = null; - /** - * Warp level - * @type {number} - */ - this.warp = 0; /** * The thread's generator. * @type {Generator} @@ -218,6 +222,8 @@ class Thread { * @type {Object} */ this.hatParam = null; + this.executableHat = false; + this.compatibilityStackFrame = null; } /** @@ -346,13 +352,21 @@ class Thread { if (!block && globalTarget) { block = globalTarget.blocks.getBlock(blockID); } - - if (typeof block !== 'undefined' && - (block.opcode === 'procedures_call' || - block.opcode === 'procedures_call_with_return' || - stackFrame.waitingReporter)) { + if (stackFrame.waitingReporter) { break; } + const isProcedureCall = block && + (block.opcode === 'procedures_call' || block.opcode === 'procedures_call_with_return'); + // Command form of procedures_call + if (isProcedureCall) { + // By definition, if we get here, the procedure is done, so skip ahead so + // the arguments won't be re-evaluated and then discarded as frozen state + // about which arguments have been evaluated is lost. + // This fixes https://github.com/TurboWarp/scratch-vm/issues/201 + this.goToNextBlock(); + break; + } + this.popStack(); blockID = this.peekStack(); } @@ -444,7 +458,7 @@ class Thread { if (frame.params === null) { continue; } - if (frame.params.hasOwnProperty(paramName)) { + if (Object.prototype.hasOwnProperty.call(frame.params, paramName)) { return frame.params[paramName]; } return null; @@ -492,7 +506,15 @@ class Thread { let callCount = 5; // Max number of enclosing procedure calls to examine. const sp = this.stackFrames.length - 1; for (let i = sp - 1; i >= 0; i--) { - const block = this.stackFrames[i].op; + // Cached block objects in op, not just id + // it cached when execute(),make sure we can get the block in any thread context + // because global procedure may not in the this.target.blocks + let block = this.stackFrames[i].op + if (block && block.id && !block.opcode) { + // compatible with not cached op, such as unit test + // only found block id, get block object + block = this.target.blocks.getBlock(block.id); + } if ((block.opcode === 'procedures_call' || block.opcode === 'procedures_call_with_return') && block.mutation.proccode === procedureCode) { return true; @@ -515,10 +537,15 @@ class Thread { this.triedToCompile = true; + // stackClick === true disables hat block generation + // It would be great to cache these separately, but for now it's easiest to just disable them to avoid + // cached versions of scripts breaking projects. + const canCache = !this.stackClick; + const topBlock = this.topBlock; // Flyout blocks are stored in a special block container. const blocks = this.blockContainer.getBlock(topBlock) ? this.blockContainer : this.target.runtime.flyoutBlocks; - const cachedResult = blocks.getCachedCompileResult(topBlock); + const cachedResult = canCache && blocks.getCachedCompileResult(topBlock); // If there is a cached error, do not attempt to recompile. if (cachedResult && !cachedResult.success) { return; @@ -530,13 +557,17 @@ class Thread { } else { try { result = compile(this); - blocks.cacheCompileResult(topBlock, result); + if (canCache) { + blocks.cacheCompileResult(topBlock, result); + } } catch (error) { // @ts-ignore if (typeof DEPLOY_ENV !== 'undefined' && DEPLOY_ENV !== 'prod') { log.error('cannot compile script', this.target.getName(), error); } - blocks.cacheCompileError(topBlock, error); + if (canCache) { + blocks.cacheCompileError(topBlock, error); + } this.target.runtime.emitCompileError(this.target, error); return; } @@ -549,6 +580,8 @@ class Thread { this.generator = result.startingFunction(this)(); + this.executableHat = result.executableHat; + if (!this.blockContainer.forceNoGlow) { this.blockGlowInFrame = this.topBlock; this.requestScriptGlowInFrame = true; @@ -558,4 +591,7 @@ class Thread { } } +// for extensions +Thread._StackFrame = _StackFrame; + module.exports = Thread; diff --git a/src/engine/tw-experimental-audio-optimizations.js b/src/engine/tw-experimental-audio-optimizations.js deleted file mode 100644 index 5e6c1cc8..00000000 --- a/src/engine/tw-experimental-audio-optimizations.js +++ /dev/null @@ -1,14 +0,0 @@ -const optimize = audioEngine => { - audioEngine.effects.forEach(Effect => { - const originalSet = Effect.prototype._set; - Effect.prototype._set = function (value) { - if (this.__value === value) { - return; - } - this.__value = value; - originalSet.call(this, value); - }; - }); -}; - -module.exports = optimize; diff --git a/src/engine/tw-font-manager.js b/src/engine/tw-font-manager.js new file mode 100644 index 00000000..b58eeffc --- /dev/null +++ b/src/engine/tw-font-manager.js @@ -0,0 +1,230 @@ +const EventEmitter = require('events'); +const AssetUtil = require('../util/tw-asset-util'); +const StringUtil = require('../util/string-util'); +const log = require('../util/log'); + +/** + * @typedef InternalFont + * @property {boolean} system True if the font is built in to the system + * @property {string} family The font's name + * @property {string} fallback Fallback font family list + * @property {Asset} [asset] scratch-storage asset if system: false + */ + +class FontManager extends EventEmitter { + /** + * @param {Runtime} runtime + */ + constructor (runtime) { + super(); + this.runtime = runtime; + /** @type {Array} */ + this.fonts = []; + } + + /** + * @param {string} family An unknown font family + * @returns {boolean} true if the family is valid + */ + isValidFamily (family) { + return /^[-\w ]+$/.test(family); + } + + /** + * @param {string} family + * @returns {boolean} + */ + hasFont (family) { + return !!this.fonts.find(i => i.family === family); + } + + /** + * @param {string} family + * @returns {boolean} + */ + getSafeName (family) { + family = family.replace(/[^-\w ]/g, ''); + return StringUtil.unusedName(family, this.fonts.map(i => i.family)); + } + + changed () { + this.emit('change'); + } + + /** + * @param {string} family + * @param {string} fallback + */ + addSystemFont (family, fallback) { + if (!this.isValidFamily(family)) { + throw new Error('Invalid family'); + } + this.fonts.push({ + system: true, + family, + fallback + }); + this.changed(); + } + + /** + * @param {string} family + * @param {string} fallback + * @param {Asset} asset scratch-storage asset + */ + addCustomFont (family, fallback, asset) { + if (!this.isValidFamily(family)) { + throw new Error('Invalid family'); + } + + this.fonts.push({ + system: false, + family, + fallback, + asset + }); + + this.updateRenderer(); + this.changed(); + } + + /** + * @returns {Array<{system: boolean; name: string; family: string; data: Uint8Array | null; format: string | null}>} + */ + getFonts () { + return this.fonts.map(font => ({ + system: font.system, + name: font.family, + family: `"${font.family}", ${font.fallback}`, + data: font.asset ? font.asset.data : null, + format: font.asset ? font.asset.dataFormat : null + })); + } + + /** + * @param {number} index Corresponds to index from getFonts() + */ + deleteFont (index) { + const [removed] = this.fonts.splice(index, 1); + if (!removed.system) { + this.updateRenderer(); + } + this.changed(); + } + + clear () { + const hadNonSystemFont = this.fonts.some(i => !i.system); + this.fonts = []; + if (hadNonSystemFont) { + this.updateRenderer(); + } + this.changed(); + } + + updateRenderer () { + if (!this.runtime.renderer || !this.runtime.renderer.setCustomFonts) { + return; + } + + const fontfaces = {}; + for (const font of this.fonts) { + if (!font.system) { + const uri = font.asset.encodeDataURI(); + const fontface = `@font-face { font-family: "${font.family}"; src: url("${uri}"); }`; + const family = `"${font.family}", ${font.fallback}`; + fontfaces[family] = fontface; + } + } + this.runtime.renderer.setCustomFonts(fontfaces); + } + + /** + * Get data to save in project.json and sb3 files. + */ + serializeJSON () { + if (this.fonts.length === 0) { + return null; + } + + return this.fonts.map(font => { + const serialized = { + system: font.system, + family: font.family, + fallback: font.fallback + }; + + if (!font.system) { + const asset = font.asset; + serialized.md5ext = `${asset.assetId}.${asset.dataFormat}`; + } + + return serialized; + }); + } + + /** + * @returns {Asset[]} list of scratch-storage assets + */ + serializeAssets () { + return this.fonts + .filter(i => !i.system) + .map(i => i.asset); + } + + /** + * @param {unknown} json + * @param {JSZip} [zip] + * @param {boolean} [keepExisting] + * @returns {Promise} + */ + async deserialize (json, zip, keepExisting) { + if (!keepExisting) { + this.clear(); + } + + if (!Array.isArray(json)) { + return; + } + + for (const font of json) { + if (!font || typeof font !== 'object') { + continue; + } + + try { + const system = font.system; + const family = font.family; + const fallback = font.fallback; + if ( + typeof system !== 'boolean' || + typeof family !== 'string' || + typeof fallback !== 'string' || + this.hasFont(family) + ) { + continue; + } + + if (system) { + this.addSystemFont(family, fallback); + } else { + const md5ext = font.md5ext; + if (typeof md5ext !== 'string') { + continue; + } + + const asset = await AssetUtil.getByMd5ext( + this.runtime, + zip, + this.runtime.storage.AssetType.Font, + md5ext + ); + this.addCustomFont(family, fallback, asset); + } + } catch (e) { + log.error('could not add font', e); + } + } + } +} + +module.exports = FontManager; diff --git a/src/engine/tw-frame-loop.js b/src/engine/tw-frame-loop.js new file mode 100644 index 00000000..45423739 --- /dev/null +++ b/src/engine/tw-frame-loop.js @@ -0,0 +1,94 @@ +// Due to the existence of features such as interpolation and "0 FPS" being treated as "screen refresh rate", +// The VM loop logic has become much more complex + +// Use setTimeout to polyfill requestAnimationFrame in Node.js environments +const _requestAnimationFrame = typeof requestAnimationFrame === 'function' ? + requestAnimationFrame : + (f => setTimeout(f, 1000 / 60)); +const _cancelAnimationFrame = typeof requestAnimationFrame === 'function' ? + cancelAnimationFrame : + clearTimeout; + +const animationFrameWrapper = callback => { + let id; + const handle = () => { + id = _requestAnimationFrame(handle); + callback(); + }; + const cancel = () => _cancelAnimationFrame(id); + id = _requestAnimationFrame(handle); + return { + cancel + }; +}; + +class FrameLoop { + constructor (runtime) { + this.runtime = runtime; + this.running = false; + this.setFramerate(30); + this.setInterpolation(false); + + this.stepCallback = this.stepCallback.bind(this); + this.interpolationCallback = this.interpolationCallback.bind(this); + + this._stepInterval = null; + this._interpolationAnimation = null; + this._stepAnimation = null; + } + + setFramerate (fps) { + this.framerate = fps; + this._restart(); + } + + setInterpolation (interpolation) { + this.interpolation = interpolation; + this._restart(); + } + + stepCallback () { + this.runtime._step(); + } + + interpolationCallback () { + this.runtime._renderInterpolatedPositions(); + } + + _restart () { + if (this.running) { + this.stop(); + this.start(); + } + } + + start () { + this.running = true; + if (this.framerate === 0) { + this._stepAnimation = animationFrameWrapper(this.stepCallback); + this.runtime.currentStepTime = 1000 / 60; + } else { + // Interpolation should never be enabled when framerate === 0 as that's just redundant + if (this.interpolation) { + this._interpolationAnimation = animationFrameWrapper(this.interpolationCallback); + } + this._stepInterval = setInterval(this.stepCallback, 1000 / this.framerate); + this.runtime.currentStepTime = 1000 / this.framerate; + } + } + + stop () { + this.running = false; + clearInterval(this._stepInterval); + if (this._interpolationAnimation) { + this._interpolationAnimation.cancel(); + } + if (this._stepAnimation) { + this._stepAnimation.cancel(); + } + this._interpolationAnimation = null; + this._stepAnimation = null; + } +} + +module.exports = FrameLoop; diff --git a/src/engine/tw-interpolate.js b/src/engine/tw-interpolate.js index 9e5eb77a..3556dffd 100644 --- a/src/engine/tw-interpolate.js +++ b/src/engine/tw-interpolate.js @@ -1,19 +1,3 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - /** * Prepare the targets of a runtime for interpolation. * @param {Runtime} runtime The Runtime with targets to prepare for interpolation. diff --git a/src/engine/tw-platform.js b/src/engine/tw-platform.js new file mode 100644 index 00000000..c3824c27 --- /dev/null +++ b/src/engine/tw-platform.js @@ -0,0 +1,7 @@ +// Forks should change this. +// This can be accessed externally on `vm.runtime.platform` + +module.exports = { + name: 'Gandi', + url: 'https://getgandi.com/' +}; diff --git a/src/extension-support/argument-type.js b/src/extension-support/argument-type.js index 1f1f88ca..aa4435d8 100644 --- a/src/extension-support/argument-type.js +++ b/src/extension-support/argument-type.js @@ -44,21 +44,29 @@ const ArgumentType = { IMAGE: 'image', /** - * powered by xigua - * XIGUA_MATRIX 小白板的12*12 Led矩阵 - * XIGUA_WHITE_BOARD_NOTE 小白版的 MIDI note number with note picker (piano) field 超长版本 + * for 12*12 Led board */ XIGUA_MATRIX: 'xigua_matrix', + /** + * MIDI note number with note picker (piano) + */ XIGUA_WHITE_BOARD_NOTE: 'xigua_white_board_note', /** * CCW_HAT_PARAMETER, use in hat block */ - CCW_HAT_PARAMETER: 'ccw_hat_parameter' + CCW_HAT_PARAMETER: 'ccw_hat_parameter', + + /** + * Name of costume in the current target + */ + COSTUME: 'costume', + /** - * powered by xigua + * Name of sound in the current target */ + SOUND: 'sound' }; module.exports = ArgumentType; diff --git a/src/extension-support/block-type.js b/src/extension-support/block-type.js index ae4fd890..9231ad20 100644 --- a/src/extension-support/block-type.js +++ b/src/extension-support/block-type.js @@ -13,6 +13,11 @@ const BlockType = { */ BUTTON: 'button', + /** + * A text label (not an actual block) for adding comments or labling blocks + */ + LABEL: 'label', + /** * Command block */ @@ -49,7 +54,12 @@ const BlockType = { /** * A text label (not an actual block) for adding comments or labling blocks */ - LABEL: 'label' + LABEL: 'label', + + /** + * Arbitrary scratch-blocks XML. + */ + XML: 'xml' }; module.exports = BlockType; diff --git a/src/extension-support/extension-load-helper.js b/src/extension-support/extension-load-helper.js index 88c04737..ee3cc03a 100644 --- a/src/extension-support/extension-load-helper.js +++ b/src/extension-support/extension-load-helper.js @@ -14,8 +14,8 @@ const pending = new Set(); const clearScratchAPI = id => { pending.delete(id); - if (window.IIFEExtensionInfoList && id) { - window.IIFEExtensionInfoList = window.IIFEExtensionInfoList.filter(({extensionObject}) => extensionObject.info.extensionId !== id); + if (global.IIFEExtensionInfoList && id) { + global.IIFEExtensionInfoList = global.IIFEExtensionInfoList.filter(({extensionObject}) => extensionObject.info.extensionId !== id); } if (global.Scratch && pending.size === 0) { global.Scratch.extensions = { @@ -46,8 +46,8 @@ const setupScratchAPI = (vm, id) => { }, Extension: () => extensionInstance.constructor }; - window.IIFEExtensionInfoList = window.IIFEExtensionInfoList || []; - window.IIFEExtensionInfoList.push({extensionObject, extensionInstance}); + global.IIFEExtensionInfoList = global.IIFEExtensionInfoList || []; + global.IIFEExtensionInfoList.push({extensionObject, extensionInstance}); return; }; @@ -55,14 +55,15 @@ const setupScratchAPI = (vm, id) => { const {runtime} = vm; if (runtime.ccwAPI && runtime.ccwAPI.getOpenVM) { openVM = runtime.ccwAPI.getOpenVM(); - } else { - openVM = { - runtime: vm.runtime - }; } + openVM = { + runtime: vm.runtime, + exports: vm.exports, + ...openVM + }; } if (!translate) { - translate = createTranslate(vm.runtime); + translate = createTranslate(vm); } const scratch = { @@ -94,11 +95,8 @@ const createdScriptLoader = ({url, onSuccess, onError}) => { exist.failedCallBack.push(onError); return exist; } - if (!url) { - log.warn('remote extension url is null'); - } - const script = document.createElement('script'); + const script = document.createElement('script'); script.src = `${url + (url.includes('?') ? '&' : '?')}t=${Date.now()}`; script.id = url; script.defer = true; @@ -111,10 +109,10 @@ const createdScriptLoader = ({url, onSuccess, onError}) => { const logError = e => { scriptError = e; }; - window.addEventListener('error', logError); + global.addEventListener('error', logError); const removeScript = () => { - window.removeEventListener('error', logError); + global.removeEventListener('error', logError); document.body.removeChild(script); }; diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index e14d8cb4..68c91112 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -4,12 +4,13 @@ const maybeFormatMessage = require('../util/maybe-format-message'); const formatMessage = require('format-message'); const BlockType = require('./block-type'); const {setupScratchAPI, clearScratchAPI, createdScriptLoader} = require('./extension-load-helper'); +const SecurityManager = require('./tw-security-manager'); // These extensions are currently built into the VM repository but should not be loaded at startup. // TODO: move these out into a separate repository? // TODO: change extension spec so that library info, including extension ID, can be collected through static methods -const builtinExtensions = { +const defaultBuiltinExtensions = { // This is an example that isn't loaded with the other core blocks, // but serves as a reference for loading core blocks as extensions. coreExample: () => require('../blocks/scratch3_core_example'), @@ -134,19 +135,27 @@ class ExtensionManager { this.pendingWorkers = []; /** - * Set of loaded extension URLs/IDs (equivalent for built-in extensions). - * @type {Set.} + * Map of worker ID to the URL where it was loaded from. + * @type {Array} + */ + this.workerURLs = []; + + /** + * Map of loaded extension URLs/IDs (equivalent for built-in extensions) to service name. + * @type {Map.} * @private */ this._loadedExtensions = new Map(); /** - * Controls how remote custom extensions are loaded. - * One of the strings: - * - "worker" (default) - * - "iframe" + * Responsible for determining security policies related to custom extensions. */ - this.workerMode = 'worker'; + this.securityManager = new SecurityManager(); + + /** + * @type {VirtualMachine} + */ + this.vm = vm; /** * Whether to show a warning that extensions are officially incompatible with Scratch. @@ -160,12 +169,6 @@ class ExtensionManager { */ this.runtime = vm.runtime; - /** - * Reference to the virtual machine, which provides the runtime and other VM functionalities. - * @type {VM} - */ - this.vm = vm; - /** * List of external extension service URLs that can be loaded remotely. * @type {Array.} @@ -183,18 +186,13 @@ class ExtensionManager { this.loadingAsyncExtensions = 0; this.asyncExtensionsLoadedCallbacks = []; - dispatch - .setService('extensions', createExtensionService(this)) - .catch(e => { - log.error( - `ExtensionManager was unable to register extension service: ${JSON.stringify( - e - )}` - ); - }); - this._customExtensionInfo = {}; this._officialExtensionInfo = {}; + this.builtinExtensions = Object.assign({}, defaultBuiltinExtensions); + + dispatch.setService('extensions', createExtensionService(this)).catch(e => { + log.error(`ExtensionManager was unable to register extension service: ${JSON.stringify(e)}`); + }); } /** @@ -270,6 +268,16 @@ class ExtensionManager { return extensionId; } + /** + * Determine whether an extension with a given ID is built in to the VM, such as pen. + * Note that "core extensions" like motion will return false here. + * @param {string} extensionId + * @returns {boolean} + */ + isBuiltinExtension (extensionId) { + return Object.prototype.hasOwnProperty.call(this.builtinExtensions, extensionId); + } + /** * Get the list of external extension service URLs. * @returns {Array.} - The list of external extension service URLs. @@ -308,16 +316,38 @@ class ExtensionManager { * @param {string} extensionId - the ID of an internal extension */ loadExtensionIdSync (extensionId) { - if (!builtinExtensions.hasOwnProperty(extensionId)) { - log.warn( - `Could not find extension ${extensionId} in the built in extensions.` - ); + if (!this.isBuiltinExtension(extensionId)) { + log.warn(`Could not find extension ${extensionId} in the built in extensions.`); + return; + } + /** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */ + if (this.isExtensionLoaded(extensionId)) { + const message = `Rejecting attempt to load a second extension with ID ${extensionId}`; + log.warn(message); return; } - const extension = builtinExtensions[extensionId](); + const extension = this.builtinExtensions[extensionId](); return this.registerExtension(extensionId, extension); } + addBuiltinExtension (extensionId, extensionClass) { + this.builtinExtensions[extensionId] = () => extensionClass; + } + + _isValidExtensionURL (extensionURL) { + try { + const parsedURL = new URL(extensionURL); + return ( + parsedURL.protocol === 'https:' || + parsedURL.protocol === 'http:' || + parsedURL.protocol === 'data:' || + parsedURL.protocol === 'file:' + ); + } catch (e) { + return false; + } + } + /** * Load an extension by URL or internal extension ID * @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension @@ -349,21 +379,12 @@ class ExtensionManager { extFileURL = await this.runtime.ccwAPI.getExtensionURLById(extensionURL); } - if (!extFileURL) { - // try ask user to input url to load extension - // eslint-disable-next-line no-alert - extFileURL = prompt( - formatMessage({ - id: 'gui.extension.custom.load.inputURLTip', - default: `input custom extension [${extensionURL}]'s URL` - }, - {extName: `${extensionURL}\n`})); - if (!this.isValidExtensionURL(extFileURL)) { - throw new Error(`Invalid extension URL: ${extensionURL}`); - } - } - if (this.isValidExtensionURL(extFileURL)) { + if (this.isExtensionURLLoaded(extensionURL)) { + // Extension is already loaded. + // TODO: should let user choose if they want to reload it? + return; + } return this.loadExternalExtensionToLibrary(extFileURL, shouldReplace).then(({onlyAdded, addedAndLoaded}) => { const allLoader = onlyAdded.map(extId => this.loadExternalExtensionById(extId, shouldReplace)); return Promise.all(allLoader).then(res => res.concat(addedAndLoaded).flat()); @@ -411,8 +432,11 @@ class ExtensionManager { if (this.loadingAsyncExtensions === 0) { return; } - return new Promise(resolve => { - this.asyncExtensionsLoadedCallbacks.push(resolve); + return new Promise((resolve, reject) => { + this.asyncExtensionsLoadedCallbacks.push({ + resolve, + reject + }); }); } @@ -488,6 +512,7 @@ class ExtensionManager { const id = this.nextExtensionWorker++; const workerInfo = this.pendingExtensions.shift(); this.pendingWorkers[id] = workerInfo; + this.workerURLs[id] = workerInfo.extensionURL; return [id, workerInfo.extensionURL]; } @@ -508,15 +533,28 @@ class ExtensionManager { dispatch.call(serviceName, 'getInfo').then(info => { this.setLoadedExtension(info.id, serviceName); this._registerExtensionInfo(serviceName, info); - - this.loadingAsyncExtensions--; - if (this.loadingAsyncExtensions === 0) { - this.asyncExtensionsLoadedCallbacks.forEach(i => i()); - this.asyncExtensionsLoadedCallbacks = []; - } + this._finishedLoadingExtensionScript(); }); } + _finishedLoadingExtensionScript () { + this.loadingAsyncExtensions--; + if (this.loadingAsyncExtensions === 0) { + this.asyncExtensionsLoadedCallbacks.forEach(i => i.resolve()); + this.asyncExtensionsLoadedCallbacks = []; + } + } + + _failedLoadingExtensionScript (error) { + // Don't set the current extension counter to 0, otherwise it will go negative if another + // extension finishes or fails to load. + this.loadingAsyncExtensions--; + this.asyncExtensionsLoadedCallbacks.forEach(i => i.reject(error)); + this.asyncExtensionsLoadedCallbacks = []; + // Re-throw error so the promise still rejects. + throw error; + } + /** * Called by an extension worker to indicate that the worker has finished initialization. * @param {int} id - the worker ID. @@ -529,7 +567,7 @@ class ExtensionManager { this.loadingAsyncExtensions = 0; workerInfo.reject(e); } else { - workerInfo.resolve(id); + workerInfo.resolve(); } } @@ -569,16 +607,6 @@ class ExtensionManager { }); } - /** - * Modify the provided text as necessary to ensure that it may be used as an attribute value in valid XML. - * @param {string} text - the text to be sanitized - * @returns {string} - the sanitized text - * @private - */ - _sanitizeID (text) { - return text.toString().replace(/[<"&]/, '_'); - } - /** * Apply minor cleanup and defaults for optional extension fields. * TODO: make the ID unique in cases where two copies of the same extension are loaded. @@ -741,18 +769,18 @@ class ExtensionManager { * @private */ _prepareBlockInfo (serviceName, blockInfo) { - blockInfo = Object.assign( - {}, - { - blockType: BlockType.COMMAND, - terminal: false, - blockAllThreads: false, - arguments: {} - }, - blockInfo - ); - blockInfo.opcode = - blockInfo.opcode && this._sanitizeID(blockInfo.opcode); + if (blockInfo.blockType === BlockType.XML) { + blockInfo = Object.assign({}, blockInfo); + blockInfo.xml = String(blockInfo.xml) || ''; + return blockInfo; + } + + blockInfo = Object.assign({}, { + blockType: BlockType.COMMAND, + terminal: false, + blockAllThreads: false, + arguments: {} + }, blockInfo); blockInfo.text = blockInfo.text || blockInfo.opcode; switch (blockInfo.blockType) { @@ -769,12 +797,13 @@ class ExtensionManager { `Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}` ); } + blockInfo.callFunc = () => { + dispatch.call(serviceName, blockInfo.func); + }; break; case BlockType.LABEL: if (blockInfo.opcode) { - log.warn( - `Ignoring opcode "${blockInfo.opcode}" for label: ${blockInfo.text}` - ); + log.warn(`Ignoring opcode "${blockInfo.opcode}" for label: ${blockInfo.text}`); } break; default: { @@ -782,9 +811,7 @@ class ExtensionManager { throw new Error('Missing opcode for block'); } - const funcName = blockInfo.func ? - this._sanitizeID(blockInfo.func) : - blockInfo.opcode; + const funcName = blockInfo.func || blockInfo.opcode; const getBlockInfo = blockInfo.isDynamic ? args => args && args.mutation && args.mutation.blockInfo : @@ -792,13 +819,20 @@ class ExtensionManager { const callBlockFunc = (() => { if (dispatch._isRemoteService(serviceName)) { return (args, util, realBlockInfo) => - dispatch.call( - serviceName, - funcName, - args, - util, - realBlockInfo - ); + dispatch.call(serviceName, funcName, args, util, realBlockInfo) + .then(result => { + // Scratch is only designed to handle these types. + // If any other value comes in such as undefined, null, an object, etc. + // we'll convert it to a string to avoid undefined behavior. + if ( + typeof result === 'number' || + typeof result === 'string' || + typeof result === 'boolean' + ) { + return result; + } + return `${result}`; + }); } // avoid promise latency if we can call direct @@ -836,8 +870,6 @@ class ExtensionManager { return blockInfo; } - // powered by xigua start - /** * @description register gandi extension when developer load custom extension * @param {string} id extension id @@ -878,17 +910,13 @@ class ExtensionManager { } injectExtension (extensionId, extension) { - builtinExtensions[extensionId] = () => extension; - } - - isBuiltinExtension (extensionId) { - return builtinExtensions.hasOwnProperty(extensionId); + this.builtinExtensions[extensionId] = () => extension; } isExternalExtension (extensionId) { return ( - officialExtension.hasOwnProperty(extensionId) || - customExtension.hasOwnProperty(extensionId) + Object.hasOwnProperty.call(officialExtension, extensionId) || + Object.hasOwnProperty.call(customExtension, extensionId) ); } @@ -960,8 +988,8 @@ class ExtensionManager { } // 3. return a IIFE which called global Scratch.extensions.register to register // extension obj will be added in IIFEExtensionInfoList - const needRegister = window.IIFEExtensionInfoList && - window.IIFEExtensionInfoList.find(({extensionObject}) => extensionObject.info.extensionId === extensionId); + const needRegister = global.IIFEExtensionInfoList && + global.IIFEExtensionInfoList.find(({extensionObject}) => extensionObject.info.extensionId === extensionId); if (needRegister) { // update extension constructor this.updateExternalExtensionConstructor(extensionId, needRegister.extensionObject.Extension); @@ -985,15 +1013,16 @@ class ExtensionManager { async loadExternalExtensionToLibrary (url, shouldReplace = false, disallowIIFERegister = false) { const onlyAdded = []; const addedAndLoaded = []; // exts use Scratch.extensions.register + const rewritten = await this.securityManager.rewriteExtensionURL(url); return new Promise((resolve, reject) => { - setupScratchAPI(this.vm, url); + setupScratchAPI(this.vm, rewritten); createdScriptLoader({ - url, + url: rewritten, onSuccess: async () => { try { - if (window.IIFEExtensionInfoList) { + if (global.IIFEExtensionInfoList) { // for those extension which registered by scratch.extensions.register in IIFE - window.IIFEExtensionInfoList.forEach(({extensionObject, extensionInstance}) => { + global.IIFEExtensionInfoList.forEach(({extensionObject, extensionInstance}) => { this.addCustomExtensionInfo(extensionObject, url); if (disallowIIFERegister) { onlyAdded.push(extensionObject.info.extensionId); @@ -1003,27 +1032,27 @@ class ExtensionManager { } }); } - if (window.ExtensionLib) { + if (global.ExtensionLib) { // for those extension which developed by user using ccw-customExt-tool - const lib = await window.ExtensionLib; + const lib = await global.ExtensionLib; Object.keys(lib).forEach(key => { const obj = lib[key]; this.addCustomExtensionInfo(obj, url); onlyAdded.push(obj.info.extensionId); }); - delete window.ExtensionLib; + delete global.ExtensionLib; } - if (window.tempExt) { + if (global.tempExt) { // for user developing custom extension - const obj = window.tempExt; + const obj = global.tempExt; this.addCustomExtensionInfo(obj, url); onlyAdded.push(obj.info.extensionId); - delete window.tempExt; + delete global.tempExt; } - if (window.scratchExtensions) { + if (global.scratchExtensions) { // for Gandi extension service const {default: lib} = - await window.scratchExtensions.default(); + await global.scratchExtensions.default(); Object.entries(lib).forEach(([key, obj]) => { if (!(obj.info && obj.info.extensionId)) { // compatible with some legacy gandi extension service @@ -1048,10 +1077,10 @@ class ExtensionManager { if (onlyAdded.length > 0 || addedAndLoaded.length > 0) { this.runtime.emit('EXTENSION_LIBRARY_UPDATED'); } - delete window.scratchExtensions; - delete window.tempExt; - delete window.ExtensionLib; - delete window.IIFEExtensionInfoList; + delete global.scratchExtensions; + delete global.tempExt; + delete global.ExtensionLib; + delete global.IIFEExtensionInfoList; }); } @@ -1073,14 +1102,13 @@ class ExtensionManager { } getLoadedExtensionURLs () { - const loadURLs = this._loadedExtensions.keys().map(extId => { + const loadURLs = {}; + Array.from(this._loadedExtensions.keys()).forEach(extId => { const ext = this._customExtensionInfo[extId] || this._officialExtensionInfo[extId]; if (ext && ext.url) { - return {[extId]: ext.url}; + loadURLs[extId] = ext.url; } - return null; }) - .filter(Boolean); return loadURLs; } @@ -1183,6 +1211,13 @@ class ExtensionManager { } } + isExtensionURLLoaded (extensionURL) { + const all = this.getLoadedExtensionURLs() + return Object.values(all).includes(extensionURL); + } + getExtensionURLs () { + return this.getLoadedExtensionURLs(); + } } module.exports = ExtensionManager; diff --git a/src/extension-support/extension-worker.js b/src/extension-support/extension-worker.js index 20a2b721..b5a721cc 100644 --- a/src/extension-support/extension-worker.js +++ b/src/extension-support/extension-worker.js @@ -1,11 +1,13 @@ /* eslint-env worker */ -const ArgumentType = require('../extension-support/argument-type'); -const BlockType = require('../extension-support/block-type'); +const ScratchCommon = require('./tw-extension-api-common'); +const createScratchX = require('./tw-scratchx-compatibility-layer'); const dispatch = require('../dispatch/worker-dispatch'); const log = require('../util/log'); -const TargetType = require('../extension-support/target-type'); const {isWorker} = require('./tw-extension-worker-context'); +const createTranslate = require('./tw-l10n'); + +const translate = createTranslate(null); const loadScripts = url => { if (isWorker) { @@ -14,7 +16,9 @@ const loadScripts = url => { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.onload = () => resolve(); - script.onerror = () => reject(new Error('Cannot run script')); + script.onerror = () => { + reject(new Error(`Error in sandboxed script: ${url}. Check the console for more information.`)); + }; script.src = url; document.body.appendChild(script); }); @@ -69,9 +73,21 @@ class ExtensionWorker { } global.Scratch = global.Scratch || {}; -global.Scratch.ArgumentType = ArgumentType; -global.Scratch.BlockType = BlockType; -global.Scratch.TargetType = TargetType; +Object.assign(global.Scratch, ScratchCommon, { + canFetch: () => Promise.resolve(true), + fetch: (url, options) => fetch(url, options), + canOpenWindow: () => Promise.resolve(false), + openWindow: () => Promise.reject(new Error('Scratch.openWindow not supported in sandboxed extensions')), + canRedirect: () => Promise.resolve(false), + redirect: () => Promise.reject(new Error('Scratch.redirect not supported in sandboxed extensions')), + canRecordAudio: () => Promise.resolve(false), + canRecordVideo: () => Promise.resolve(false), + canReadClipboard: () => Promise.resolve(false), + canNotify: () => Promise.resolve(false), + canGeolocate: () => Promise.resolve(false), + canEmbed: () => Promise.resolve(false), + translate +}); /** * Expose only specific parts of the worker to extensions. @@ -80,3 +96,5 @@ const extensionWorker = new ExtensionWorker(); global.Scratch.extensions = { register: extensionWorker.register.bind(extensionWorker) }; + +global.ScratchExtensions = createScratchX(global.Scratch); diff --git a/src/extension-support/tw-default-extension-urls.js b/src/extension-support/tw-default-extension-urls.js new file mode 100644 index 00000000..7b05eb80 --- /dev/null +++ b/src/extension-support/tw-default-extension-urls.js @@ -0,0 +1,15 @@ +// If a project uses an extension but does not specify a URL, it will default to +// the URLs given here, if it exists. This is useful for compatibility with other mods. + +const defaults = new Map(); + +// Box2D (`griffpatch`) is not listed here because our extension is not actually +// compatible with the original version due to fields vs inputs. + +// Scratch Lab Animated Text - https://lab.scratch.mit.edu/text/ +defaults.set('text', 'https://extensions.turbowarp.org/lab/text.js'); + +// Turboloader's AudioStream +defaults.set('audiostr', 'https://extensions.turbowarp.org/turboloader/audiostream.js'); + +module.exports = defaults; diff --git a/src/extension-support/tw-extension-api-common.js b/src/extension-support/tw-extension-api-common.js new file mode 100644 index 00000000..e2ac0b74 --- /dev/null +++ b/src/extension-support/tw-extension-api-common.js @@ -0,0 +1,13 @@ +const ArgumentType = require('./argument-type'); +const BlockType = require('./block-type'); +const TargetType = require('./target-type'); +const Cast = require('../util/cast'); + +const Scratch = { + ArgumentType, + BlockType, + TargetType, + Cast +}; + +module.exports = Scratch; diff --git a/src/extension-support/tw-iframe-extension-worker-entry.js b/src/extension-support/tw-iframe-extension-worker-entry.js index 1126c297..ddd6d41c 100644 --- a/src/extension-support/tw-iframe-extension-worker-entry.js +++ b/src/extension-support/tw-iframe-extension-worker-entry.js @@ -1,5 +1,9 @@ const context = require('./tw-extension-worker-context'); +const jQuery = require('./tw-jquery-shim'); +global.$ = jQuery; +global.jQuery = jQuery; + const id = window.__WRAPPED_IFRAME_ID__; context.isWorker = false; diff --git a/src/extension-support/tw-iframe-extension-worker.js b/src/extension-support/tw-iframe-extension-worker.js index 9eccebba..782ee449 100644 --- a/src/extension-support/tw-iframe-extension-worker.js +++ b/src/extension-support/tw-iframe-extension-worker.js @@ -2,18 +2,15 @@ const uid = require('../util/uid'); const frameSource = require('./tw-load-script-as-plain-text!./tw-iframe-extension-worker-entry'); const none = "'none'"; -const allow = '*'; const featurePolicy = { 'accelerometer': none, 'ambient-light-sensor': none, - 'autoplay': none, 'battery': none, 'camera': none, 'display-capture': none, 'document-domain': none, 'encrypted-media': none, 'fullscreen': none, - 'gamepad': allow, 'geolocation': none, 'gyroscope': none, 'magnetometer': none, @@ -53,9 +50,10 @@ class IframeExtensionWorker { window.addEventListener('message', this._onWindowMessage.bind(this)); const blob = new Blob([ - `` + // eslint-disable-next-line max-len + `` ], { - type: 'text/html' + type: 'text/html; charset=utf-8' }); this.iframe.src = URL.createObjectURL(blob); } diff --git a/src/extension-support/tw-jquery-shim.js b/src/extension-support/tw-jquery-shim.js new file mode 100644 index 00000000..9d1f8897 --- /dev/null +++ b/src/extension-support/tw-jquery-shim.js @@ -0,0 +1,112 @@ +/** + * @fileoverview + * Many ScratchX extensions require jQuery to do things like loading scripts and making requests. + * The real jQuery is pretty large and we'd rather not bring in everything, so this file reimplements + * small stubs of a few jQuery methods. + * It's just supposed to be enough to make existing ScratchX extensions work, nothing more. + */ + +const log = require('../util/log'); + +const jQuery = () => { + throw new Error('Not implemented'); +}; + +jQuery.getScript = (src, callback) => { + const script = document.createElement('script'); + script.src = src; + if (callback) { + // We don't implement callback arguments. + script.onload = () => callback(); + } + document.body.appendChild(script); +}; + +/** + * @param {Record|undefined} obj + * @returns {URLSearchParams} + */ +const objectToQueryString = obj => { + const params = new URLSearchParams(); + if (obj) { + for (const key of Object.keys(obj)) { + params.set(key, obj[key]); + } + } + return params; +}; + +let jsonpCallback = 0; + +jQuery.ajax = async (arg1, arg2) => { + let options = {}; + + if (arg1 && arg2) { + options = arg2; + options.url = arg1; + } else if (arg1) { + options = arg1; + } + + const urlParameters = objectToQueryString(options.data); + const getFinalURL = () => { + const query = urlParameters.toString(); + let url = options.url; + if (query) { + url += `?${query}`; + } + // Forcibly upgrade all HTTP requests to HTTPS so that they don't error on HTTPS sites + // All the extensions we care about work fine with this + if (url.startsWith('http://')) { + url = url.replace('http://', 'https://'); + } + return url; + }; + + const successCallback = result => { + if (options.success) { + options.success(result); + } + }; + const errorCallback = error => { + log.error(error); + if (options.error) { + // The error object we provide here might not match what jQuery provides but it's enough to + // prevent extensions from throwing errors trying to access properties. + options.error(error); + } + }; + + try { + if (options.dataType === 'jsonp') { + const callbackName = `_jsonp_callback${jsonpCallback++}`; + global[callbackName] = data => { + delete global[callbackName]; + successCallback(data); + }; + + const callbackParameterName = options.jsonp || 'callback'; + urlParameters.set(callbackParameterName, callbackName); + + jQuery.getScript(getFinalURL()); + return; + } + + if (options.dataType === 'script') { + jQuery.getScript(getFinalURL(), successCallback); + return; + } + + const res = await fetch(getFinalURL(), { + headers: options.headers + }); + // dataType defaults to "Intelligent Guess (xml, json, script, or html)" + // It happens that all the ScratchX extensions we care about either set dataType to "json" or + // leave it blank and implicitly request JSON, so this works good enough for now. + successCallback(await res.json()); + } catch (e) { + errorCallback(e); + } +}; + +module.exports = jQuery; diff --git a/src/extension-support/tw-l10n.js b/src/extension-support/tw-l10n.js index 739dd626..3d7bf00a 100644 --- a/src/extension-support/tw-l10n.js +++ b/src/extension-support/tw-l10n.js @@ -1,10 +1,10 @@ const formatMessage = require('format-message'); /** - * @param {Runtime|null} runtime + * @param {VM|null} vm * @returns {object} */ -const createTranslate = runtime => { +const createTranslate = vm => { const namespace = formatMessage.namespace(); const translate = (message, args) => { @@ -22,7 +22,11 @@ const createTranslate = runtime => { const generateId = defaultMessage => `_${defaultMessage}`; - const getLocale = () => formatMessage.setup().locale; + const getLocale = () => { + if (vm) return vm.getLocale(); + if (typeof navigator !== 'undefined') return navigator.language; + return 'en'; + }; let storedTranslations = {}; translate.setup = newTranslations => { @@ -37,10 +41,16 @@ const createTranslate = runtime => { }); }; + Object.defineProperty(translate, 'language', { + configurable: true, + enumerable: true, + get: () => getLocale() + }); + translate.setup({}); - if (runtime) { - runtime.on('LOCALE_CHANGED', () => { + if (vm) { + vm.on('LOCALE_CHANGED', () => { translate.setup(null); }); } diff --git a/src/extension-support/tw-load-script-as-plain-text.js b/src/extension-support/tw-load-script-as-plain-text.js index a36b6dc6..c93203ea 100644 --- a/src/extension-support/tw-load-script-as-plain-text.js +++ b/src/extension-support/tw-load-script-as-plain-text.js @@ -3,10 +3,6 @@ const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin'); module.exports.pitch = function (request) { - // Temporary hack to allow TW Desktop to avoid nonsensical errors - if (process.env.TW_DISABLE_PLAIN_TEXT_LOADER) { - return 'throw new Error("Loader was disabled at build time");'; - } // Technically this loader does work in other environments, but our use case does not want that. if (this.target !== 'web') { return 'throw new Error("Not supported in non-web environment");'; diff --git a/src/extension-support/tw-scratchx-compatibility-layer.js b/src/extension-support/tw-scratchx-compatibility-layer.js new file mode 100644 index 00000000..237bda80 --- /dev/null +++ b/src/extension-support/tw-scratchx-compatibility-layer.js @@ -0,0 +1,227 @@ +// ScratchX API Documentation: https://github.com/LLK/scratchx/wiki/ + +const ArgumentType = require('./argument-type'); +const BlockType = require('./block-type'); + +const { + argumentIndexToId, + generateExtensionId +} = require('./tw-scratchx-utilities'); + +/** + * @typedef ScratchXDescriptor + * @property {unknown[][]} blocks + * @property {Record} [menus] + * @property {string} [url] + * @property {string} [displayName] + */ + +/** + * @typedef ScratchXStatus + * @property {0|1|2} status 0 is red/error, 1 is yellow/not ready, 2 is green/ready + * @property {string} msg + */ + +const parseScratchXBlockType = type => { + if (type === '' || type === ' ' || type === 'w') { + return { + type: BlockType.COMMAND, + async: type === 'w' + }; + } + if (type === 'r' || type === 'R') { + return { + type: BlockType.REPORTER, + async: type === 'R' + }; + } + if (type === 'b') { + return { + type: BlockType.BOOLEAN, + // ScratchX docs don't seem to mention boolean reporters that wait + async: false + }; + } + if (type === 'h') { + return { + type: BlockType.HAT, + async: false + }; + } + throw new Error(`Unknown ScratchX block type: ${type}`); +}; + +const isScratchCompatibleValue = v => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'; + +/** + * @param {string} argument ScratchX argument with leading % removed. + * @param {unknown} defaultValue Default value, if any + */ +const parseScratchXArgument = (argument, defaultValue) => { + const result = {}; + const hasDefaultValue = isScratchCompatibleValue(defaultValue); + + // defaultValue is ignored for booleans in Scratch 3 + if (hasDefaultValue && argument !== 'b') { + result.defaultValue = defaultValue; + } + + if (argument === 's') { + result.type = ArgumentType.STRING; + if (!hasDefaultValue) { + result.defaultValue = ''; + } + } else if (argument === 'n') { + result.type = ArgumentType.NUMBER; + if (!hasDefaultValue) { + result.defaultValue = 0; + } + } else if (argument[0] === 'm') { + result.type = ArgumentType.STRING; + const split = argument.split(/\.|:/); + const menuName = split[1]; + result.menu = menuName; + } else if (argument === 'b') { + result.type = ArgumentType.BOOLEAN; + } else { + throw new Error(`Unknown ScratchX argument type: ${argument}`); + } + return result; +}; + +const wrapScratchXFunction = (originalFunction, argumentCount, async) => args => { + // Convert Scratch 3's argument object to an argument list expected by ScratchX + const argumentList = []; + for (let i = 0; i < argumentCount; i++) { + argumentList.push(args[argumentIndexToId(i)]); + } + if (async) { + return new Promise(resolve => { + originalFunction(...argumentList, resolve); + }); + } + return originalFunction(...argumentList); +}; + +/** + * @param {string} name + * @param {ScratchXDescriptor} descriptor + * @param {Record unknown>} functions + */ +const convert = (name, descriptor, functions) => { + const extensionId = generateExtensionId(name); + const info = { + id: extensionId, + name: descriptor.displayName || name, + blocks: [], + color1: '#4a4a5e', + color2: '#31323f', + color3: '#191a21' + }; + const scratch3Extension = { + getInfo: () => info, + _getStatus: functions._getStatus + }; + + if (descriptor.url) { + info.docsURI = descriptor.url; + } + + for (const blockDescriptor of descriptor.blocks) { + if (blockDescriptor.length === 1) { + // Separator + info.blocks.push('---'); + continue; + } + const scratchXBlockType = blockDescriptor[0]; + const blockText = blockDescriptor[1]; + const functionName = blockDescriptor[2]; + const defaultArgumentValues = blockDescriptor.slice(3); + + let scratchText = ''; + const argumentInfo = []; + const blockTextParts = blockText.split(/%([\w.:]+)/g); + for (let i = 0; i < blockTextParts.length; i++) { + const part = blockTextParts[i]; + const isArgument = i % 2 === 1; + if (isArgument) { + parseScratchXArgument(part); + const argumentIndex = Math.floor(i / 2).toString(); + const argumentDefaultValue = defaultArgumentValues[argumentIndex]; + const argumentId = argumentIndexToId(argumentIndex); + argumentInfo[argumentId] = parseScratchXArgument(part, argumentDefaultValue); + scratchText += `[${argumentId}]`; + } else { + scratchText += part; + } + } + + const scratch3BlockType = parseScratchXBlockType(scratchXBlockType); + const blockInfo = { + opcode: functionName, + blockType: scratch3BlockType.type, + text: scratchText, + arguments: argumentInfo + }; + info.blocks.push(blockInfo); + + const originalFunction = functions[functionName]; + const argumentCount = argumentInfo.length; + scratch3Extension[functionName] = wrapScratchXFunction( + originalFunction, + argumentCount, + scratch3BlockType.async + ); + } + + const menus = descriptor.menus; + if (menus) { + const scratch3Menus = {}; + for (const menuName of Object.keys(menus) || {}) { + const menuItems = menus[menuName]; + const menuInfo = { + items: menuItems + }; + scratch3Menus[menuName] = menuInfo; + } + info.menus = scratch3Menus; + } + + return scratch3Extension; +}; + +const extensionNameToExtension = new Map(); + +/** + * @param {*} Scratch Scratch 3.0 extension API object + * @returns {*} ScratchX-compatible API object + */ +const createScratchX = Scratch => { + const register = (name, descriptor, functions) => { + const scratch3Extension = convert(name, descriptor, functions); + extensionNameToExtension.set(name, scratch3Extension); + Scratch.extensions.register(scratch3Extension); + }; + + /** + * @param {string} extensionName + * @returns {ScratchXStatus} + */ + const getStatus = extensionName => { + const extension = extensionNameToExtension.get(extensionName); + if (extension) { + return extension._getStatus(); + } + return { + status: 0, + msg: 'does not exist' + }; + }; + + return { + register, + getStatus + }; +}; + +module.exports = createScratchX; diff --git a/src/extension-support/tw-scratchx-utilities.js b/src/extension-support/tw-scratchx-utilities.js new file mode 100644 index 00000000..29aa6015 --- /dev/null +++ b/src/extension-support/tw-scratchx-utilities.js @@ -0,0 +1,25 @@ +/** + * @fileoverview + * General ScratchX-related utilities used in multiple places. + * Changing these functions may break projects. + */ + +/** + * @param {string} scratchXName + * @returns {string} + */ +const generateExtensionId = scratchXName => { + const sanitizedName = scratchXName.replace(/[^a-z0-9]/gi, '').toLowerCase(); + return `sbx${sanitizedName}`; +}; + +/** + * @param {number} i 0-indexed index of argument in list + * @returns {string} Scratch 3 argument name + */ +const argumentIndexToId = i => i.toString(); + +module.exports = { + generateExtensionId, + argumentIndexToId +}; diff --git a/src/extension-support/tw-security-manager.js b/src/extension-support/tw-security-manager.js new file mode 100644 index 00000000..a254ba24 --- /dev/null +++ b/src/extension-support/tw-security-manager.js @@ -0,0 +1,157 @@ +/* eslint-disable no-unused-vars */ + +/** + * Responsible for determining various policies related to custom extension security. + * The default implementation prevents automatic extension loading, but grants any + * loaded extensions the maximum possible capabilities so as to retain compatibility + * with a vanilla scratch-vm. You may override properties of an instance of this class + * to customize the security policies as you see fit, for example: + * ```js + * vm.securityManager.getSandboxMode = (url) => { + * if (url.startsWith("https://example.com/")) { + * return "unsandboxed"; + * } + * return "iframe"; + * }; + * vm.securityManager.canAutomaticallyLoadExtension = (url) => { + * return confirm("Automatically load extension: " + url); + * }; + * vm.securityManager.canFetch = (url) => { + * return url.startsWith('https://turbowarp.org/'); + * }; + * vm.securityManager.canOpenWindow = (url) => { + * return url.startsWith('https://turbowarp.org/'); + * }; + * vm.securityManager.canRedirect = (url) => { + * return url.startsWith('https://turbowarp.org/'); + * }; + * ``` + */ +class SecurityManager { + /** + * Determine the typeof sandbox to use for a certain custom extension. + * @param {string} extensionURL The URL of the custom extension. + * @returns {'worker'|'iframe'|'unsandboxed'|Promise<'worker'|'iframe'|'unsandboxed'>} + */ + getSandboxMode (extensionURL) { + // Default to worker for Scratch compatibility + return Promise.resolve('worker'); + } + + /** + * Determine whether a custom extension that was stored inside a project may be + * loaded. You could, for example, ask the user to confirm loading an extension + * before resolving. + * @param {string} extensionURL The URL of the custom extension. + * @returns {Promise|boolean} + */ + canLoadExtensionFromProject (extensionURL) { + // Default to false for security + return Promise.resolve(false); + } + + /** + * Allows last-minute changing the real URL of the extension that gets loaded. + * @param {*} extensionURL The URL requested to be loaded. + * @returns {Promise|string} The URL to actually load. + */ + rewriteExtensionURL (extensionURL) { + return Promise.resolve(extensionURL); + } + + /** + * Determine whether an extension is allowed to fetch a remote resource URL. + * This only applies to unsandboxed extensions that use the appropriate Scratch.* APIs. + * Sandboxed extensions ignore this entirely as there is no way to force them to use our APIs. + * data: and blob: URLs are always allowed (this method is never called). + * @param {string} resourceURL + * @returns {Promise|boolean} + */ + canFetch (resourceURL) { + // By default, allow any requests. + return Promise.resolve(true); + } + + /** + * Determine whether an extension is allowed to open a new window or tab to a given URL. + * This only applies to unsandboxed extensions. Sandboxed extensions are unable to open windows. + * javascript: URLs are always rejected (this method is never called). + * @param {string} websiteURL + * @returns {Promise|boolean} + */ + canOpenWindow (websiteURL) { + // By default, allow all. + return Promise.resolve(true); + } + + /** + * Determine whether an extension is allowed to redirect the current tab to a given URL. + * This only applies to unsandboxed extensions. Sandboxed extensions are unable to redirect the parent + * window, but are free to redirect their own sandboxed window. + * javascript: URLs are always rejected (this method is never called). + * @param {string} websiteURL + * @returns {Promise|boolean} + */ + canRedirect (websiteURL) { + // By default, allow all. + return Promise.resolve(true); + } + + /** + * Determine whether an extension is allowed to record audio from the user's microphone. + * This could include raw audio data or a transcriptions. + * Note that, even if this returns true, success is not guaranteed. + * @returns {Promise|boolean} + */ + canRecordAudio () { + return Promise.resolve(true); + } + + /** + * Determine whether an extension is allowed to record video from the user's camera. + * Note that, even if this returns true, success is not guaranteed. + * @returns {Promise|boolean} + */ + canRecordVideo () { + return Promise.resolve(true); + } + + /** + * Determine whether an extension is allowed to read values from the user's clipboard + * without user interaction. + * Note that, even if this returns true, success is not guaranteed. + * @returns {Promise|boolean} + */ + canReadClipboard () { + return Promise.resolve(true); + } + + /** + * Determine whether an extension is allowed to show notifications. + * Note that, even if this returns true, success is not guaranteed. + * @returns {Promise|boolean} + */ + canNotify () { + return Promise.resolve(true); + } + + /** + * Determine whether an extension is allowed to find the user's precise location using GPS + * and other techniques. Note that, even if this returns true, success is not guaranteed. + * @returns {Promise|boolean} + */ + canGeolocate () { + return Promise.resolve(true); + } + + /** + * Determine whether an extension is allowed to embed content from a given URL. + * @param {string} documentURL The URL of the embed. + * @returns {Promise|boolean} + */ + canEmbed (documentURL) { + return Promise.resolve(true); + } +} + +module.exports = SecurityManager; diff --git a/src/extension-support/tw-unsandboxed-extension-runner.js b/src/extension-support/tw-unsandboxed-extension-runner.js new file mode 100644 index 00000000..45312e76 --- /dev/null +++ b/src/extension-support/tw-unsandboxed-extension-runner.js @@ -0,0 +1,177 @@ +const ScratchCommon = require('./tw-extension-api-common'); +const createScratchX = require('./tw-scratchx-compatibility-layer'); +const AsyncLimiter = require('../util/async-limiter'); +const createTranslate = require('./tw-l10n'); +const staticFetch = require('../util/tw-static-fetch'); + +/* eslint-disable require-await */ + +/** + * Parse a URL object or return null. + * @param {string} url + * @returns {URL|null} + */ +const parseURL = url => { + try { + return new URL(url, location.href); + } catch (e) { + return null; + } +}; + +/** + * Sets up the global.Scratch API for an unsandboxed extension. + * @param {VirtualMachine} vm + * @returns {Promise} Resolves with a list of extension objects when Scratch.extensions.register is called. + */ +const setupUnsandboxedExtensionAPI = vm => new Promise(resolve => { + const extensionObjects = []; + const register = extensionObject => { + extensionObjects.push(extensionObject); + resolve(extensionObjects); + }; + + // Create a new copy of global.Scratch for each extension + const Scratch = Object.assign({}, global.Scratch || {}, ScratchCommon); + Scratch.extensions = { + unsandboxed: true, + register + }; + Scratch.vm = vm; + Scratch.renderer = vm.runtime.renderer; + + Scratch.canFetch = async url => { + const parsed = parseURL(url); + if (!parsed) { + return false; + } + // Always allow protocols that don't involve a remote request. + if (parsed.protocol === 'blob:' || parsed.protocol === 'data:') { + return true; + } + return vm.securityManager.canFetch(parsed.href); + }; + + Scratch.canOpenWindow = async url => { + const parsed = parseURL(url); + if (!parsed) { + return false; + } + // Always reject protocols that would allow code execution. + // eslint-disable-next-line no-script-url + if (parsed.protocol === 'javascript:') { + return false; + } + return vm.securityManager.canOpenWindow(parsed.href); + }; + + Scratch.canRedirect = async url => { + const parsed = parseURL(url); + if (!parsed) { + return false; + } + // Always reject protocols that would allow code execution. + // eslint-disable-next-line no-script-url + if (parsed.protocol === 'javascript:') { + return false; + } + return vm.securityManager.canRedirect(parsed.href); + }; + + Scratch.canRecordAudio = async () => vm.securityManager.canRecordAudio(); + + Scratch.canRecordVideo = async () => vm.securityManager.canRecordVideo(); + + Scratch.canReadClipboard = async () => vm.securityManager.canReadClipboard(); + + Scratch.canNotify = async () => vm.securityManager.canNotify(); + + Scratch.canGeolocate = async () => vm.securityManager.canGeolocate(); + + Scratch.canEmbed = async url => { + const parsed = parseURL(url); + if (!parsed) { + return false; + } + return vm.securityManager.canEmbed(parsed.href); + }; + + Scratch.fetch = async (url, options) => { + const actualURL = url instanceof Request ? url.url : url; + + const staticFetchResult = staticFetch(url); + if (staticFetchResult) { + return staticFetchResult; + } + + if (!await Scratch.canFetch(actualURL)) { + throw new Error(`Permission to fetch ${actualURL} rejected.`); + } + return fetch(url, options); + }; + + Scratch.openWindow = async (url, features) => { + if (!await Scratch.canOpenWindow(url)) { + throw new Error(`Permission to open tab ${url} rejected.`); + } + // Use noreferrer to prevent new tab from accessing `window.opener` + const baseFeatures = 'noreferrer'; + features = features ? `${baseFeatures},${features}` : baseFeatures; + return window.open(url, '_blank', features); + }; + + Scratch.redirect = async url => { + if (!await Scratch.canRedirect(url)) { + throw new Error(`Permission to redirect to ${url} rejected.`); + } + location.href = url; + }; + + Scratch.translate = createTranslate(vm); + + global.Scratch = Scratch; + global.ScratchExtensions = createScratchX(Scratch); + + vm.emit('CREATE_UNSANDBOXED_EXTENSION_API', Scratch); +}); + +/** + * Disable the existing global.Scratch unsandboxed extension APIs. + * This helps debug poorly designed extensions. + */ +const teardownUnsandboxedExtensionAPI = () => { + // We can assume global.Scratch already exists. + global.Scratch.extensions.register = () => { + throw new Error('Too late to register new extensions.'); + }; +}; + +/** + * Load an unsandboxed extension from an arbitrary URL. This is dangerous. + * @param {string} extensionURL + * @param {Virtualmachine} vm + * @returns {Promise} Resolves with a list of extension objects if the extension was loaded successfully. + */ +const loadUnsandboxedExtension = (extensionURL, vm) => new Promise((resolve, reject) => { + setupUnsandboxedExtensionAPI(vm).then(resolve); + + const script = document.createElement('script'); + script.onerror = () => { + reject(new Error(`Error in unsandboxed script ${extensionURL}. Check the console for more information.`)); + }; + script.src = extensionURL; + document.body.appendChild(script); +}).then(objects => { + teardownUnsandboxedExtensionAPI(); + return objects; +}); + +// Because loading unsandboxed extensions requires messing with global state (global.Scratch), +// only let one extension load at a time. +const limiter = new AsyncLimiter(loadUnsandboxedExtension, 1); +const load = (extensionURL, vm) => limiter.do(extensionURL, vm); + +module.exports = { + setupUnsandboxedExtensionAPI, + load +}; diff --git a/src/extensions/scratch3_makeymakey/index.js b/src/extensions/scratch3_makeymakey/index.js index a83ca661..e57bc76e 100644 --- a/src/extensions/scratch3_makeymakey/index.js +++ b/src/extensions/scratch3_makeymakey/index.js @@ -368,7 +368,7 @@ class Scratch3MakeyMakeyBlocks { */ addSequence (sequenceString, sequenceArray) { // If we already have this sequence string, return. - if (this.sequences.hasOwnProperty(sequenceString)) { + if (Object.prototype.hasOwnProperty.call(this.sequences, sequenceString)) { return; } this.sequences[sequenceString] = { diff --git a/src/extensions/scratch3_microbit/index.js b/src/extensions/scratch3_microbit/index.js index 6a7b9b6c..fbc7a903 100644 --- a/src/extensions/scratch3_microbit/index.js +++ b/src/extensions/scratch3_microbit/index.js @@ -15,7 +15,7 @@ const blockIconURI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYA /** * Enum for micro:bit BLE command protocol. - * https://github.com/LLK/scratch-microbit-firmware/blob/master/protocol.md + * https://github.com/scratchfoundation/scratch-microbit-firmware/blob/master/protocol.md * @readonly * @enum {number} */ @@ -46,7 +46,7 @@ const BLEDataStoppedError = 'micro:bit extension stopped receiving data'; /** * Enum for micro:bit protocol. - * https://github.com/LLK/scratch-microbit-firmware/blob/master/protocol.md + * https://github.com/scratchfoundation/scratch-microbit-firmware/blob/master/protocol.md * @readonly * @enum {string} */ diff --git a/src/extensions/scratch3_music/index.js b/src/extensions/scratch3_music/index.js index d2937455..939cb7a5 100644 --- a/src/extensions/scratch3_music/index.js +++ b/src/extensions/scratch3_music/index.js @@ -925,6 +925,13 @@ class Scratch3MusicBlocks { }; } + _isConcurrencyLimited () { + return ( + this.runtime.runtimeOptions.miscLimits && + this._concurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT + ); + } + /** * Play a drum sound for some number of beats. * @param {object} args - the block arguments. @@ -987,7 +994,7 @@ class Scratch3MusicBlocks { if (util.runtime.audioEngine === null) return; if (util.target.sprite.soundBank === null) return; // If we're playing too many sounds, do not play the drum sound. - if (this._concurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT) { + if (this._isConcurrencyLimited()) { return; } @@ -1088,7 +1095,7 @@ class Scratch3MusicBlocks { if (util.target.sprite.soundBank === null) return; // If we're playing too many sounds, do not play the note. - if (this._concurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT) { + if (this._isConcurrencyLimited()) { return; } diff --git a/src/extensions/scratch3_pen/index.js b/src/extensions/scratch3_pen/index.js index c9f86734..b1b8eace 100644 --- a/src/extensions/scratch3_pen/index.js +++ b/src/extensions/scratch3_pen/index.js @@ -6,7 +6,6 @@ const Clone = require('../../util/clone'); const Color = require('../../util/color'); const formatMessage = require('format-message'); const MathUtil = require('../../util/math-util'); -const RenderedTarget = require('../../sprites/rendered-target'); const log = require('../../util/log'); const StageLayering = require('../../engine/stage-layering'); @@ -106,6 +105,8 @@ class Scratch3PenBlocks { * @type {string} */ static get STATE_KEY () { + // tw: We've hardcoded this value in various places for slight performance gains + // Make sure to update those if this changes. return 'Scratch.pen'; } @@ -150,7 +151,7 @@ class Scratch3PenBlocks { * @private */ _getPenState (target) { - let penState = target.getCustomState(Scratch3PenBlocks.STATE_KEY); + let penState = target._customState['Scratch.pen']; if (!penState) { penState = Clone.simple(Scratch3PenBlocks.DEFAULT_PEN_STATE); target.setCustomState(Scratch3PenBlocks.STATE_KEY, penState); @@ -171,7 +172,7 @@ class Scratch3PenBlocks { if (penState) { newTarget.setCustomState(Scratch3PenBlocks.STATE_KEY, Clone.simple(penState)); if (penState.penDown) { - newTarget.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + newTarget.onTargetMoved = this._onTargetMoved; } } } @@ -297,6 +298,16 @@ class Scratch3PenBlocks { }), blockIconURI: blockIconURI, blocks: [ + // tw: additional message when on the stage for clarity + { + blockType: BlockType.LABEL, + text: formatMessage({ + id: 'tw.pen.stageSelected', + default: 'Stage selected: less pen blocks', + description: 'Label that appears in the Pen category when the stage is selected' + }), + filter: [TargetType.STAGE] + }, { opcode: 'clear', blockType: BlockType.COMMAND, @@ -540,7 +551,7 @@ class Scratch3PenBlocks { if (!penState.penDown) { penState.penDown = true; - target.addListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + target.onTargetMoved = this._onTargetMoved; } const penSkinId = this._getPenLayerID(); @@ -563,7 +574,7 @@ class Scratch3PenBlocks { if (penState.penDown) { penState.penDown = false; - target.removeListener(RenderedTarget.EVENT_TARGET_MOVED, this._onTargetMoved); + target.onTargetMoved = null; } } @@ -584,7 +595,7 @@ class Scratch3PenBlocks { penState.color = (hsv.h / 360) * 100; penState.saturation = hsv.s * 100; penState.brightness = hsv.v * 100; - if (rgb.hasOwnProperty('a')) { + if (Object.prototype.hasOwnProperty.call(rgb, 'a')) { penState.transparency = 100 * (1 - (rgb.a / 255.0)); } else { penState.transparency = 0; diff --git a/src/extensions/scratch3_text2speech/index.js b/src/extensions/scratch3_text2speech/index.js index 4027526a..bc8e6b5d 100644 --- a/src/extensions/scratch3_text2speech/index.js +++ b/src/extensions/scratch3_text2speech/index.js @@ -7,8 +7,8 @@ const Cast = require('../../util/cast'); const MathUtil = require('../../util/math-util'); const Clone = require('../../util/clone'); const log = require('../../util/log'); -const fetchWithTimeout = require('../../util/fetch-with-timeout'); - +const {fetchWithTimeout} = require('../../util/fetch-with-timeout'); +const getXGAccessCode = require('../../util/xg-access-code'); /** * Icon svg to be displayed in the blocks category menu, encoded as a data URI. @@ -158,9 +158,9 @@ class Scratch3Text2SpeechBlocks { */ this._supportedLocales = this._getSupportedLocales(); // powered by xigua start - this.thirdPartApiKey = localStorage.getItem('xg-access-code'); + this.thirdPartApiKey = getXGAccessCode(); - if (runtime.ccwAPI && runtime.ccwAPI.getOnlineExtensionsConfig) { + if (runtime && runtime.ccwAPI && runtime.ccwAPI.getOnlineExtensionsConfig) { const config = runtime.ccwAPI.getOnlineExtensionsConfig(); if (config && config.hosts && config.hosts.tts) { this.host = config.hosts.tts; @@ -230,7 +230,7 @@ class Scratch3Text2SpeechBlocks { * * SCRATCH LOCALE * Set by the editor, and used to store the language state in the project. - * Listed in l10n: https://github.com/LLK/scratch-l10n/blob/master/src/supported-locales.js + * Listed in l10n: https://github.com/scratchfoundation/scratch-l10n/blob/master/src/supported-locales.js * SUPPORTED LOCALE * A Scratch locale that has a corresponding extension locale. * EXTENSION LOCALE @@ -318,7 +318,7 @@ class Scratch3Text2SpeechBlocks { id: 'text2speech.Japanese', default: 'Japanese' }), - locales: ['ja', 'ja-Hira'], + locales: ['ja', 'ja-hira'], speechSynthLocale: 'ja-JP' }, [KOREAN_ID]: { diff --git a/src/extensions/scratch3_translate/index.js b/src/extensions/scratch3_translate/index.js index be58dea0..32a992ad 100644 --- a/src/extensions/scratch3_translate/index.js +++ b/src/extensions/scratch3_translate/index.js @@ -2,9 +2,10 @@ const ArgumentType = require('../../extension-support/argument-type'); const BlockType = require('../../extension-support/block-type'); const Cast = require('../../util/cast'); const log = require('../../util/log'); -const fetchWithTimeout = require('../../util/fetch-with-timeout'); +const {fetchWithTimeout} = require('../../util/fetch-with-timeout'); const languageNames = require('scratch-translate-extension-languages'); const formatMessage = require('format-message'); +const getXGAccessCode = require('../../util/xg-access-code'); /** * Icon svg to be displayed in the blocks category menu, encoded as a data URI. * @type {string} @@ -101,9 +102,9 @@ class Scratch3TranslateBlocks { */ this._lastTextTranslated = ''; // powered by xigua start - this.thirdPartApiKey = localStorage.getItem('xg-access-code'); + this.thirdPartApiKey = getXGAccessCode(); - if (runtime.ccwAPI && runtime.ccwAPI.getOnlineExtensionsConfig) { + if (runtime && runtime.ccwAPI && runtime.ccwAPI.getOnlineExtensionsConfig) { const config = runtime.ccwAPI.getOnlineExtensionsConfig(); if (config && config.hosts && config.hosts.translate) { this.host = config.hosts.translate; @@ -279,11 +280,11 @@ class Scratch3TranslateBlocks { getLanguageCodeFromArg (arg) { const languageArg = Cast.toString(arg).toLowerCase(); // Check if the arg matches a language code in the menu. - if (languageNames.menuMap.hasOwnProperty(languageArg)) { + if (Object.prototype.hasOwnProperty.call(languageNames.menuMap, languageArg)) { return languageArg; } // Check for a dropped-in language name, and convert to a language code. - if (languageNames.nameMap.hasOwnProperty(languageArg)) { + if (Object.prototype.hasOwnProperty.call(languageNames.nameMap, languageArg)) { return languageNames.nameMap[languageArg]; } @@ -388,7 +389,7 @@ class Scratch3TranslateBlocks { }) .catch(err => { log.warn(`error fetching translate result! ${err}`); - return ''; + return args.WORDS; }); return translatePromise; } diff --git a/src/extensions/scratch3_video_sensing/index.js b/src/extensions/scratch3_video_sensing/index.js index 11643782..f46ee81f 100644 --- a/src/extensions/scratch3_video_sensing/index.js +++ b/src/extensions/scratch3_video_sensing/index.js @@ -167,7 +167,6 @@ class Scratch3VideoSensingBlocks { if (stage) { stage.videoTransparency = transparency; } - return transparency; } /** @@ -191,7 +190,6 @@ class Scratch3VideoSensingBlocks { if (stage) { stage.videoState = state; } - return state; } /** @@ -231,7 +229,8 @@ class Scratch3VideoSensingBlocks { * @private */ _loop () { - setTimeout(this._loop.bind(this), Math.max(this.runtime.currentStepTime, Scratch3VideoSensingBlocks.INTERVAL)); + const loopTime = Math.max(this.runtime.currentStepTime, Scratch3VideoSensingBlocks.INTERVAL); + this._loopInterval = setTimeout(this._loop.bind(this), loopTime); // Add frame to detector const time = Date.now(); @@ -251,6 +250,13 @@ class Scratch3VideoSensingBlocks { } } + /** + * Stop the video sampling loop. Only used for testing. + */ + _stopLoop () { + clearTimeout(this._loopInterval); + } + /** * Create data for a menu in scratch-blocks format, consisting of an array * of objects with text and value properties. The text is a translated diff --git a/src/extensions/scratch3_wedo2/index.js b/src/extensions/scratch3_wedo2/index.js index 6806e4f3..64a6ee46 100644 --- a/src/extensions/scratch3_wedo2/index.js +++ b/src/extensions/scratch3_wedo2/index.js @@ -269,8 +269,6 @@ class WeDo2Motor { * Turn this motor on indefinitely. */ turnOn () { - if (this._power === 0) return; - const cmd = this._parent.generateOutputCommand( this._index + 1, WeDo2Command.MOTOR_POWER, diff --git a/src/extensions/tw/index.js b/src/extensions/tw/index.js index d50d6cce..9485ff00 100644 --- a/src/extensions/tw/index.js +++ b/src/extensions/tw/index.js @@ -3,6 +3,9 @@ const BlockType = require('../../extension-support/block-type'); const ArgumentType = require('../../extension-support/argument-type'); const Cast = require('../../util/cast'); +// eslint-disable-next-line max-len +const iconURI = `data:image/svg+xml;base64,${btoa('')}`; + /** * Class for TurboWarp blocks * @constructor @@ -25,7 +28,10 @@ class TurboWarpBlocks { name: 'TurboWarp', color1: '#ff4c4c', color2: '#e64444', + color3: '#c73a3a', docsURI: 'https://docs.turbowarp.org/blocks', + menuIconURI: iconURI, + blockIconURI: iconURI, blocks: [ { opcode: 'getLastKeyPressed', @@ -34,7 +40,6 @@ class TurboWarpBlocks { default: 'last key pressed', description: 'Block that returns the last key that was pressed' }), - disableMonitor: true, blockType: BlockType.REPORTER }, { diff --git a/src/import/load-costume.js b/src/import/load-costume.js index 2ded9748..ea5a2d3f 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -1,11 +1,24 @@ const StringUtil = require('../util/string-util'); const uid = require('../util/uid'); const log = require('../util/log'); -const {loadSvgString, serializeSvgToString} = require('scratch-svg-renderer'); +const AsyncLimiter = require('../util/async-limiter'); +const {loadSvgString, serializeSvgToString} = require('@turbowarp/scratch-svg-renderer'); +const {parseVectorMetadata} = require('../serialization/tw-costume-import-export'); const loadVector_ = function (costume, runtime, rotationCenter, optVersion) { return new Promise(resolve => { let svgString = costume.asset.decodeText(); + + // TW: We allow SVGs to specify their rotation center using a special comment. + if (typeof rotationCenter === 'undefined') { + const parsedRotationCenter = parseVectorMetadata(svgString); + if (parsedRotationCenter) { + rotationCenter = parsedRotationCenter; + costume.rotationCenterX = rotationCenter[0]; + costume.rotationCenterY = rotationCenter[1]; + } + } + // SVG Renderer load fixes "quirks" associated with Scratch 2 projects if (optVersion && optVersion === 2) { // scratch-svg-renderer fixes syntax that causes loading issues, @@ -34,6 +47,11 @@ const loadVector_ = function (costume, runtime, rotationCenter, optVersion) { costume.bitmapResolution = 1; } runtime.emit('LOAD_ASSETS_PROGRESS', costume); + + if (runtime.isPackaged) { + costume.asset = null; + } + resolve(costume); }); }; @@ -85,6 +103,64 @@ const canvasPool = (function () { return new CanvasPool(); }()); +/** + * @param {string} src URL of image + * @returns {Promise} + */ +const readAsImageElement = src => new Promise((resolve, reject) => { + const image = new Image(); + image.onload = function () { + resolve(image); + image.onload = null; + image.onerror = null; + }; + image.onerror = function () { + reject(new Error('Costume load failed. Asset could not be read.')); + image.onload = null; + image.onerror = null; + }; + image.src = src; +}); + +/** + * @param {Asset} asset scratch-storage asset + * @returns {Promise} + */ +const _persistentReadImage = async asset => { + // Sometimes, when a lot of images are loaded at once, especially in Chrome, reading an image + // can throw an error even on valid images. To mitigate this, we'll retry image reading a few + // time with delays. + let firstError; + for (let i = 0; i < 3; i++) { + try { + if (typeof createImageBitmap === 'function') { + const imageBitmap = await createImageBitmap( + new Blob([asset.data.buffer], {type: asset.assetType.contentType}) + ); + // If we do too many createImageBitmap at the same time, some browsers (Chrome) will + // sometimes resolve with undefined. We limit concurrency so this shouldn't ever + // happen, but if it somehow does, throw an error so it can be retried or so that it + // falls back to scratch's broken costume handling. + if (!imageBitmap) { + throw new Error(`createImageBitmap resolved with ${imageBitmap}`); + } + return imageBitmap; + } + return await readAsImageElement(asset.encodeDataURI()); + } catch (e) { + if (!firstError) { + firstError = e; + } + log.warn(e); + await new Promise(resolve => setTimeout(resolve, Math.random() * 2000)); + } + } + throw firstError; +}; + +// Browsers break when we do too many createImageBitmap at the same time. +const readImage = new AsyncLimiter(_persistentReadImage, 25); + /** * Return a promise to fetch a bitmap from storage and return it as a canvas * If the costume has bitmapResolution 1, it will be converted to bitmapResolution 2 here (the standard for Scratch 3) @@ -100,10 +176,14 @@ const canvasPool = (function () { * assetMatchesBase is true if the asset matches the base layer; false if it required adjustment */ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) { - if (!costume || !costume.asset) { + if (!costume || !costume.asset) { // TODO: We can probably remove this check... + // TODO: reject with an Error (breaking API change!) + // eslint-disable-next-line prefer-promise-reject-errors return Promise.reject('Costume load failed. Assets were missing.'); } if (!runtime.v2BitmapAdapter) { + // TODO: reject with an Error (breaking API change!) + // eslint-disable-next-line prefer-promise-reject-errors return Promise.reject('No V2 Bitmap adapter present.'); } @@ -142,28 +222,42 @@ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) { }); })) .then(([baseImageElement, textImageElement]) => { - const mergeCanvas = canvasPool.create(); + if (!baseImageElement) { + throw new Error('Loading bitmap costume base failed.'); + } const scale = costume.bitmapResolution === 1 ? 2 : 1; - mergeCanvas.width = baseImageElement.width; - mergeCanvas.height = baseImageElement.height; - const ctx = mergeCanvas.getContext('2d'); - ctx.drawImage(baseImageElement, 0, 0); + let imageOrCanvas; + let canvas; if (textImageElement) { + canvas = canvasPool.create(); + canvas.width = baseImageElement.width; + canvas.height = baseImageElement.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(baseImageElement, 0, 0); ctx.drawImage(textImageElement, 0, 0); + imageOrCanvas = canvas; + } else { + imageOrCanvas = baseImageElement; } - // Track the canvas we merged the bitmaps onto separately from the - // canvas that we receive from resize if scale is not 1. We know - // resize treats mergeCanvas as read only data. We don't know when - // resize may use or modify the canvas. So we'll only release the - // mergeCanvas back into the canvas pool. Reusing the canvas from - // resize may cause errors. - let canvas = mergeCanvas; if (scale !== 1) { - canvas = runtime.v2BitmapAdapter.resize(mergeCanvas, canvas.width * scale, canvas.height * scale); + // resize() returns a new canvas. + imageOrCanvas = runtime.v2BitmapAdapter.resize( + imageOrCanvas, + imageOrCanvas.width * scale, + imageOrCanvas.height * scale + ); + // Old canvas is no longer used. + if (canvas) { + canvasPool.release(canvas); + } } + // This informs TurboWarp/scratch-render that this canvas won't be reused by the canvas pool, + // which helps it optimize memory use. + imageOrCanvas.reusable = false; + // By scaling, we've converted it to bitmap resolution 2 if (rotationCenter) { rotationCenter[0] = rotationCenter[0] * scale; @@ -180,21 +274,33 @@ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) { runtime.emit('LOAD_ASSETS_PROGRESS', costume); return { - canvas, - mergeCanvas, + image: imageOrCanvas, rotationCenter, // True if the asset matches the base layer; false if it required adjustment assetMatchesBase: scale === 1 && !textImageElement }; }) - .catch(e => { + .finally(() => { // Clean up the text layer properties if it fails to load delete costume.textLayerMD5; delete costume.textLayerAsset; - throw e; }); }; +const toDataURL = imageOrCanvas => { + if (imageOrCanvas instanceof HTMLCanvasElement) { + return imageOrCanvas.toDataURL(); + } + const canvas = canvasPool.create(); + canvas.width = imageOrCanvas.width; + canvas.height = imageOrCanvas.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(imageOrCanvas, 0, 0); + const url = canvas.toDataURL(); + canvasPool.release(canvas); + return url; +}; + const loadBitmap_ = function (costume, runtime, _rotationCenter) { return fetchBitmapCanvas_(costume, runtime, _rotationCenter) .then(fetched => { @@ -206,6 +312,8 @@ const loadBitmap_ = function (costume, runtime, _rotationCenter) { // somewhere and act on that error (like logging). // // Return a rejection to stop executing updateCostumeAsset. + // TODO: reject with an Error (breaking API change!) + // eslint-disable-next-line prefer-promise-reject-errors return Promise.reject('No V2 Bitmap adapter present.'); } @@ -222,13 +330,13 @@ const loadBitmap_ = function (costume, runtime, _rotationCenter) { costume.md5 = `${costume.assetId}.${costume.dataFormat}`; }; - if (fetched && !fetched.assetMatchesBase) { - updateCostumeAsset(fetched.canvas.toDataURL()); + if (!fetched.assetMatchesBase) { + updateCostumeAsset(toDataURL(fetched.image)); } return fetched; }) - .then(({canvas, mergeCanvas, rotationCenter}) => { + .then(({image, rotationCenter}) => { // createBitmapSkin does the right thing if costume.rotationCenter is undefined. // That will be the case if you upload a bitmap asset or create one by taking a photo. let center; @@ -243,8 +351,7 @@ const loadBitmap_ = function (costume, runtime, _rotationCenter) { // TODO: costume.bitmapResolution will always be 2 at this point because of fetchBitmapCanvas_, so we don't // need to pass it in here. - costume.skinId = runtime.renderer.createBitmapSkin(canvas, costume.bitmapResolution, center); - canvasPool.release(mergeCanvas); + costume.skinId = runtime.renderer.createBitmapSkin(image, costume.bitmapResolution, center); const renderSize = runtime.renderer.getSkinSize(costume.skinId); costume.size = [renderSize[0] * 2, renderSize[1] * 2]; // Actual size, since all bitmaps are resolution 2 @@ -255,6 +362,11 @@ const loadBitmap_ = function (costume, runtime, _rotationCenter) { costume.rotationCenterY = rotationCenter[1] * 2; costume.bitmapResolution = 2; } + + if (runtime.isPackaged) { + costume.asset = null; + } + return costume; }); }; @@ -331,7 +443,7 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) { costume.assetId = costume.asset.assetId; const renderer = runtime.renderer; if (!renderer) { - log.error('No rendering module present; cannot load costume: ', costume.name); + log.warn('No rendering module present; cannot load costume: ', costume.name); return Promise.resolve(costume); } const AssetType = runtime.storage.AssetType; @@ -356,6 +468,7 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) { }); }; + /** * Load a costume's asset into memory asynchronously. * Do not call this unless there is a renderer attached. @@ -383,12 +496,12 @@ const loadCostume = function (md5ext, costume, runtime, optVersion) { // Need to load the costume from storage. The server should have a reference to this md5. if (!runtime.storage) { - log.error('No storage module present; cannot load costume asset: ', md5ext); + log.warn('No storage module present; cannot load costume asset: ', md5ext); return Promise.resolve(costume); } if (!runtime.storage.defaultAssetId) { - log.error(`No default assets found`); + log.warn(`No default assets found`); return Promise.resolve(costume); } diff --git a/src/import/load-sound.js b/src/import/load-sound.js index a2dbd71f..0bd3d608 100644 --- a/src/import/load-sound.js +++ b/src/import/load-sound.js @@ -18,7 +18,7 @@ const loadSoundFromAsset = function (sound, soundAsset, runtime, soundBank) { sound.id = sound.id || uid(); sound.assetId = soundAsset.assetId; if (!runtime.audioEngine) { - log.error('No audio engine present; cannot load sound asset: ', sound.md5); + log.warn('No audio engine present; cannot load sound asset: ', sound.md5); return Promise.resolve(sound); } return runtime.audioEngine.decodeSoundPlayer(Object.assign( @@ -38,6 +38,11 @@ const loadSoundFromAsset = function (sound, soundAsset, runtime, soundBank) { soundBank.addSoundPlayer(soundPlayer); } runtime.emit('LOAD_ASSETS_PROGRESS', sound); + + if (runtime.isPackaged) { + sound.asset = null; + } + return sound; }); }; @@ -99,7 +104,7 @@ const handleSoundLoadError = function (sound, runtime, soundBank) { */ const loadSound = function (sound, runtime, soundBank) { if (!runtime.storage) { - log.error('No storage module present; cannot load sound asset: ', sound.md5); + log.warn('No storage module present; cannot load sound asset: ', sound.md5); return Promise.resolve(sound); } if (!runtime.storage.defaultAssetId) { diff --git a/src/io/keyboard.js b/src/io/keyboard.js index 53b0081b..ed6605fe 100644 --- a/src/io/keyboard.js +++ b/src/io/keyboard.js @@ -27,10 +27,10 @@ const KEY_NAME = { }; /** - * An array of the names of scratch keys. - * @type {Array} + * A set of the names of Scratch keys. + * @type {Set} */ -const KEY_NAME_LIST = Object.keys(KEY_NAME).map(name => KEY_NAME[name]); +const KEY_NAME_SET = new Set(Object.values(KEY_NAME)); class Keyboard { constructor (runtime) { @@ -121,7 +121,9 @@ class Keyboard { keyArg = Cast.toString(keyArg); // If the arg matches a special key name, return it. - if (KEY_NAME_LIST.includes(keyArg)) { + // No special keys have a name that is only 1 character long, so we can avoid the lookup + // entirely in the most common case. + if (keyArg.length > 1 && KEY_NAME_SET.has(keyArg)) { return keyArg; } @@ -171,7 +173,7 @@ class Keyboard { this._keysPressed.splice(index, 1); } // Fix for https://github.com/LLK/scratch-vm/issues/2271 - if (data.hasOwnProperty('keyCode')) { + if (Object.prototype.hasOwnProperty.call(data, 'keyCode')) { const keyCode = data.keyCode; if (this._numeralKeyCodesToStringKey.has(keyCode)) { const lastKeyOfSameCode = this._numeralKeyCodesToStringKey.get(keyCode); diff --git a/src/io/mouse.js b/src/io/mouse.js index 3857d517..d5408591 100644 --- a/src/io/mouse.js +++ b/src/io/mouse.js @@ -1,5 +1,7 @@ const MathUtil = require('../util/math-util'); +const roundToThreeDecimals = number => Math.round(number * 1000) / 1000; + class Mouse { constructor (runtime) { this._clientX = 0; @@ -50,7 +52,7 @@ class Mouse { const drawableID = this.runtime.renderer.pick(x, y); for (let i = 0; i < this.runtime.targets.length; i++) { const target = this.runtime.targets[i]; - if (target.hasOwnProperty('drawableID') && + if (Object.prototype.hasOwnProperty.call(target, 'drawableID') && target.drawableID === drawableID) { return target; } @@ -65,21 +67,21 @@ class Mouse { * @param {object} data Data from DOM event. */ postData (data) { - if (data.x) { + if (typeof data.x === 'number') { this._clientX = data.x; - this._scratchX = Math.round(MathUtil.clamp( + this._scratchX = MathUtil.clamp( this.runtime.stageWidth * ((data.x / data.canvasWidth) - 0.5), -(this.runtime.stageWidth / 2), (this.runtime.stageWidth / 2) - )); + ); } - if (data.y) { + if (typeof data.y === 'number') { this._clientY = data.y; - this._scratchY = Math.round(MathUtil.clamp( + this._scratchY = MathUtil.clamp( -this.runtime.stageHeight * ((data.y / data.canvasHeight) - 0.5), -(this.runtime.stageHeight / 2), (this.runtime.stageHeight / 2) - )); + ); } if (typeof data.isDown !== 'undefined') { // If no button specified, default to left button for compatibility @@ -138,7 +140,10 @@ class Mouse { * @return {number} Clamped and integer rounded X position of the mouse cursor. */ getScratchX () { - return this._scratchX; + if (this.runtime.runtimeOptions.miscLimits) { + return Math.round(this._scratchX); + } + return roundToThreeDecimals(this._scratchX); } /** @@ -146,7 +151,10 @@ class Mouse { * @return {number} Clamped and integer rounded Y position of the mouse cursor. */ getScratchY () { - return this._scratchY; + if (this.runtime.runtimeOptions.miscLimits) { + return Math.round(this._scratchY); + } + return roundToThreeDecimals(this._scratchY); } /** diff --git a/src/io/video.js b/src/io/video.js index 23dce8b7..35eeadf7 100644 --- a/src/io/video.js +++ b/src/io/video.js @@ -152,6 +152,11 @@ class Video { this._skinId = renderer.createBitmapSkin(new ImageData(...Video.DIMENSIONS), 1); this._drawable = renderer.createDrawable(StageLayering.VIDEO_LAYER); renderer.updateDrawableSkinId(this._drawable, this._skinId); + // TW: Video probably contains the user's face. This is private information. + // This API won't exist if we're using a vanilla scratch-render + if (renderer.markSkinAsPrivate) { + renderer.markSkinAsPrivate(this._skinId); + } } // if we haven't already created and started a preview frame render loop, do so diff --git a/src/playground/benchmark.js b/src/playground/benchmark.js index a2c706df..85da6b49 100644 --- a/src/playground/benchmark.js +++ b/src/playground/benchmark.js @@ -49,7 +49,7 @@ const Runtime = require('../engine/runtime'); const ScratchRender = require('scratch-render'); const AudioEngine = require('scratch-audio'); -const ScratchSVGRenderer = require('scratch-svg-renderer'); +const ScratchSVGRenderer = require('@turbowarp/scratch-svg-renderer'); const Scratch = window.Scratch = window.Scratch || {}; @@ -58,12 +58,20 @@ const PROJECT_SERVER = 'https://cdn.projects.scratch.mit.edu/'; const SLOW = .1; -const projectInput = document.querySelector('input'); +const projectInput = document.querySelector('#project-id'); +if (location.hash) { + projectInput.value = location.hash.substring(1); +} + +const enableCompiler = new URLSearchParams(location.search).get('compiler') === 'true'; +const compilerInput = document.querySelector('#enable-compiler'); +compilerInput.checked = enableCompiler; document.querySelector('.run') .addEventListener('click', () => { - window.location.hash = projectInput.value; - location.reload(); + const params = new URLSearchParams(location.search); + params.set('compiler', compilerInput.checked); + location.href = `${location.pathname}?${params}#${projectInput.value}`; }, false); const setShareLink = function (json) { @@ -73,12 +81,35 @@ const setShareLink = function (json) { .href = `suite.html`; }; +const getProjectMetadata = async projectId => { + const response = await fetch(`https://trampoline.turbowarp.org/api/projects/${projectId}`); + if (response.status === 404) { + throw new Error('The project is unshared or does not exist'); + } + if (!response.ok) { + throw new Error(`HTTP error ${response.status} fetching project metadata`); + } + const json = await response.json(); + return json; +}; + +const getProjectData = async projectId => { + const metadata = await getProjectMetadata(projectId); + const token = metadata.project_token; + const response = await fetch(`https://projects.scratch.mit.edu/${projectId}?token=${token}`); + if (!response.ok) { + throw new Error(`HTTP error ${response.status} fetching project data`); + } + const data = await response.arrayBuffer(); + return data; +}; + const loadProject = function () { let id = location.hash.substring(1).split(',')[0]; if (id.length < 1 || !isFinite(id)) { id = projectInput.value; } - Scratch.vm.downloadProjectId(id); + getProjectData(id).then(data => Scratch.vm.loadProject(data)); return id; }; @@ -588,6 +619,9 @@ const runBenchmark = function () { const vm = new VirtualMachine(); Scratch.vm = vm; + vm.setCompilerOptions({ + enabled: enableCompiler + }); vm.setTurboMode(true); const storage = new ScratchStorage(); diff --git a/src/playground/index.html b/src/playground/index.html index 8459fcdd..43b3d423 100644 --- a/src/playground/index.html +++ b/src/playground/index.html @@ -23,15 +23,22 @@

Scratch VM Benchmark

Welcome to the scratch-vm benchmark. This tool helps you profile a scratch project. When you load the page, it: -

    -
  1. loads the default project and enables turbo mode -
  2. runs the project for 4 seconds to warm up -
  3. profiles for 6 seconds -
  4. stops and reports -
+

+
    +
  1. loads the default project and enables turbo mode +
  2. runs the project for 4 seconds to warm up +
  3. profiles for 6 seconds +
  4. stops and reports +
+

+ The benchmark is not very useful when the compiler is enabled.

- + +

diff --git a/src/serialization/deserialize-assets.js b/src/serialization/deserialize-assets.js index bf59f915..12812ac3 100644 --- a/src/serialization/deserialize-assets.js +++ b/src/serialization/deserialize-assets.js @@ -1,4 +1,4 @@ -const JSZip = require('jszip'); +const JSZip = require('@turbowarp/jszip'); const log = require('../util/log'); /** @@ -18,7 +18,7 @@ const deserializeSound = function (sound, runtime, zip, assetFileName) { const fileName = assetFileName ? assetFileName : sound.md5; const storage = runtime.storage; if (!storage) { - log.error('No storage module present; cannot load sound asset: ', fileName); + log.warn('No storage module present; cannot load sound asset: ', fileName); return Promise.resolve(null); } @@ -81,7 +81,7 @@ const deserializeCostume = function (costume, runtime, zip, assetFileName, textL `${assetId}.${costume.dataFormat}`; if (!storage) { - log.error('No storage module present; cannot load costume asset: ', fileName); + log.warn('No storage module present; cannot load costume asset: ', fileName); return Promise.resolve(null); } diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index 1878606c..5b8bef7f 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -18,6 +18,7 @@ const Comment = require('../engine/comment'); const Variable = require('../engine/variable'); const MonitorRecord = require('../engine/monitor-record'); const StageLayering = require('../engine/stage-layering'); +const ScratchXUtilities = require('../extension-support/tw-scratchx-utilities'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); @@ -44,6 +45,48 @@ const CORE_EXTENSIONS = [ const WORKSPACE_X_SCALE = 1.5; const WORKSPACE_Y_SCALE = 2.2; +// By examining ScratchX projects, we've found that ScratchX can use either "\u001f" or "." +// to separate the extension name from the extension method opcode eg. "Text To Speech.say" +// eslint-disable-next-line no-control-regex +const SCRATCHX_OPCODE_SEPARATOR = /\u001f|\./; + +/** + * @param {string} opcode + * @returns {boolean} + */ +const isPossiblyScratchXBlock = opcode => SCRATCHX_OPCODE_SEPARATOR.test(opcode); + +/** + * @param {string} opcode + * @returns {string} + */ +const mapScratchXOpcode = opcode => { + const [extensionName, extensionMethod] = opcode.split(SCRATCHX_OPCODE_SEPARATOR); + const newOpcodeBase = ScratchXUtilities.generateExtensionId(extensionName); + return `${newOpcodeBase}_${extensionMethod}`; +}; + +/** + * @param {object} block + * @returns {object} + */ +const mapScratchXBlock = block => { + const opcode = block[0]; + const argumentCount = block.length - 1; + const args = []; + for (let i = 0; i < argumentCount; i++) { + args.push({ + type: 'input', + inputOp: 'text', + inputName: ScratchXUtilities.argumentIndexToId(i) + }); + } + return { + opcode: mapScratchXOpcode(opcode), + argMap: args + }; +}; + /** * Convert a Scratch 2.0 procedure string (e.g., "my_procedure %s %b %n") * into an argument map. This allows us to provide the expected inputs @@ -288,7 +331,7 @@ const parseMonitorObject = (object, runtime, targets, extensions) => { let target = null; // List blocks don't come in with their target name set. // Find the target by searching for a target with matching variable name/type. - if (!object.hasOwnProperty('target')) { + if (!Object.prototype.hasOwnProperty.call(object, 'target')) { for (let i = 0; i < targets.length; i++) { const currTarget = targets[i]; const listVariables = Object.keys(currTarget.variables).filter(key => { @@ -326,7 +369,7 @@ const parseMonitorObject = (object, runtime, targets, extensions) => { block.id = getVariableId(object.param, Variable.SCALAR_TYPE); } else if (object.cmd === 'contentsOfList:') { block.id = getVariableId(object.param, Variable.LIST_TYPE); - } else if (runtime.monitorBlockInfo.hasOwnProperty(block.opcode)) { + } else if (Object.prototype.hasOwnProperty.call(runtime.monitorBlockInfo, block.opcode)) { block.id = runtime.monitorBlockInfo[block.opcode].getId(target.id, block.fields); } else { // If the opcode can't be found in the runtime monitorBlockInfo, @@ -406,7 +449,7 @@ const parseMonitorObject = (object, runtime, targets, extensions) => { * objects. */ const parseScratchAssets = function (object, runtime, topLevel, zip) { - if (!object.hasOwnProperty('objName')) { + if (!Object.prototype.hasOwnProperty.call(object, 'objName')) { // Skip parsing monitors. Or any other objects missing objName. return null; } @@ -420,7 +463,7 @@ const parseScratchAssets = function (object, runtime, topLevel, zip) { // Costumes from JSON. const costumePromises = assets.costumePromises; - if (object.hasOwnProperty('costumes')) { + if (Object.prototype.hasOwnProperty.call(object, 'costumes')) { for (let i = 0; i < object.costumes.length; i++) { const costumeSource = object.costumes[i]; const bitmapResolution = costumeSource.bitmapResolution || 1; @@ -458,14 +501,15 @@ const parseScratchAssets = function (object, runtime, topLevel, zip) { // the file name of the costume should be the baseLayerID followed by the file ext const assetFileName = `${costumeSource.baseLayerID}.${ext}`; const textLayerFileName = costumeSource.textLayerID ? `${costumeSource.textLayerID}.png` : null; - costumePromises.push(deserializeCostume(costume, runtime, zip, assetFileName, textLayerFileName) - .then(() => loadCostume(costume.md5, costume, runtime, 2 /* optVersion */)) - ); + costumePromises.push(runtime.wrapAssetRequest(() => + deserializeCostume(costume, runtime, zip, assetFileName, textLayerFileName) + .then(() => loadCostume(costume.md5, costume, runtime, 2 /* optVersion */)) + )); } } // Sounds from JSON const {soundBank, soundPromises} = assets; - if (object.hasOwnProperty('sounds')) { + if (Object.prototype.hasOwnProperty.call(object, 'sounds')) { for (let s = 0; s < object.sounds.length; s++) { const soundSource = object.sounds[s]; const sound = { @@ -493,10 +537,10 @@ const parseScratchAssets = function (object, runtime, topLevel, zip) { // the file name of the sound should be the soundID (provided from the project.json) // followed by the file ext const assetFileName = `${soundSource.soundID}.${ext}`; - soundPromises.push( + soundPromises.push(runtime.wrapAssetRequest(() => deserializeSound(sound, runtime, zip, assetFileName) .then(() => loadSound(sound, runtime, soundBank)) - ); + )); } } @@ -524,8 +568,8 @@ const parseScratchAssets = function (object, runtime, topLevel, zip) { * @return {!Promise.>} Promise for the loaded targets when ready, or null for unsupported objects. */ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, assets) { - if (!object.hasOwnProperty('objName')) { - if (object.hasOwnProperty('listName')) { + if (!Object.prototype.hasOwnProperty.call(object, 'objName')) { + if (Object.prototype.hasOwnProperty.call(object, 'listName')) { // Shim these objects so they can be processed as monitors object.cmd = 'contentsOfList:'; object.param = object.listName; @@ -541,10 +585,10 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, // @todo: For now, load all Scratch objects (stage/sprites) as a Sprite. const sprite = new Sprite(blocks, runtime); // Sprite/stage name from JSON. - if (object.hasOwnProperty('objName')) { + if (Object.prototype.hasOwnProperty.call(object, 'objName')) { if (topLevel && object.objName !== 'Stage') { for (const child of object.children) { - if (!child.hasOwnProperty('objName') && child.target === object.objName) { + if (!Object.prototype.hasOwnProperty.call(child, 'objName') && child.target === object.objName) { child.target = 'Stage'; } } @@ -567,7 +611,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, const addBroadcastMsg = globalBroadcastMsgObj.broadcastMsgMapUpdater; // Load target properties from JSON. - if (object.hasOwnProperty('variables')) { + if (Object.prototype.hasOwnProperty.call(object, 'variables')) { for (let j = 0; j < object.variables.length; j++) { const variable = object.variables[j]; // A variable is a cloud variable if: @@ -591,7 +635,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, // If included, parse any and all comments on the object (this includes top-level // workspace comments as well as comments attached to specific blocks) const blockComments = {}; - if (object.hasOwnProperty('scriptComments')) { + if (Object.prototype.hasOwnProperty.call(object, 'scriptComments')) { const comments = object.scriptComments.map(commentDesc => { const [ commentX, @@ -626,7 +670,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, newComment.blockId = flattenedBlockIndex; // Add this comment to the block comments object with its script index // as the key - if (blockComments.hasOwnProperty(flattenedBlockIndex)) { + if (Object.prototype.hasOwnProperty.call(blockComments, flattenedBlockIndex)) { blockComments[flattenedBlockIndex].push(newComment); } else { blockComments[flattenedBlockIndex] = [newComment]; @@ -643,7 +687,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, } // If included, parse any and all scripts/blocks on the object. - if (object.hasOwnProperty('scripts')) { + if (Object.prototype.hasOwnProperty.call(object, 'scripts')) { parseScripts(object.scripts, blocks, addBroadcastMsg, getVariableId, extensions, blockComments); } @@ -666,7 +710,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, // Update stage specific blocks (e.g. sprite clicked <=> stage clicked) blocks.updateTargetSpecificBlocks(topLevel); // topLevel = isStage - if (object.hasOwnProperty('lists')) { + if (Object.prototype.hasOwnProperty.call(object, 'lists')) { for (let k = 0; k < object.lists.length; k++) { const list = object.lists[k]; const newVariable = new Variable( @@ -680,32 +724,34 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, target.variables[newVariable.id] = newVariable; } } - if (object.hasOwnProperty('scratchX')) { + if (Object.prototype.hasOwnProperty.call(object, 'scratchX')) { target.x = object.scratchX; } - if (object.hasOwnProperty('scratchY')) { + if (Object.prototype.hasOwnProperty.call(object, 'scratchY')) { target.y = object.scratchY; } - if (object.hasOwnProperty('direction')) { - target.direction = object.direction; + if (Object.prototype.hasOwnProperty.call(object, 'direction')) { + // Sometimes the direction can be outside of the range: LLK/scratch-gui#5806 + // wrapClamp it (like we do on RenderedTarget.setDirection) + target.direction = MathUtil.wrapClamp(object.direction, -179, 180); } - if (object.hasOwnProperty('isDraggable')) { + if (Object.prototype.hasOwnProperty.call(object, 'isDraggable')) { target.draggable = object.isDraggable; } - if (object.hasOwnProperty('scale')) { + if (Object.prototype.hasOwnProperty.call(object, 'scale')) { // SB2 stores as 1.0 = 100%; we use % in the VM. target.size = object.scale * 100; } - if (object.hasOwnProperty('visible')) { + if (Object.prototype.hasOwnProperty.call(object, 'visible')) { target.visible = object.visible; } - if (object.hasOwnProperty('currentCostumeIndex')) { + if (Object.prototype.hasOwnProperty.call(object, 'currentCostumeIndex')) { // Current costume index can sometimes be a floating // point number, use Math.floor to come up with an appropriate index // and clamp it to the actual number of costumes the object has for good measure. target.currentCostume = MathUtil.clamp(Math.floor(object.currentCostumeIndex), 0, object.costumes.length - 1); } - if (object.hasOwnProperty('rotationStyle')) { + if (Object.prototype.hasOwnProperty.call(object, 'rotationStyle')) { if (object.rotationStyle === 'none') { target.rotationStyle = RenderedTarget.ROTATION_STYLE_NONE; } else if (object.rotationStyle === 'leftRight') { @@ -714,16 +760,16 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, target.rotationStyle = RenderedTarget.ROTATION_STYLE_ALL_AROUND; } } - if (object.hasOwnProperty('tempoBPM')) { + if (Object.prototype.hasOwnProperty.call(object, 'tempoBPM')) { target.tempo = object.tempoBPM; } - if (object.hasOwnProperty('videoAlpha')) { + if (Object.prototype.hasOwnProperty.call(object, 'videoAlpha')) { // SB2 stores alpha as opacity, where 1.0 is opaque. // We convert to a percentage, and invert it so 100% is full transparency. target.videoTransparency = 100 - (100 * object.videoAlpha); } - if (object.hasOwnProperty('info')) { - if (object.info.hasOwnProperty('videoOn')) { + if (Object.prototype.hasOwnProperty.call(object, 'info')) { + if (Object.prototype.hasOwnProperty.call(object.info, 'videoOn')) { if (object.info.videoOn) { target.videoState = RenderedTarget.VIDEO_STATE.ON; } else { @@ -731,7 +777,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, } } } - if (object.hasOwnProperty('indexInLibrary')) { + if (Object.prototype.hasOwnProperty.call(object, 'indexInLibrary')) { // Temporarily store the 'indexInLibrary' property from the sb2 file // so that we can correctly order sprites in the target pane. // This will be deleted after we are done parsing and ordering the targets list. @@ -760,6 +806,18 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, } } + // Parse extension list from ScratchX projects. + if (topLevel) { + const savedExtensions = object.info && object.info.savedExtensions; + if (Array.isArray(savedExtensions)) { + for (const extension of savedExtensions) { + const id = ScratchXUtilities.generateExtensionId(extension.extensionName); + const url = extension.javascriptURL; + extensions.extensionURLs.set(id, url); + } + } + } + return Promise.all( costumePromises.concat(soundPromises) ).then(() => @@ -899,6 +957,9 @@ const specMapBlock = function (block) { const opcode = block[0]; const mapped = opcode && specMap[opcode]; if (!mapped) { + if (opcode && isPossiblyScratchXBlock(opcode)) { + return mapScratchXBlock(block); + } log.warn(`Couldn't find SB2 block: ${opcode}`); return null; } diff --git a/src/serialization/sb2_specmap.js b/src/serialization/sb2_specmap.js index 5f0e14f4..ef8fa01f 100644 --- a/src/serialization/sb2_specmap.js +++ b/src/serialization/sb2_specmap.js @@ -9,13 +9,13 @@ * Keep this up-to-date as 3.0 blocks are renamed, changed, etc. * Originally this was generated largely by a hand-guided scripting process. * The relevant data lives here: - * https://github.com/LLK/scratch-flash/blob/master/src/Specs.as + * https://github.com/scratchfoundation/scratch-flash/blob/master/src/Specs.as * (for the old opcode and argument order). * and here: - * https://github.com/LLK/scratch-blocks/tree/develop/blocks_vertical + * https://github.com/scratchfoundation/scratch-blocks/tree/develop/blocks_vertical * (for the new opcodes and argument names). * and here: - * https://github.com/LLK/scratch-blocks/blob/develop/tests/ + * https://github.com/scratchfoundation/scratch-blocks/blob/develop/tests/ * (for the shadow blocks created for each block). * I started with the `commands` array in Specs.as, and discarded irrelevant * properties. By hand, I matched the opcode name to the 3.0 opcode. diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index bac77707..6c5d835b 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -6,6 +6,7 @@ const vmPackage = require('../../package.json'); const Frames = require('../engine/frame'); +const Runtime = require('../engine/runtime'); const Blocks = require('../engine/blocks'); const Sprite = require('../sprites/sprite'); const Variable = require('../engine/variable'); @@ -17,7 +18,7 @@ const uid = require('../util/uid'); const MathUtil = require('../util/math-util'); const StringUtil = require('../util/string-util'); const VariableUtil = require('../util/variable-util'); -const optimize = require('./tw-optimize-sb3'); +const compress = require('./tw-compress-sb3'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); @@ -29,7 +30,7 @@ const hasOwnProperty = Object.prototype.hasOwnProperty; /** * @typedef {object} ImportedProject * @property {Array.} targets - the imported Scratch 3.0 target objects. - * @property {ImportedExtensionsInfo} extensionsInfo - the ID of each extension actually used by this project. + * @property {ImportedExtensionsInfo} extensions - the ID of each extension actually used by this project. */ /** @@ -99,6 +100,9 @@ const primitiveOpcodeInfoMap = { data_listcontents: [LIST_PRIMITIVE, 'LIST'] }; +// We don't enforce this limit, but Scratch does, so we need to handle it for compatibility. +const UPSTREAM_MAX_COMMENT_LENGTH = 8000; + /** * Serializes primitives described above into a more compact format * @param {object} block the block to serialize @@ -181,7 +185,7 @@ const serializeFields = function (fields) { for (const fieldName in fields) { if (!hasOwnProperty.call(fields, fieldName)) continue; obj[fieldName] = [fields[fieldName].value]; - if (fields[fieldName].hasOwnProperty('id')) { + if (Object.prototype.hasOwnProperty.call(fields[fieldName], 'id')) { obj[fieldName].push(fields[fieldName].id || null); } } @@ -326,6 +330,39 @@ const getExtensionIdForOpcode = function (opcode) { } }; +/** + * @param {Set|string[]} extensionIDs Project extension IDs + * @param {Runtime} runtime + * @returns {Record|null} extension ID -> URL map, or null if no custom extensions. + */ +const getExtensionURLsToSave = (extensionIDs, runtime) => { + // Extension manager only exists when runtime is wrapped by VirtualMachine + if (!runtime.extensionManager) { + return null; + } + + // We'll save the extensions in the format: + // { + // "extensionid": "https://...", + // "otherid": "https://..." + // } + // Which lets the VM know which URLs correspond to which IDs, which is useful when the project + // is being loaded. For example, if the extension is eventually converted to a builtin extension + // or if it is already loaded, then it doesn't need to fetch the script again. + const extensionURLs = runtime.extensionManager.getExtensionURLs(); + const toSave = {}; + for (const extension of extensionIDs) { + const url = extensionURLs[extension]; + if (typeof url === 'string') { + toSave[extension] = url; + } + } + if (Object.keys(toSave).length === 0) { + return null; + } + return toSave; +}; + /** * Serialize the given blocks object (representing all the blocks for the target * currently being serialized.) @@ -339,7 +376,7 @@ const serializeBlocks = function (blocks, saveVarId) { const obj = Object.create(null); const extensionIDs = new Set(); for (const blockID in blocks) { - if (!blocks.hasOwnProperty(blockID)) continue; + if (!Object.prototype.hasOwnProperty.call(blocks, blockID)) continue; obj[blockID] = serializeBlock(blocks[blockID], saveVarId); const extensionID = getExtensionIdForOpcode(blocks[blockID].opcode); if (extensionID) { @@ -375,6 +412,58 @@ const serializeBlocks = function (blocks, saveVarId) { return [obj, Array.from(extensionIDs)]; }; +/** + * @param {unknown} blocks Output of serializeStandaloneBlocks + * @returns {{blocks: Block[], extensionURLs: Map}} + */ +const deserializeStandaloneBlocks = blocks => { + // deep clone to ensure it's safe to modify later + blocks = JSON.parse(JSON.stringify(blocks)); + + if (blocks.extensionURLs) { + const extensionURLs = new Map(); + for (const [id, url] of Object.entries(blocks.extensionURLs)) { + extensionURLs.set(id, url); + } + return { + blocks: blocks.blocks, + extensionURLs + }; + } + + // Vanilla Scratch format is just a list of block objects + return { + blocks, + extensionURLs: new Map() + }; +}; + +/** + * @param {Block[]} blocks List of block objects. + * @param {Runtime} runtime Runtime + * @returns {object} Something that can be understood by deserializeStandaloneBlocks + */ +const serializeStandaloneBlocks = (blocks, runtime) => { + const extensionIDs = new Set(); + for (const block of blocks) { + const extensionID = getExtensionIdForOpcode(block.opcode); + if (extensionID) { + extensionIDs.add(extensionID); + } + } + const extensionURLs = getExtensionURLsToSave(extensionIDs, runtime); + if (extensionURLs) { + return { + blocks, + // same format as project.json + extensionURLs: extensionURLs + }; + } + // Vanilla Scratch always just uses the block array as-is. To reduce compatibility concerns + // we too will use that when possible. + return blocks; +}; + /** * Serialize the given costume. * @param {object} costume The costume to be serialized. @@ -385,16 +474,24 @@ const serializeCostume = function (costume) { obj.id = costume.id; obj.assetId = costume.assetId; obj.name = costume.name; - obj.bitmapResolution = costume.bitmapResolution; + + const costumeToSerialize = costume.broken || costume; + + obj.bitmapResolution = costumeToSerialize.bitmapResolution; + obj.dataFormat = costumeToSerialize.dataFormat.toLowerCase(); + + obj.assetId = costumeToSerialize.assetId; + // serialize this property with the name 'md5ext' because that's // what it's actually referring to. TODO runtime objects need to be // updated to actually refer to this as 'md5ext' instead of 'md5' // but that change should be made carefully since it is very // pervasive - obj.md5ext = costume.md5; - obj.dataFormat = costume.dataFormat.toLowerCase(); - obj.rotationCenterX = costume.rotationCenterX; - obj.rotationCenterY = costume.rotationCenterY; + obj.md5ext = costumeToSerialize.md5; + + obj.rotationCenterX = costumeToSerialize.rotationCenterX; + obj.rotationCenterY = costumeToSerialize.rotationCenterY; + return obj; }; @@ -406,21 +503,56 @@ const serializeCostume = function (costume) { const serializeSound = function (sound) { const obj = Object.create(null); obj.id = sound.id; - obj.assetId = sound.assetId; obj.name = sound.name; - obj.dataFormat = sound.dataFormat.toLowerCase(); - obj.format = sound.format; - obj.rate = sound.rate; - obj.sampleCount = sound.sampleCount; + + const soundToSerialize = sound.broken || sound; + + obj.assetId = soundToSerialize.assetId; + obj.dataFormat = soundToSerialize.dataFormat.toLowerCase(); + obj.format = soundToSerialize.format; + obj.rate = soundToSerialize.rate; + obj.sampleCount = soundToSerialize.sampleCount; // serialize this property with the name 'md5ext' because that's // what it's actually referring to. TODO runtime objects need to be // updated to actually refer to this as 'md5ext' instead of 'md5' // but that change should be made carefully since it is very // pervasive - obj.md5ext = sound.md5; + obj.md5ext = soundToSerialize.md5; return obj; }; +// Using some bugs, it can be possible to get values like undefined, null, or complex objects into +// variables or lists. This will cause make the project unusable after exporting without JSON editing +// as it will fail validation in scratch-parser. +// To avoid this, we'll convert those objects to strings before saving them. +const isVariableValueSafeForJSON = value => ( + typeof value === 'number' || + typeof value === 'string' || + typeof value === 'boolean' +); +const makeSafeForJSON = value => { + if (Array.isArray(value)) { + let copy = null; + for (let i = 0; i < value.length; i++) { + if (!isVariableValueSafeForJSON(value[i])) { + if (!copy) { + // Only copy the list when needed + copy = value.slice(); + } + copy[i] = `${copy[i]}`; + } + } + if (copy) { + return copy; + } + return value; + } + if (isVariableValueSafeForJSON(value)) { + return value; + } + return `${value}`; +}; + /** * Serialize the given variables object. * @param {object} variables The variables to be serialized. @@ -443,18 +575,13 @@ const serializeVariables = function (variables) { continue; } if (v.type === Variable.LIST_TYPE) { - // CCW: validate variable value not null - // make sure no null in json - const nonNullvalue = v.value.map(item => item ?? ''); - obj.lists[varId] = [v.name, nonNullvalue]; - // powered by xigua start + obj.lists[varId] = [v.name, makeSafeForJSON(v.value)]; if (v.isCloud) obj.lists[varId].push(true); - // powered by xigua end continue; } // otherwise should be a scalar type - obj.variables[varId] = [v.name, v.value ?? '']; // CCW: make sure no null in json + obj.variables[varId] = [v.name, makeSafeForJSON(v.value)]; // only scalar vars have the potential to be cloud vars if (v.isCloud) obj.variables[varId].push(true); } @@ -464,7 +591,7 @@ const serializeVariables = function (variables) { const serializeComments = function (comments) { const obj = Object.create(null); for (const commentId in comments) { - if (!comments.hasOwnProperty(commentId)) continue; + if (!Object.prototype.hasOwnProperty.call(comments, commentId)) continue; const comment = comments[commentId]; const serializedComment = Object.create(null); @@ -474,7 +601,16 @@ const serializeComments = function (comments) { serializedComment.width = comment.width; serializedComment.height = comment.height; serializedComment.minimized = comment.minimized; - serializedComment.text = comment.text; + + if (comment.text.length > UPSTREAM_MAX_COMMENT_LENGTH) { + // Upstream's scratch-parser will refuse to load projects if the text is too long, so to maximize + // compatibility and minimize redundancy we'll store a truncated version in .text and the rest in + // another field + serializedComment.text = comment.text.substring(0, UPSTREAM_MAX_COMMENT_LENGTH); + serializedComment.extraText = comment.text.substring(UPSTREAM_MAX_COMMENT_LENGTH); + } else { + serializedComment.text = comment.text; + } obj[commentId] = serializedComment; } @@ -518,14 +654,22 @@ const serializeTarget = function (target, extensions, saveVarId) { obj.currentCostume = target.currentCostume; obj.costumes = target.costumes.filter(item => !item.isRuntimeAsyncLoad).map(serializeCostume); obj.sounds = target.sounds.filter(item => !item.isRuntimeAsyncLoad).map(serializeSound); - if (target.hasOwnProperty('volume')) obj.volume = target.volume; - if (target.hasOwnProperty('layerOrder')) obj.layerOrder = target.layerOrder; - if (target.extractProperties) obj.extractProperties = target.extractProperties; + if (Object.prototype.hasOwnProperty.call(target, 'volume')) obj.volume = target.volume; + if (Object.prototype.hasOwnProperty.call(target, 'layerOrder')) obj.layerOrder = target.layerOrder; + if (Object.prototype.hasOwnProperty.call(target, 'extractProperties')) obj.extractProperties = target.extractProperties; if (obj.isStage) { // Only the stage should have these properties - if (target.hasOwnProperty('tempo')) obj.tempo = target.tempo; - if (target.hasOwnProperty('videoTransparency')) obj.videoTransparency = target.videoTransparency; - if (target.hasOwnProperty('videoState')) obj.videoState = target.videoState; - if (target.hasOwnProperty('textToSpeechLanguage')) obj.textToSpeechLanguage = target.textToSpeechLanguage; + if (Object.prototype.hasOwnProperty.call(target, 'tempo')) { + obj.tempo = target.tempo; + } + if (Object.prototype.hasOwnProperty.call(target, 'videoTransparency')) { + obj.videoTransparency = target.videoTransparency; + } + if (Object.prototype.hasOwnProperty.call(target, 'videoState')) { + obj.videoState = target.videoState; + } + if (Object.prototype.hasOwnProperty.call(target, 'textToSpeechLanguage')) { + obj.textToSpeechLanguage = target.textToSpeechLanguage; + } } else { // The stage does not need the following properties, but sprites should obj.visible = target.visible; obj.x = target.x; @@ -543,36 +687,74 @@ const serializeTarget = function (target, extensions, saveVarId) { return obj; }; +/** + * @param {Record} extensionStorage extensionStorage object + * @param {Set} extensions extension IDs + * @returns {Record|null} + */ +const serializeExtensionStorage = (extensionStorage, extensions) => { + const result = {}; + let isEmpty = true; + for (const [key, value] of Object.entries(extensionStorage)) { + if (extensions.has(key) && value !== null && typeof value !== 'undefined') { + isEmpty = false; + result[key] = extensionStorage[key]; + } + } + if (isEmpty) { + return null; + } + return result; +}; + const getSimplifiedLayerOrdering = function (targets) { const layerOrders = targets.map(t => t.getLayerOrder()); return MathUtil.reducedSortOrdering(layerOrders); }; -const serializeMonitors = function (monitors, runtime) { +const serializeMonitors = function (monitors, runtime, extensions) { // Monitors position is always stored as position from top-left corner in 480x360 stage. const xOffset = (runtime.stageWidth - 480) / 2; const yOffset = (runtime.stageHeight - 360) / 2; - return monitors.valueSeq().map(monitorData => { - const serializedMonitor = { - id: monitorData.id, - mode: monitorData.mode, - opcode: monitorData.opcode, - params: monitorData.params, - spriteName: monitorData.spriteName, - value: Array.isArray(monitorData.value) ? [] : 0, - width: monitorData.width, - height: monitorData.height, - x: monitorData.x - xOffset, - y: monitorData.y - yOffset, - visible: monitorData.visible - }; - if (monitorData.mode !== 'list') { - serializedMonitor.sliderMin = monitorData.sliderMin; - serializedMonitor.sliderMax = monitorData.sliderMax; - serializedMonitor.isDiscrete = monitorData.isDiscrete; - } - return serializedMonitor; - }); + return monitors.valueSeq() + // Don't include hidden monitors from extensions + // https://github.com/LLK/scratch-vm/issues/2331 + .filter(monitorData => { + const extensionID = getExtensionIdForOpcode(monitorData.opcode); + if (!extensionID) { + // Native block, always safe + return true; + } + if (monitorData.visible) { + extensions.add(extensionID); + return true; + } + return false; + }) + .map(monitorData => { + const serializedMonitor = { + id: monitorData.id, + mode: monitorData.mode, + opcode: monitorData.opcode, + params: monitorData.params, + spriteName: monitorData.spriteName, + value: Array.isArray(monitorData.value) ? [] : 0, + width: monitorData.width, + height: monitorData.height, + x: monitorData.x - xOffset, + y: monitorData.y - yOffset, + visible: monitorData.visible + }; + if (monitorData.mode !== 'list') { + serializedMonitor.sliderMin = monitorData.sliderMin; + serializedMonitor.sliderMax = monitorData.sliderMax; + serializedMonitor.isDiscrete = monitorData.isDiscrete; + } + return serializedMonitor; + }) + // By default the sequence is lazily evaluated, but we want it to be evaluated right + // now to update the used extension list. + .toArray(); }; /** @@ -607,41 +789,65 @@ const serialize = function (runtime, targetId, {allowOptimization = false, saveV }); } - const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions, saveVarId)); - + const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, extensions, saveVarId)) + .map((serialized, index) => { + // can't serialize extensionStorage until the list of used extensions is fully known + const target = originalTargetsToSerialize[index]; + const targetExtensionStorage = serializeExtensionStorage(target.extensionStorage, extensions); + if (targetExtensionStorage) { + serialized.extensionStorage = targetExtensionStorage; + } + return serialized; + }); + const fonts = runtime.fontManager.serializeJSON(); if (targetId) { const target = serializedTargets[0]; const gandi = runtime.gandi.serialize(extensions); - if (target && gandi) { + if (extensions.size) { + // Vanilla Scratch doesn't include extensions in sprites, so don't add this if it's not needed + // target.extensions = Array.from(extensions); + } + const extensionURLs = getExtensionURLsToSave(extensions, runtime); + if (extensionURLs) { + // target.extensionURLs = extensionURLs; + } + if (fonts) { + // target.customFonts = fonts; + } + if (gandi) { target.gandi = gandi; } return target; } - obj.targets = serializedTargets; + const globalExtensionStorage = serializeExtensionStorage(runtime.extensionStorage, extensions); + if (globalExtensionStorage) { + obj.extensionStorage = globalExtensionStorage; + } - // serializeMonitors also records extensions - obj.monitors = serializeMonitors(runtime.getMonitorState(), runtime); - obj.monitors.forEach(monitor => { - // If the monitor block is from an extension, record it. - const extensionID = getExtensionIdForOpcode(monitor.opcode); - if (extensionID) { - extensions.add(extensionID); - } - }); + obj.targets = serializedTargets; + obj.monitors = serializeMonitors(runtime.getMonitorState(), runtime, extensions); const gandi = runtime.gandi.serialize(extensions); if (gandi) { obj.gandi = gandi; } - // Assemble extension list obj.extensions = Array.from(extensions); + const extensionURLs = getExtensionURLsToSave(extensions, runtime); + if (extensionURLs) { + obj.extensionURLs = extensionURLs; + } + + if (fonts) { + obj.customFonts = fonts; + } // Assemble metadata const meta = Object.create(null); meta.semver = '3.0.0'; - meta.vm = vmPackage.version; + // TW: There isn't a good reason to put the full version number in the json, so we don't. + meta.vm = '0.2.0'; if (runtime.origin) { meta.origin = runtime.origin; } @@ -651,11 +857,14 @@ const serialize = function (runtime, targetId, {allowOptimization = false, saveV // TW: Never include full user agent to slightly improve user privacy // if (typeof navigator !== 'undefined') meta.agent = navigator.userAgent; + // TW: Attach copy of platform information + meta.platform = Object.assign({}, runtime.platform); + // Assemble payload and return obj.meta = meta; if (allowOptimization) { - optimize(obj); + compress(obj); } return obj; }; @@ -923,6 +1132,18 @@ const deserializeBlocks = function (blocks) { continue; } block.id = blockId; // add id back to block since it wasn't serialized + + // - compatible with tw procedures_return + // testing purposes for now + // TODO: compatibility with whole tw return procedures + if (block.opcode === 'procedures_return' && Object.hasOwnProperty.call(block.inputs, 'VALUE')) { + block.inputs['RETURN'] = block.inputs['VALUE']; + delete block.inputs['VALUE']; + } + if (block.opcode === 'procedures_call' && block.mutation.return) { + block.opcode = 'procedures_call_with_return'; + } + // - END compatible with tw procedures_return block.inputs = deserializeInputs(block.inputs, blockId, blocks); block.fields = deserializeFields(block.fields); } @@ -942,7 +1163,7 @@ const deserializeBlocks = function (blocks) { * SoundBank for the sound assets. null for unsupported objects. */ const parseScratchAssets = function (object, runtime, zip) { - if (!object.hasOwnProperty('name')) { + if (!Object.prototype.hasOwnProperty.call(object, 'name')) { // Watcher/monitor - skip this object until those are implemented in VM. // @todo return Promise.resolve(null); @@ -973,7 +1194,7 @@ const parseScratchAssets = function (object, runtime, zip) { costumeSource.dataFormat || (costumeSource.assetType && costumeSource.assetType.runtimeFormat) || // older format 'png'; // if all else fails, guess that it might be a PNG - const costumeMd5Ext = costumeSource.hasOwnProperty('md5ext') ? + const costumeMd5Ext = Object.prototype.hasOwnProperty.call(costumeSource, 'md5ext') ? costumeSource.md5ext : `${costumeSource.assetId}.${dataFormat}`; costume.md5 = costumeMd5Ext; costume.dataFormat = dataFormat; @@ -982,8 +1203,8 @@ const parseScratchAssets = function (object, runtime, zip) { // we're always loading the 'sb3' representation of the costume // any translation that needs to happen will happen in the process // of building up the costume object into an sb3 format - return deserializeCostume(costume, runtime, zip) - .then(() => loadCostume(costumeMd5Ext, costume, runtime)); + return runtime.wrapAssetRequest(() => deserializeCostume(costume, runtime, zip) + .then(() => loadCostume(costumeMd5Ext, costume, runtime))); // Only attempt to load the costume after the deserialization // process has been completed }); @@ -1008,8 +1229,8 @@ const parseScratchAssets = function (object, runtime, zip) { // we're always loading the 'sb3' representation of the costume // any translation that needs to happen will happen in the process // of building up the costume object into an sb3 format - return deserializeSound(sound, runtime, zip) - .then(() => loadSound(sound, runtime, assets.soundBank)); + return runtime.wrapAssetRequest(() => deserializeSound(sound, runtime, zip) + .then(() => loadSound(sound, runtime, assets.soundBank))); // Only attempt to load the sound after the deserialization // process has been completed. }); @@ -1045,7 +1266,7 @@ const parseGandiAssets = function (object, runtime) { * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. */ const parseScratchObject = function (object, runtime, extensions, zip, assets) { - if (!object.hasOwnProperty('name')) { + if (!Object.prototype.hasOwnProperty.call(object, 'name')) { // Watcher/monitor - skip this object until those are implemented in VM. // @todo return Promise.resolve(null); @@ -1059,19 +1280,18 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { const sprite = new Sprite(blocks, runtime, frames); // Sprite/stage name from JSON. - if (object.hasOwnProperty('name')) { + if (Object.prototype.hasOwnProperty.call(object, 'name')) { sprite.name = object.name; } - - if (object.hasOwnProperty('id')) { + if (Object.prototype.hasOwnProperty.call(object, 'id')) { sprite.id = object.id; } - if (object.hasOwnProperty('blocks')) { + if (Object.prototype.hasOwnProperty.call(object, 'blocks')) { deserializeBlocks(object.blocks); // Take a second pass to create objects and add extensions for (const blockId in object.blocks) { - if (!object.blocks.hasOwnProperty(blockId)) continue; + if (!Object.prototype.hasOwnProperty.call(object.blocks, blockId)) continue; const blockJSON = object.blocks[blockId]; blocks.createBlock(blockJSON); @@ -1095,28 +1315,28 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { delete sprite.id; } // Load target properties from JSON. - if (object.hasOwnProperty('tempo')) { + if (Object.prototype.hasOwnProperty.call(object, 'tempo')) { target.tempo = object.tempo; } - if (object.hasOwnProperty('volume')) { + if (Object.prototype.hasOwnProperty.call(object, 'volume')) { target.volume = object.volume; } - if (object.hasOwnProperty('videoTransparency')) { + if (Object.prototype.hasOwnProperty.call(object, 'videoTransparency')) { target.videoTransparency = object.videoTransparency; } - if (object.hasOwnProperty('videoState')) { + if (Object.prototype.hasOwnProperty.call(object, 'videoState')) { target.videoState = object.videoState; } - if (object.hasOwnProperty('textToSpeechLanguage')) { + if (Object.prototype.hasOwnProperty.call(object, 'textToSpeechLanguage')) { target.textToSpeechLanguage = object.textToSpeechLanguage; } - if (object.hasOwnProperty('frames')) { + if (Object.prototype.hasOwnProperty.call(object, 'frames')) { for (const frameId in object.frames) { const frameJSON = object.frames[frameId]; target.createFrame(frameJSON); } } - if (object.hasOwnProperty('variables')) { + if (Object.prototype.hasOwnProperty.call(object, 'variables')) { for (const varId in object.variables) { const variable = object.variables[varId]; // A variable is a cloud variable if: @@ -1137,7 +1357,7 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { target.variables[newVariable.id] = newVariable; } } - if (object.hasOwnProperty('lists')) { + if (Object.prototype.hasOwnProperty.call(object, 'lists')) { for (const listId in object.lists) { const list = object.lists[listId]; // powered by xigua start @@ -1158,7 +1378,7 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { target.variables[newList.id] = newList; } } - if (object.hasOwnProperty('broadcasts')) { + if (Object.prototype.hasOwnProperty.call(object, 'broadcasts')) { for (const broadcastId in object.broadcasts) { const broadcast = object.broadcasts[broadcastId]; const newBroadcast = new Variable( @@ -1173,15 +1393,16 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { target.variables[newBroadcast.id] = newBroadcast; } } - if (object.hasOwnProperty('extractProperties')) { + if (Object.prototype.hasOwnProperty.call(object, 'extractProperties')) { target.extractProperties = object.extractProperties; } - if (object.hasOwnProperty('comments')) { + if (Object.prototype.hasOwnProperty.call(object, 'comments')) { for (const commentId in object.comments) { const comment = object.comments[commentId]; const newComment = new Comment( commentId, - comment.text, + // text has a length limit, so anything extra got saved in extraText + comment.text + (typeof comment.extraText === 'string' ? comment.extraText : ''), comment.x, comment.y, comment.width, @@ -1197,39 +1418,44 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { } } } - if (object.hasOwnProperty('x')) { + if (Object.prototype.hasOwnProperty.call(object, 'x')) { target.x = object.x; } - if (object.hasOwnProperty('y')) { + if (Object.prototype.hasOwnProperty.call(object, 'y')) { target.y = object.y; } - if (object.hasOwnProperty('direction')) { - target.direction = object.direction; + if (Object.prototype.hasOwnProperty.call(object, 'direction')) { + // Sometimes the direction can be outside of the range: LLK/scratch-gui#5806 + // wrapClamp it (like we do on RenderedTarget.setDirection) + target.direction = MathUtil.wrapClamp(object.direction, -179, 180); } - if (object.hasOwnProperty('size')) { + if (Object.prototype.hasOwnProperty.call(object, 'size')) { target.size = object.size; } - if (object.hasOwnProperty('visible')) { + if (Object.prototype.hasOwnProperty.call(object, 'visible')) { target.visible = object.visible; } - if (object.hasOwnProperty('currentCostume')) { + if (Object.prototype.hasOwnProperty.call(object, 'currentCostume')) { target.currentCostume = MathUtil.clamp(object.currentCostume, 0, object.costumes.length - 1); } - if (object.hasOwnProperty('rotationStyle')) { + if (Object.prototype.hasOwnProperty.call(object, 'rotationStyle')) { target.rotationStyle = object.rotationStyle; } - if (object.hasOwnProperty('isStage')) { + if (Object.prototype.hasOwnProperty.call(object, 'isStage')) { target.isStage = object.isStage; } - if (object.hasOwnProperty('targetPaneOrder')) { + if (Object.prototype.hasOwnProperty.call(object, 'targetPaneOrder')) { // Temporarily store the 'targetPaneOrder' property // so that we can correctly order sprites in the target pane. // This will be deleted after we are done parsing and ordering the targets list. target.targetPaneOrder = object.targetPaneOrder; } - if (object.hasOwnProperty('draggable')) { + if (Object.prototype.hasOwnProperty.call(object, 'draggable')) { target.draggable = object.draggable; } + if (Object.prototype.hasOwnProperty.call(object, 'extensionStorage')) { + target.extensionStorage = object.extensionStorage; + } Promise.all(costumePromises).then(costumes => { sprite.costumes = costumes; }); @@ -1267,7 +1493,8 @@ const deserializeMonitor = function (monitorData, runtime, targets, extensions) const target = monitorData.targetId ? targets.find(t => t.id === monitorData.targetId) : targets.find(t => t.isStage); - if (!target.variables[monitorData.id]) { + const saveId = StringUtil.replaceUnsafeChars(monitorData.id); + if (!Object.hasOwnProperty.call(target.variables, saveId)) { log.warn(`Tried to deserialize sprite specific monitor ${monitorData.opcode} but could not find variable ${monitorData.id}.`); return; } @@ -1277,7 +1504,7 @@ const deserializeMonitor = function (monitorData, runtime, targets, extensions) // This will be undefined for extension blocks const monitorBlockInfo = runtime.monitorBlockInfo[monitorData.opcode]; - // Due to a bug (see https://github.com/LLK/scratch-vm/pull/2322), renamed list monitors may have been serialized + // Due to a bug (see https://github.com/scratchfoundation/scratch-vm/pull/2322), renamed list monitors may have been serialized // with an outdated/incorrect LIST parameter. Fix it up to use the current name of the actual corresponding list. if (monitorData.opcode === 'data_listcontents') { const listTarget = monitorData.targetId ? @@ -1396,6 +1623,36 @@ const replaceUnsafeCharsInVariableIds = function (targets) { return targets; }; +/** + * @param {object} json + * @param {Runtime} runtime + * @returns {void|Promise} Resolves when the user has acknowledged any compatibilities, if any exist. + */ +const checkPlatformCompatibility = (json, runtime) => { + if (!json.meta || !json.meta.platform) { + return; + } + + const projectPlatform = json.meta.platform.name; + if (projectPlatform === runtime.platform.name) { + return; + } + + let pending = runtime.listenerCount(Runtime.PLATFORM_MISMATCH); + if (pending === 0) { + return; + } + + return new Promise(resolve => { + runtime.emit(Runtime.PLATFORM_MISMATCH, json.meta.platform, () => { + pending--; + if (pending === 0) { + resolve(); + } + }); + }); +}; + /** * Parses the gandi object and updates the runtime, assets, and extensions accordingly. * @param {Object} object - The gandi object to parse. @@ -1414,7 +1671,7 @@ const parseGandiObject = (object, runtime, gandiAssetsPromises, extensions) => { if (Array.isArray(object.assets)) { // find extension need to load object.assets.forEach(asset => { - if (asset.dataFormat === 'py' || asset.dataFormat === 'json') { + if (asset.dataFormat === 'py') { // py and json file need GandiPython extension to run extensions.extensionIDs.add('GandiPython'); } @@ -1441,6 +1698,8 @@ const parseGandiObject = (object, runtime, gandiAssetsPromises, extensions) => { * @returns {Promise.} Promise that resolves to the list of targets after the project is deserialized */ const deserialize = async function (json, runtime, zip, isSingleSprite) { + await checkPlatformCompatibility(json, runtime); + const extensions = { extensionIDs: new Set(), extensionURLs: new Map() @@ -1448,11 +1707,28 @@ const deserialize = async function (json, runtime, zip, isSingleSprite) { // Store the origin field (e.g. project originated at CSFirst) so that we can save it again. if (json.meta && json.meta.origin) { + // eslint-disable-next-line require-atomic-updates runtime.origin = json.meta.origin; } else { + // eslint-disable-next-line require-atomic-updates runtime.origin = null; } + // Extract custom extension IDs, if they exist. + if (json.extensionURLs) { + for (const [id, url] of Object.entries(json.extensionURLs)) { + extensions.extensionURLs.set(id, url); + } + } + + // Extract any custom fonts before loading costumes. + // let fontPromise; + // if (json.customFonts) { + // fontPromise = runtime.fontManager.deserialize(json.customFonts, zip, isSingleSprite); + // } else { + // fontPromise = Promise.resolve(); + // } + // First keep track of the current target order in the json, // then sort by the layer order property before parsing the targets // so that their corresponding render drawables can be created in @@ -1500,6 +1776,9 @@ const deserialize = async function (json, runtime, zip, isSingleSprite) { .then(targets => replaceUnsafeCharsInVariableIds(targets)) .then(targets => { monitorObjects.map(monitorDesc => deserializeMonitor(monitorDesc, runtime, targets, extensions)); + if (Object.prototype.hasOwnProperty.call(json, 'extensionStorage')) { + runtime.extensionStorage = json.extensionStorage; + } return targets; }) .then(targets => parseGandiObject(gandiObjects, runtime, gandiAssetsPromises, extensions).then(gandi => ({targets, gandi}))) @@ -1527,5 +1806,8 @@ module.exports = { getExtensionIdForOpcode: getExtensionIdForOpcode, deserializeInputDesc: deserializeInputDesc, serializePrimitiveBlock: serializePrimitiveBlock, - primitiveOpcodeInfoMap: primitiveOpcodeInfoMap + primitiveOpcodeInfoMap: primitiveOpcodeInfoMap, + deserializeStandaloneBlocks: deserializeStandaloneBlocks, + serializeStandaloneBlocks: serializeStandaloneBlocks, + getExtensionIdForOpcode: getExtensionIdForOpcode }; diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index 55d0e20c..02ef411b 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -30,11 +30,13 @@ const serializeAssets = function (runtime, assetType, optTargetId) { for (let j = 0; j < currAssets.length; j++) { const currAsset = currAssets[j]; if (currAsset.isRuntimeAsyncLoad) continue; - const asset = currAsset.asset; + const asset = currAsset.broken ? currAsset.broken.asset : currAsset.asset; if (asset) { + // Serialize asset if it exists, otherwise skip assetDescs.push({ fileName: `${asset.assetId}.${asset.dataFormat}`, - fileContent: asset.data}); + fileContent: asset.data + }); } } } diff --git a/src/serialization/tw-optimize-sb3.js b/src/serialization/tw-compress-sb3.js similarity index 54% rename from src/serialization/tw-optimize-sb3.js rename to src/serialization/tw-compress-sb3.js index 5cf15eba..22dfedfd 100644 --- a/src/serialization/tw-optimize-sb3.js +++ b/src/serialization/tw-compress-sb3.js @@ -1,24 +1,8 @@ -/** - * Copyright (C) 2021 Thomas Weber - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License version 3 - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ - -const SOUP = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#%()*+,-./:;=?@[]^_`{|}~'; +// We don't generate new IDs using numbers at this time because their enumeration +// order can affect script execution order as they always come first. +// https://tc39.es/ecma262/#sec-ordinaryownpropertykeys +const SOUP = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#%()*+,-./:;=?@[]^_`{|}~'; const generateId = i => { - // IDs in Object.keys(vm.runtime.monitorBlocks._blocks) already have meaning, so make sure to skip those - // We don't bother listing many here because most would take more than ten million items to be used - if (i > 1309) i++; // of let str = ''; while (i >= 0) { str = SOUP[i % SOUP.length] + str; @@ -31,6 +15,13 @@ class Pool { constructor () { this.generatedIds = new Map(); this.references = new Map(); + this.skippedIds = new Set(); + // IDs in Object.keys(vm.runtime.monitorBlocks._blocks) already have meaning, so make sure to skip those + // We don't bother listing many here because most would take more than ten million items to be used + this.skippedIds.add('of'); + } + skip (id) { + this.skippedIds.add(id); } addReference (id) { const currentCount = this.references.get(id) || 0; @@ -40,8 +31,19 @@ class Pool { const entries = Array.from(this.references.entries()); // The most used original IDs should get the shortest new IDs. entries.sort((a, b) => b[1] - a[1]); - for (let i = 0; i < entries.length; i++) { - this.generatedIds.set(entries[i][0], generateId(i)); + + let i = 0; + for (const entry of entries) { + const oldId = entry[0]; + + let newId = generateId(i); + while (this.skippedIds.has(newId)) { + i++; + newId = generateId(i); + } + + this.generatedIds.set(oldId, newId); + i++; } } getNewId (originalId) { @@ -52,22 +54,36 @@ class Pool { } } -const optimize = projectData => { +const compress = projectData => { // projectData is modified in-place - // The optimization here is not optimal. This is intentional because we want to be truly lossless and maintain - // all editor functionality and compatibility with third-party tools. + // The optimization here is not optimal. This is intentional. + // We only compress block and comment IDs because we want to maintain 100% (not 99.99%; 100%) compatibility and be + // truly lossless. Optimizing things like variable IDs will cause things such as the editor's backpack feature + // to misbehave. - // Optimization happens in two "passes", one to find all IDs and sort them so that we can generate the most - // optimized new IDs, then one more pass to actually apply those new IDs. + // We use the same variable pool for all objects to avoid any possible issues if IDs are ever treated as unique + // within a given project. const pool = new Pool(); for (const target of projectData.targets) { + // While we don't compress these IDs, we need to make sure that our compressed IDs + // do not intersect, which could happen if the project was compressed with a + // different tool. + for (const variableId of Object.keys(target.variables)) { + pool.skip(variableId); + } + for (const listId of Object.keys(target.lists)) { + pool.skip(listId); + } + for (const broadcastId of Object.keys(target.broadcasts)) { + pool.skip(broadcastId); + } for (const blockId of Object.keys(target.blocks)) { const block = target.blocks[blockId]; pool.addReference(blockId); if (Array.isArray(block)) { - // Compressed primitive + // Compressed native continue; } if (block.parent) { @@ -79,15 +95,16 @@ const optimize = projectData => { if (block.comment) { pool.addReference(block.comment); } - for (const inputName of Object.keys(block.inputs)) { - const input = block.inputs[inputName]; - const inputValue = input[1]; - if (typeof inputValue === 'string') { - const childBlockId = input[1]; - pool.addReference(childBlockId); + for (const input of Object.values(block.inputs)) { + for (let i = 1; i < input.length; i++) { + const inputValue = input[i]; + if (typeof inputValue === 'string') { + pool.addReference(inputValue); + } } } } + for (const commentId of Object.keys(target.comments)) { const comment = target.comments[commentId]; pool.addReference(commentId); @@ -105,6 +122,7 @@ const optimize = projectData => { const block = target.blocks[blockId]; newBlocks[pool.getNewId(blockId)] = block; if (Array.isArray(block)) { + // Compressed native continue; } if (block.parent) { @@ -116,15 +134,16 @@ const optimize = projectData => { if (block.comment) { block.comment = pool.getNewId(block.comment); } - for (const inputName of Object.keys(block.inputs)) { - const input = block.inputs[inputName]; - const inputValue = input[1]; - if (typeof inputValue === 'string') { - const childBlockId = input[1]; - input[1] = pool.getNewId(childBlockId); + for (const input of Object.values(block.inputs)) { + for (let i = 1; i < input.length; i++) { + const inputValue = input[i]; + if (typeof inputValue === 'string') { + input[i] = pool.getNewId(inputValue); + } } } } + for (const commentId of Object.keys(target.comments)) { const comment = target.comments[commentId]; newComments[pool.getNewId(commentId)] = comment; @@ -132,9 +151,10 @@ const optimize = projectData => { comment.blockId = pool.getNewId(comment.blockId); } } + target.blocks = newBlocks; target.comments = newComments; } }; -module.exports = optimize; +module.exports = compress; diff --git a/src/serialization/tw-costume-import-export.js b/src/serialization/tw-costume-import-export.js new file mode 100644 index 00000000..5725510d --- /dev/null +++ b/src/serialization/tw-costume-import-export.js @@ -0,0 +1,78 @@ +// We want to preserve the rotation center of exported SVGs when they are later imported. +// Unfortunately, the SVG itself does not have sufficient information to accomplish this. +// Instead we must add a small amount of extra information to the end of exported SVGs +// that can be read on import. + +// Adding this comment in scratch-paint is not a viable approach because the user can +// open projects not made with TurboWarp and we want costumes exported from there to +// have their center saved even if they haven't been edited. + +let _TextEncoder; +let _TextDecoder; +if (typeof TextEncoder === 'undefined') { + _TextEncoder = require('text-encoding').TextEncoder; + _TextDecoder = require('text-encoding').TextDecoder; +} else { + _TextEncoder = TextEncoder; + _TextDecoder = TextDecoder; +} + +// Using literal HTML comments tokens will cause this script to be very hard to inline in +// a