diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..6befc9a --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,242 @@ +version: 2.1 +workflows: + macos: + jobs: + - build_for_macos: + context: scratch-desktop-and-link + windows: + jobs: + - build_for_windows: + context: scratch-desktop-and-link +orbs: + windows: circleci/windows@2.4.0 +aliases: + # condition to indicate whether or not we should sign this build + - &should_sign + or: + - equal: [ develop, << pipeline.git.branch >> ] + - equal: [ main, << pipeline.git.branch >> ] + - equal: [ master, << pipeline.git.branch >> ] + # clear large environment variables from the "scratch-desktop-and-link" context + # this helps when a program (like NPM) encounters errors with a large environment + - &clear_context + CSC_MACOS: "" + MAC_PROVISION_PROFILE: "" + SDM_CERT: "" + SDM_CERT_CA_BUNDLE: "" + SDM_CERT_KEY: "" + WIN_CSC_LINK: "" +jobs: + build_for_macos: + macos: + # CircleCI's Xcode 11.1.0 image is the last of their images to be based on macOS 10.14 + # I've had trouble building for earlier versions of macOS on Catalina but it's unclear whether that was due to + # Catalina or the version of Xcode. We should investigate this further. + xcode: 11.1.0 + steps: + - checkout + - npm_install: + npmCacheDir: ~/.npm + - run: + name: Test + command: npm run test + - when: + condition: + *should_sign + steps: + - run: + name: Import CI context + command: | + set -e + function decodeToFile () { + if [ -z "$1" ]; then + echo "Missing or invalid filename" + return 1 + fi + if [ -z "$2" ]; then + echo "Missing environment variable contents for file: $1" + return 2 + fi + echo "$2" | base64 --decode > "$1" + } + decodeToFile embedded.provisionprofile "${MAC_PROVISION_PROFILE}" + decodeToFile mas-dev.provisionprofile "${MAC_DEV_PROVISION_PROFILE}" + decodeToFile macos-certs-scratch-foundation.p12.gz "${CSC_MACOS_GZ}" + decodeToFile apple-dev-cert.p12 "${MAC_DEV_CERT}" + gunzip macos-certs-scratch-foundation.p12.gz + security -v create-keychain -p circleci circleci.keychain + security -v default-keychain -s circleci.keychain + security -v import macos-certs-scratch-foundation.p12 -k circleci.keychain -P "${CSC_MACOS_PASSWORD}" -T /usr/bin/codesign -T /usr/bin/productbuild + security -v import apple-dev-cert.p12 -k circleci.keychain -P "${MAC_DEV_CERT_PASSWORD}" -T /usr/bin/codesign -T /usr/bin/productbuild + security -v unlock-keychain -p circleci circleci.keychain + # "set-key-partition-list" prints extensive not-so-useful output and adding "-q" (even multiple times) doesn't suppress it. + # The "grep -v" at the end of this line suppresses all of that so any errors or warnings might be more visible. + security -v set-key-partition-list -S apple-tool:,apple:,codesign: -s -k circleci circleci.keychain | grep -v '^ 0x' + security -v set-keychain-settings -lut 600 circleci.keychain + security -v find-identity circleci.keychain + rm macos-certs-scratch-foundation.p12 apple-dev-cert.p12 + - restore_cache: + # Caching Homebrew's files (see the save_cache step below) means that Homebrew doesn't have to update as + # much. The Homebrew update can take several minutes without this, but with the cache it tends to take less + # than a minute most of the time. The cache will expire periodically and be replaced by a more up-to-date + # cache, which should effectively cap the amount of updating that Homebrew needs to do on top of the cache. + name: Restore Homebrew cache + key: homebrew-cache-v1 + - run: + name: Work around electron-userland/electron-builder#4964 + command: | + brew install go go-bindata + git -C ~ clone https://github.com/develar/app-builder.git + git -C ~/app-builder checkout b85740334fec875f5dd8dcd22eb1f729599109db + make --directory=~/app-builder build + ln -sfv ~/app-builder/dist/app-builder_darwin_amd64/app-builder ./node_modules/app-builder-bin/mac/ + - run: + name: Upgrade to Node 14 + command: brew install node@14 + - save_cache: + name: Save Homebrew cache + paths: + - ~/Library/Caches/Homebrew + - /usr/local/Homebrew + key: homebrew-cache-v1 + - build + - run: + name: Move DMG to artifacts directory + command: | + mkdir -p Artifacts/ + mv -v dist/Scratch*.dmg Artifacts/ + - when: + condition: + *should_sign + steps: + - run: + name: Zip MAS-Dev to artifacts directory + # If you use `zip` for this it will throw away some metadata (resource forks?) and + # the app will crash on startup with "EXC_CRASH (Code Signature Invalid)". + # To preserve that metadata, use `ditto` instead. + # See also: https://stackoverflow.com/a/22370486 + command: | + NPM_APP_VERSION="`node -pe "require('./package.json').version"`" + cd dist/mas-dev + ditto -v -c -k --sequesterRsrc --keepParent --zlibCompressionLevel 9 \ + Scratch*.app ../../Artifacts/mas-dev-${NPM_APP_VERSION}.zip + - run: + name: Move PKG to artifacts directory + command: | + mv -v dist/mas/Scratch*.pkg Artifacts/ + - store_artifacts: + path: Artifacts/ + build_for_windows: + executor: windows/default + steps: + - run: + # work around https://github.com/appveyor/ci/issues/2420 which seems to affect CircleCI too + # see also https://circleci.com/docs/2.0/env-vars/#using-parameters-and-bash-environment + name: Work around git-sh-setup issue + shell: bash + command: | + echo 'Adding libexec/git-core to PATH...' + echo 'For more details see https://github.com/appveyor/ci/issues/2420' + echo 'export PATH="$PATH:/c/Program Files/Git/mingw64/libexec/git-core"' >> $BASH_ENV + - run: + # nvm for Windows doesn't accept partial version numbers, so specify exact :( + name: Upgrade to Node 14.17.0 + command: | + nvm install 14.17.0 + nvm use 14.17.0 + - checkout + - npm_install: + npmCacheDir: "C:/Users/circleci/AppData/Roaming/npm-cache" + - run: + name: Test + command: npm run test + environment: *clear_context + - when: + condition: + *should_sign + steps: + - run: + name: Import CI context + shell: bash + command: | + set -e + function decodeToFile () { + if [ -z "$1" ]; then + echo "Missing or invalid filename" + return 1 + fi + if [ -z "$2" ]; then + echo "Missing environment variable contents for file: $1" + return 2 + fi + echo "$2" | base64 --decode > "$1" + } + decodeToFile ~/codesign.pfx "${WIN_CSC_LINK}" + - run: + # In theory this should be unnecessary: the electron-builder documentation says that WIN_CSC_LINK can + # be a base64-encoded certificate, which is what's in the CI context. In practice that leads to an + # signtool.exe finding the certificate but not the key, for reasons I haven't been able to understand. + # Also, because of the non-standard user configuration on CircleCI's Windows VM, attempting to import + # a certificate into the user's certificate store ("Cert:/LocalUser/My") will fail. Instead, this code + # imports the PFX into the machine certificate store. That usually requires Administrator permissions, + # but on CircleCI's setup it works just fine. See also: https://github.com/ShabadOS/desktop/issues/265 + # and https://github.com/ShabadOS/desktop/pull/266 + name: Add CSC to machine store + shell: powershell + command: | + $securePassword = (ConvertTo-SecureString -String $env:WIN_CSC_KEY_PASSWORD -AsPlainText -Force) + Import-PfxCertificate -FilePath ~/codesign.pfx -Password $securePassword -CertStoreLocation "Cert:/LocalMachine/My" + - build + - run: + name: Move Windows build products to artifacts directory + shell: bash + command: | + mkdir -p Artifacts/ + mv dist/{Scratch*.appx,Scratch*.exe} Artifacts/ + - store_artifacts: + path: Artifacts/ +commands: + npm_install: + description: Run 'npm install' with caching + parameters: + npmCacheDir: + type: string + description: NPM cache directory (`npm config cache get`) - usually either ~/.npm or %AppData%/npm-cache + steps: + - restore_cache: + keys: + - npm-cache-{{ arch }}-{{ checksum "package-lock.json" }} + - run: + name: Install node_modules + shell: bash # harmless on macOS, required on Windows to work around git-sh-setup issue + # --prefer-offline "will make npm skip any conditional requests (304 checks) for stale cache data, and only + # hit the network if something is missing from the cache" + command: npm ci --prefer-offline + environment: *clear_context + - save_cache: + paths: + - << parameters.npmCacheDir >> + key: npm-cache-{{ arch }}-{{ checksum "package-lock.json" }} + build: + steps: + - when: + condition: + *should_sign + steps: + - run: + command: npm run dist + no_output_timeout: 30m # macOS notarization can take longer than the default 10 minutes + environment: + # blank big variables to avoid crash on Windows + <<: *clear_context + # let Windows know where to get the PFX (ignored on non-Windows builds) + WIN_CSC_LINK: ~/codesign.pfx + # blank CIRCLE_BUILD_NUM to work around electron-userland/electron-builder#5016 + CIRCLE_BUILD_NUM: "" + - unless: + condition: + *should_sign + steps: + - run: + command: npm run distDev + environment: *clear_context diff --git a/.gitattributes b/.gitattributes index c49fe81..4e6bb68 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,7 @@ # File types which we know are binary # Prefer LF for most file types +*.css text eol=lf *.htm text eol=lf *.html text eol=lf *.js text eol=lf diff --git a/electron-builder.yaml b/electron-builder.yaml index 0224edc..0501880 100644 --- a/electron-builder.yaml +++ b/electron-builder.yaml @@ -5,7 +5,7 @@ copyright: Copyright © 2019-2020 Clip Team. compression: normal directories: buildResources: buildResources - output: dist/ + output: dist win: target: [ zip, nsis ] linux: diff --git a/electron-webpack.json5 b/electron-webpack.json5 index 29051a5..4d59a7e 100644 --- a/electron-webpack.json5 +++ b/electron-webpack.json5 @@ -1,9 +1,9 @@ { "main": { - "webpackConfig": "webpack.main.additions.js" + "webpackConfig": "webpack.main.js" }, "renderer": { "template": "src/renderer/index.html", - "webpackConfig": "webpack.renderer.additions.js" + "webpackConfig": "webpack.renderer.js" } } diff --git a/package.json b/package.json index c159228..0e96cef 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,20 @@ }, "version": "3.0.0", "license": "AGPL-3.0", - "scripts": { - "start": "node --max-old-space-size=4096 node_modules/electron-webpack/out/cli.js dev --bail --display-error-details --env.minify=false", - "build-gui": "node ./scripts/run-in-gui.js build", - "watch-gui": "node ./scripts/run-in-gui.js watch", - "clean": "rimraf ./dist/ ./static/assets/", - "compile": "rimraf ./dist/ && node --max-old-space-size=4096 node_modules/electron-webpack/out/cli.js --bail --display-error-details --env.minify=false", + "scripts": { + "clean": "rimraf ./dist ./static/assets", + "start": "mkdirp ./dist && electron-webpack dev --bail --display-error-details --env.minify=false --no-progress", + "compile": "mkdirp ./dist && electron-webpack --bail --display-error-details --env.minify=false --no-progress", "fetch": "rimraf ./static/assets/ && mkdirp ./static/assets/ && node ./scripts/fetchMediaLibraryAssets.js", - "dist": "yarn run build-gui && yarn run fetch && yarn run compile -p && node ./scripts/electron-builder-wrapper.js", - "builder": "node ./scripts/electron-builder-wrapper.js", - "dist:dir": "yarn run dist -- --dir -c.compression=store -c.mac.identity=null", - "lint": "eslint --cache --color --ext .jsx,.js ." + "build": "yarn run build:dev", + "build:dev": "yarn run compile && yarn run doBuild -- --mode=dev", + "build:dir": "yarn run compile && yarn run doBuild -- --mode=dir", + "build:dist": "yarn run compile && yarn run doBuild -- --mode=dist", + "doBuild": "node ./scripts/electron-builder-wrapper.js", + "dist": "yarn run clean && yarn run compile && yarn run fetch && yarn run doBuild -- --mode=dist", + "distDev": "yarn run clean && yarn run compile && yarn run fetch && yarn run doBuild -- --mode=dev", + "test": "yarn run test:lint", + "test:lint": "eslint --cache --color --ext .jsx,.js ." }, "repository": { "type": "git", @@ -41,7 +44,7 @@ "clipcc-gui": "latest", "copy-webpack-plugin": "^5.1.1", "electron": "^8.2.5", - "electron-builder": "^22.6.0", + "electron-builder": "^22.11.5", "electron-devtools-installer": "^3.0.0", "electron-notarize": "^0.3.0", "electron-store": "^5.1.1", @@ -50,19 +53,20 @@ "eslint-config-scratch": "^6.0.0", "eslint-plugin-import": "^2.20.0", "eslint-plugin-react": "^7.20.0", + "fs-extra": "^9.0.1", "intl": "1.2.5", "lodash.bindall": "^4.4.0", "lodash.defaultsdeep": "^4.6.1", "minilog": "^3.1.0", + "minimist": "^1.2.5", "mkdirp": "^1.0.4", "nets": "^3.2.0", "react": "16.2.0", "react-dom": "16.2.1", - "react-intl": "2.8.0", + "react-intl": "2.9.0", "react-redux": "5.0.7", - "redux": "3.5.2", + "redux": "3.7.2", "rimraf": "^3.0.2", - "source-map-loader": "^0.2.4", "uuid": "^8.0.0", "webpack": "^4.43.0" }, diff --git a/scripts/afterSign.js b/scripts/afterSign.js index af01eb1..fbe4fb0 100644 --- a/scripts/afterSign.js +++ b/scripts/afterSign.js @@ -6,10 +6,10 @@ const notarizeMacBuild = async function (context) { if (!process.env.AC_USERNAME) { console.error([ - 'Notarizing the macOS build requires an Apple ID.', - 'Please set the environment variable AC_USERNAME.', - 'Make sure your keychain has an item for "Application Loader: your@apple.id"', - 'This build will not run on newer versions of macOS!' + 'This build is not notarized and will not run on newer versions of macOS!', + 'Notarizing the macOS build requires an Apple ID. To notarize future builds:', + '* Set the environment variable AC_USERNAME to your@apple.id and', + '* Either set AC_PASSWORD or ensure your keychain has an item for "Application Loader: your@apple.id"' ].join('\n')); return; } @@ -17,7 +17,11 @@ const notarizeMacBuild = async function (context) { const appleId = process.env.AC_USERNAME; const appleIdKeychainItem = `Application Loader: ${appleId}`; - console.log(`Notarizing with Apple ID "${appleId}" and keychain item "${appleIdKeychainItem}"`); + if (process.env.AC_PASSWORD) { + console.log(`Notarizing with Apple ID "${appleId}" and a password`); + } else { + console.log(`Notarizing with Apple ID "${appleId}" and keychain item "${appleIdKeychainItem}"`); + } const {appOutDir} = context; const productFilename = context.packager.appInfo.productFilename; @@ -25,7 +29,7 @@ const notarizeMacBuild = async function (context) { appBundleId: appId, appPath: `${appOutDir}/${productFilename}.app`, appleId, - appleIdPassword: `@keychain:${appleIdKeychainItem}` + appleIdPassword: process.env.AC_PASSWORD || `@keychain:${appleIdKeychainItem}` }); }; diff --git a/scripts/electron-builder-wrapper.js b/scripts/electron-builder-wrapper.js index a7128a2..f3b6e1a 100644 --- a/scripts/electron-builder-wrapper.js +++ b/scripts/electron-builder-wrapper.js @@ -7,6 +7,7 @@ */ const {spawnSync} = require('child_process'); +const fs = require('fs'); /** * Strip any code signing configuration (CSC) from a set of environment variables. @@ -38,20 +39,38 @@ const getPlatformFlag = function () { /** * Run `electron-builder` once to build one or more target(s). - * @param {string} targetGroup - the target(s) to build in this pass. - * If the `targetGroup` is `'nsis'` then the environment must contain code-signing config (CSC_* or WIN_CSC_*). - * If the `targetGroup` is `'appx'` then code-signing config will be stripped from the environment if present. + * @param {object} wrapperConfig - overall configuration object for the wrapper script. + * @param {object} target - the target to build in this call. + * If the `target.name` is `'nsis'` then the environment must contain code-signing config (CSC_* or WIN_CSC_*). + * If the `target.name` is `'appx'` then code-signing config will be stripped from the environment if present. */ -const runBuilder = function (targetGroup) { - // the appx build fails if CSC_* or WIN_CSC_* variables are set - const shouldStripCSC = (targetGroup === 'appx'); +const runBuilder = function (wrapperConfig, target) { + // the AppX build fails if CSC_* or WIN_CSC_* variables are set + const shouldStripCSC = (target.name.indexOf('appx') === 0) || (!wrapperConfig.doSign); const childEnvironment = shouldStripCSC ? stripCSC(process.env) : process.env; - //if ((targetGroup === 'nsis') && !(childEnvironment.CSC_LINK || childEnvironment.WIN_CSC_LINK)) { - // throw new Error(`NSIS build requires CSC_LINK or WIN_CSC_LINK`); - //} + if (wrapperConfig.doSign && + (target.name.indexOf('nsis') === 0) && + !(childEnvironment.CSC_LINK || childEnvironment.WIN_CSC_LINK)) { + throw new Error(`Signing NSIS build requires CSC_LINK or WIN_CSC_LINK`); + } const platformFlag = getPlatformFlag(); - const customArgs = process.argv.slice(2); // remove `node` and `this-script.js` - const allArgs = [platformFlag, targetGroup, ...customArgs]; + let allArgs = [platformFlag, target.name]; + if (target.platform === 'darwin') { + allArgs.push(`--c.mac.type=${wrapperConfig.mode === 'dist' ? 'distribution' : 'development'}`); + if (target.name === 'mas-dev') { + allArgs.push('--c.mac.provisioningProfile=mas-dev.provisionprofile'); + } + if (wrapperConfig.doSign) { + // really this is "notarize only if we also sign" + allArgs.push('--c.afterSign=scripts/afterSign.js'); + } else { + allArgs.push('--c.mac.identity=null'); + } + } + if (!wrapperConfig.doPackage) { + allArgs.push('--dir', '--c.compression=store'); + } + allArgs = allArgs.concat(wrapperConfig.builderArgs); console.log(`running electron-builder with arguments: ${allArgs}`); const result = spawnSync('electron-builder', allArgs, { env: childEnvironment, @@ -70,30 +89,126 @@ const runBuilder = function (targetGroup) { }; /** - * @returns {Array.} - the default list of target groups on this platform. Each item in the array represents - * one call to `runBuilder` for one or more build target(s). + * @param {object} wrapperConfig - overall configuration object for the wrapper script. + * @returns {Array.} - the default list of targets on this platform. Each item in the array represents one + * call to `runBuilder` for exactly one build target. In theory electron-builder can build two or more targets at the + * same time but doing so limits has unwanted side effects on both macOS and Windows (see function body). */ -const calculateTargets = function () { +const calculateTargets = function (wrapperConfig) { + const masDevProfile = 'mas-dev.provisionprofile'; + const availableTargets = { + macAppStore: { + name: 'mas', + platform: 'darwin' + }, + macAppStoreDev: { + name: 'mas-dev', + platform: 'darwin' + }, + macDirectDownload: { + name: 'dmg', + platform: 'darwin' + }, + microsoftStore: { + name: 'appx:ia32 appx:x64', + platform: 'win32' + }, + windowsDirectDownload: { + name: 'nsis:ia32', + platform: 'win32' + } + }; + const targets = []; switch (process.platform) { case 'win32': // run in two passes so we can skip signing the appx //return ['nsis:ia32', 'appx']; return ['zip', 'nsis']; + /* + // Run in two passes so we can skip signing the AppX for distribution through the MS Store. + targets.push(availableTargets.microsoftStore); + targets.push(availableTargets.windowsDirectDownload); + break; + */ case 'darwin': // Running 'dmg' and 'mas' in the same pass causes electron-builder to skip signing the non-MAS app copy. - // Running them as separate passes means they both get signed. + // Running them as separate passes means they can both get signed. // Seems like a bug in electron-builder... // Running the 'mas' build first means that its output is available while we wait for 'dmg' notarization. // Add 'mas-dev' here to test a 'mas'-like build locally. You'll need a Mac Developer provisioning profile. return ['zip', 'mas', 'dmg']; + /* + // Add macAppStoreDev here to test a MAS-like build locally. You'll need a Mac Developer provisioning profile. + if (fs.existsSync(masDevProfile)) { + targets.push(availableTargets.macAppStoreDev); + } else { + console.log(`skipping target "${availableTargets.macAppStoreDev.name}": ${masDevProfile} missing`); + } + if (wrapperConfig.doSign) { + targets.push(availableTargets.macAppStore); + } else { + // electron-builder doesn't seem to support this configuration even if mac.type is "development" + console.log(`skipping target "${availableTargets.macAppStore.name}" because code-signing is disabled`); + } + targets.push(availableTargets.macDirectDownload); + break; + */ case 'linux': return ['zip', 'deb', 'rpm']; + default: + throw new Error(`Could not determine targets for platform: ${process.platform}`); + } + //return targets; +}; + +const parseArgs = function () { + const scriptArgs = process.argv.slice(2); // remove `node` and `this-script.js` + const builderArgs = []; + let mode = 'dev'; // default + + for (const arg of scriptArgs) { + const modeSplit = arg.split(/--mode(\s+|=)/); + if (modeSplit.length === 3) { + mode = modeSplit[2]; + } else { + builderArgs.push(arg); + } + } + + let doPackage; + let doSign; + + switch (mode) { + case 'dev': + doPackage = true; + doSign = false; + break; + case 'dir': + doPackage = false; + doSign = false; + break; + case 'dist': + doPackage = true; + doSign = true; + } + + return { + builderArgs, + doPackage, // false = build to directory + doSign, + mode + }; +}; + +const main = function () { + const wrapperConfig = parseArgs(); + + // TODO: allow user to specify targets? We could theoretically build NSIS on Mac, for example. + wrapperConfig.targets = calculateTargets(wrapperConfig); + + for (const target of wrapperConfig.targets) { + runBuilder(wrapperConfig, target); } - throw new Error(`Could not determine targets for platform: ${process.platform}`); }; -// TODO: allow user to specify targets? We could theoretically build NSIS on Mac, for example. -const targets = calculateTargets(); -for (const targetGroup of targets) { - runBuilder(targetGroup); -} +main(); diff --git a/scripts/fetchMediaLibraryAssets.js b/scripts/fetchMediaLibraryAssets.js index b729fc6..a9dd2bc 100644 --- a/scripts/fetchMediaLibraryAssets.js +++ b/scripts/fetchMediaLibraryAssets.js @@ -48,16 +48,11 @@ const collectAssets = function (dest) { collectSimple(libraries.costumes, dest, 'Costume'); collectSimple(libraries.sounds, dest, 'Sound'); libraries.sprites.forEach(sprite => { - if (sprite.md5) { - dest.add(sprite.md5); - } else { - console.warn(`Sprite has no MD5 property:\n${describe(sprite)}`); - } - if (sprite.json.costumes) { - collectSimple(sprite.json.costumes, dest, `Costume for sprite ${sprite.name}`); + if (sprite.costumes) { + collectSimple(sprite.costumes, dest, `Costume for sprite ${sprite.name}`); } - if (sprite.json.sounds) { - collectSimple(sprite.json.sounds, dest, `Sound for sprite ${sprite.name}`); + if (sprite.sounds) { + collectSimple(sprite.sounds, dest, `Sound for sprite ${sprite.name}`); } }); return dest; diff --git a/src/main/ScratchDesktopTelemetry.js b/src/main/ScratchDesktopTelemetry.js index ec27205..906a7d4 100644 --- a/src/main/ScratchDesktopTelemetry.js +++ b/src/main/ScratchDesktopTelemetry.js @@ -90,6 +90,11 @@ class ScratchDesktopTelemetry { // make a singleton so it's easy to share across both Electron processes const scratchDesktopTelemetrySingleton = new ScratchDesktopTelemetry(); +// `handle` works with `invoke` +ipcMain.handle('getTelemetryDidOptIn', () => + scratchDesktopTelemetrySingleton.didOptIn +); +// `on` works with `sendSync` (and `send`) ipcMain.on('getTelemetryDidOptIn', event => { event.returnValue = scratchDesktopTelemetrySingleton.didOptIn; }); diff --git a/src/main/argv.js b/src/main/argv.js new file mode 100644 index 0000000..25be323 --- /dev/null +++ b/src/main/argv.js @@ -0,0 +1,23 @@ +import minimist from 'minimist'; + +// inspired by yargs' process-argv +export const isElectronApp = () => !!process.versions.electron; +export const isElectronBundledApp = () => isElectronApp() && !process.defaultApp; + +export const parseAndTrimArgs = argv => { + // bundled Electron app: ignore 1 from "my-app arg1 arg2" + // unbundled Electron app: ignore 2 from "electron main/index.js arg1 arg2" + // node.js app: ignore 2 from "node src/index.js arg1 arg2" + const ignoreCount = isElectronBundledApp() ? 1 : 2; + + const parsed = minimist(argv); + + // ignore arguments AFTER parsing to handle cases like "electron --inspect=42 my.js arg1 arg2" + parsed._ = parsed._.slice(ignoreCount); + + return parsed; +}; + +const argv = parseAndTrimArgs(process.argv); + +export default argv; diff --git a/src/main/index.js b/src/main/index.js index f7bf66d..0a97190 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,17 +1,21 @@ -import {BrowserWindow, Menu, app, dialog, ipcMain, systemPreferences, globalShortcut} from 'electron'; -import fs from 'fs'; +import {BrowserWindow, Menu, app, dialog, ipcMain, shell, systemPreferences} from 'electron'; +import fs from 'fs-extra'; import path from 'path'; import {URL} from 'url'; +import {promisify} from 'util'; +import argv from './argv'; import {getFilterForExtension} from './FileFilters'; import telemetry from './ScratchDesktopTelemetry'; import MacOSMenu from './MacOSMenu'; import log from '../common/log.js'; +import {productName, version} from '../../package.json'; // suppress deprecation warning; this will be the default in Electron 9 app.allowRendererProcessReuse = true; -telemetry.appWasOpened(); +// suppress deprecation warning; this will be the default in Electron 9 +app.allowRendererProcessReuse = true; global.sharedObject = { argv: process.argv @@ -22,9 +26,29 @@ const defaultSize = {width: 1280, height: 800}; // good for MAS screenshots const isDevelopment = process.env.NODE_ENV !== 'production'; +const devToolKey = ((process.platform === 'darwin') ? + { // macOS: command+option+i + alt: true, // option + control: false, + meta: true, // command + shift: false, + code: 'KeyI' + } : { // Windows: control+shift+i + alt: false, + control: true, + meta: false, // Windows key + shift: true, + code: 'KeyI' + } +); + // global window references prevent them from being garbage-collected const _windows = {}; +// enable connecting to Scratch Link even if we DNS / Internet access is not available +// this must happen BEFORE the app ready event! +app.commandLine.appendSwitch('host-resolver-rules', 'MAP device-manager.scratch.mit.edu 127.0.0.1'); + const displayPermissionDeniedWarning = (browserWindow, permissionType) => { let title; let message; @@ -160,8 +184,30 @@ const createWindow = ({search = null, url = 'index.html', ...browserWindowOption webContents.session.setPermissionRequestHandler(handlePermissionRequest); + webContents.on('before-input-event', (event, input) => { + if (input.code === devToolKey.code && + input.alt === devToolKey.alt && + input.control === devToolKey.control && + input.meta === devToolKey.meta && + input.shift === devToolKey.shift && + input.type === 'keyDown' && + !input.isAutoRepeat && + !input.isComposing) { + event.preventDefault(); + webContents.openDevTools({mode: 'detach', activate: true}); + } + }); + + webContents.on('new-window', (event, newWindowUrl) => { + shell.openExternal(newWindowUrl); + event.preventDefault(); + }); + const fullUrl = makeFullUrl(url, search); window.loadURL(fullUrl); + window.once('ready-to-show', () => { + webContents.send('ready-to-show'); + }); return window; }; @@ -172,7 +218,18 @@ const createAboutWindow = () => { height: 400, parent: _windows.main, search: 'route=about', - title: 'About ClipCC' + title: `About ${productName}` + }); + return window; +}; + +const createPrivacyWindow = () => { + const window = createWindow({ + width: _windows.main.width * 0.8, + height: _windows.main.height * 0.8, + parent: _windows.main, + search: 'route=privacy', + title: `${productName} Privacy Policy` }); return window; }; @@ -189,13 +246,13 @@ const createMainWindow = () => { const window = createWindow({ width: defaultSize.width, height: defaultSize.height, - title: 'ClipCC 3' + title: `${productName} ${version}` // something like "Scratch 3.14" }); const webContents = window.webContents; - webContents.session.on('will-download', (ev, item) => { - const isProjectSave = getIsProjectSave(item); - const itemPath = item.getFilename(); + webContents.session.on('will-download', (willDownloadEvent, downloadItem) => { + const isProjectSave = getIsProjectSave(downloadItem); + const itemPath = downloadItem.getFilename(); const baseName = path.basename(itemPath); const extName = path.extname(baseName); const options = { @@ -206,22 +263,51 @@ const createMainWindow = () => { options.filters = [getFilterForExtension(extNameNoDot)]; } const userChosenPath = dialog.showSaveDialogSync(window, options); + // this will be falsy if the user canceled the save if (userChosenPath) { + const userBaseName = path.basename(userChosenPath); + const tempPath = path.join(app.getPath('temp'), userBaseName); + // WARNING: `setSavePath` on this item is only valid during the `will-download` event. Calling the async // version of `showSaveDialog` means the event will finish before we get here, so `setSavePath` will be // ignored. For that reason we need to call `showSaveDialogSync` above. - item.setSavePath(userChosenPath); - if (isProjectSave) { - const newProjectTitle = path.basename(userChosenPath, extName); - webContents.send('setTitleFromSave', {title: newProjectTitle}); - - // "setTitleFromSave" will set the project title but GUI has already reported the telemetry event - // using the old title. This call lets the telemetry client know that the save was actually completed - // and the event should be committed to the event queue with this new title. - telemetry.projectSaveCompleted(newProjectTitle); - } + downloadItem.setSavePath(tempPath); + + downloadItem.on('done', async (doneEvent, doneState) => { + try { + if (doneState !== 'completed') { + // The download was canceled or interrupted. Cancel the telemetry event and delete the file. + throw new Error(`save ${doneState}`); // "save cancelled" or "save interrupted" + } + await fs.move(tempPath, userChosenPath, {overwrite: true}); + if (isProjectSave) { + const newProjectTitle = path.basename(userChosenPath, extName); + webContents.send('setTitleFromSave', {title: newProjectTitle}); + + // "setTitleFromSave" will set the project title but GUI has already reported the telemetry + // event using the old title. This call lets the telemetry client know that the save was + // actually completed and the event should be committed to the event queue with this new title. + telemetry.projectSaveCompleted(newProjectTitle); + } + } catch (e) { + if (isProjectSave) { + telemetry.projectSaveCanceled(); + } + // don't clean up until after the message box to allow troubleshooting / recovery + await dialog.showMessageBox(window, { + type: 'error', + message: `Save failed:\n${userChosenPath}`, + detail: e.message + }); + fs.exists(tempPath).then(exists => { + if (exists) { + fs.unlink(tempPath); + } + }); + } + }); } else { - item.cancel(); + downloadItem.cancel(); if (isProjectSave) { telemetry.projectSaveCanceled(); } @@ -281,6 +367,14 @@ if (process.platform === 'win32') { // create main BrowserWindow when electron is ready app.on('ready', () => { + protocol.interceptFileProtocol('file', (request, callback) => { + const filePath = request.url.replace('file://', ''); + const url = request.url.includes('static/assets') ? path.normalize(`${__dirname}/${filePath}`) : filePath; + console.log(request.url, url); + callback({ path: url }); + }, err => { + if (err) console.error('Failed to register protocol'); + }); if (isDevelopment) { import('electron-devtools-installer').then(importedModule => { const {default: installExtension, ...devToolsExtensions} = importedModule; @@ -313,8 +407,44 @@ app.on('ready', () => { globalShortcut.register('CommandOrControl+Alt+D', () => { _windows.main.webContents.openDevTools({mode: 'detach', activate: true}); }); + + _windows.privacy = createPrivacyWindow(); + _windows.privacy.on('close', event => { + event.preventDefault(); + _windows.privacy.hide(); + }); }); ipcMain.on('open-about-window', () => { _windows.about.show(); }); + +ipcMain.on('open-privacy-policy-window', () => { + _windows.privacy.show(); +}); + +// start loading initial project data before the GUI needs it so the load seems faster +const initialProjectDataPromise = (async () => { + if (argv._.length === 0) { + // no command line argument means no initial project data + return; + } + if (argv._.length > 1) { + log.warn(`Expected 1 command line argument but received ${argv._.length}.`); + } + const projectPath = argv._[argv._.length - 1]; + try { + const projectData = await promisify(fs.readFile)(projectPath, null); + return projectData; + } catch (e) { + dialog.showMessageBox(_windows.main, { + type: 'error', + title: 'Failed to load project', + message: `Could not load project from file:\n${projectPath}`, + detail: e.message + }); + } + // load failed: initial project data undefined +})(); // IIFE + +ipcMain.handle('get-initial-project-data', () => initialProjectDataPromise); diff --git a/src/renderer/ScratchDesktopAppStateHOC.jsx b/src/renderer/ScratchDesktopAppStateHOC.jsx new file mode 100644 index 0000000..13a7905 --- /dev/null +++ b/src/renderer/ScratchDesktopAppStateHOC.jsx @@ -0,0 +1,53 @@ +import {ipcRenderer} from 'electron'; +import bindAll from 'lodash.bindall'; +import React from 'react'; + +/** + * Higher-order component to add desktop logic to AppStateHOC. + * @param {Component} WrappedComponent - an AppStateHOC-like component to wrap. + * @returns {Component} - a component similar to AppStateHOC with desktop-specific logic added. + */ +const ScratchDesktopAppStateHOC = function (WrappedComponent) { + class ScratchDesktopAppStateComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleTelemetryModalOptIn', + 'handleTelemetryModalOptOut' + ]); + this.state = { + // use `sendSync` because this should be set before first render + telemetryDidOptIn: ipcRenderer.sendSync('getTelemetryDidOptIn') + }; + } + handleTelemetryModalOptIn () { + ipcRenderer.send('setTelemetryDidOptIn', true); + ipcRenderer.invoke('getTelemetryDidOptIn').then(telemetryDidOptIn => { + this.setState({telemetryDidOptIn}); + }); + } + handleTelemetryModalOptOut () { + ipcRenderer.send('setTelemetryDidOptIn', false); + ipcRenderer.invoke('getTelemetryDidOptIn').then(telemetryDidOptIn => { + this.setState({telemetryDidOptIn}); + }); + } + render () { + const shouldShowTelemetryModal = (typeof ipcRenderer.sendSync('getTelemetryDidOptIn') !== 'boolean'); + + return (); + } + } + + return ScratchDesktopAppStateComponent; +}; + +export default ScratchDesktopAppStateHOC; diff --git a/src/renderer/ScratchDesktopGUIHOC.jsx b/src/renderer/ScratchDesktopGUIHOC.jsx new file mode 100644 index 0000000..2ace94d --- /dev/null +++ b/src/renderer/ScratchDesktopGUIHOC.jsx @@ -0,0 +1,175 @@ +import {ipcRenderer, remote} from 'electron'; +import bindAll from 'lodash.bindall'; +import omit from 'lodash.omit'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; +import GUIComponent from 'clipcc-gui/src/components/gui/gui.jsx'; + +import { + LoadingStates, + onFetchedProjectData, + onLoadedProject, + defaultProjectId, + requestNewProject, + requestProjectUpload, + setProjectId +} from 'clipcc-gui/src/reducers/project-state'; +import { + openLoadingProject, + closeLoadingProject, + openTelemetryModal +} from 'clipcc-gui/src/reducers/modals'; + +import ElectronStorageHelper from '../common/ElectronStorageHelper'; + +import showPrivacyPolicy from './showPrivacyPolicy'; + +/** + * Higher-order component to add desktop logic to the GUI. + * @param {Component} WrappedComponent - a GUI-like component to wrap. + * @returns {Component} - a component similar to GUI with desktop-specific logic added. + */ +const ScratchDesktopGUIHOC = function (WrappedComponent) { + class ScratchDesktopGUIComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleProjectTelemetryEvent', + 'handleSetTitleFromSave', + 'handleStorageInit', + 'handleUpdateProjectTitle' + ]); + this.props.onLoadingStarted(); + ipcRenderer.invoke('get-initial-project-data').then(initialProjectData => { + const hasInitialProject = initialProjectData && (initialProjectData.length > 0); + this.props.onHasInitialProject(hasInitialProject, this.props.loadingState); + if (!hasInitialProject) { + this.props.onLoadingCompleted(); + return; + } + this.props.vm.loadProject(initialProjectData).then( + () => { + this.props.onLoadingCompleted(); + this.props.onLoadedProject(this.props.loadingState, true); + }, + e => { + this.props.onLoadingCompleted(); + this.props.onLoadedProject(this.props.loadingState, false); + remote.dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'error', + title: 'Failed to load project', + message: 'Invalid or corrupt project file.', + detail: e.message + }); + + // this effectively sets the default project ID + // TODO: maybe setting the default project ID should be implicit in `requestNewProject` + this.props.onHasInitialProject(false, this.props.loadingState); + + // restart as if we didn't have an initial project to load + this.props.onRequestNewProject(); + } + ); + }); + } + componentDidMount () { + ipcRenderer.on('setTitleFromSave', this.handleSetTitleFromSave); + } + componentWillUnmount () { + ipcRenderer.removeListener('setTitleFromSave', this.handleSetTitleFromSave); + } + handleClickAbout () { + ipcRenderer.send('open-about-window'); + } + handleProjectTelemetryEvent (event, metadata) { + ipcRenderer.send(event, metadata); + } + handleSetTitleFromSave (event, args) { + this.handleUpdateProjectTitle(args.title); + } + handleStorageInit (storageInstance) { + storageInstance.addHelper(new ElectronStorageHelper(storageInstance)); + } + handleUpdateProjectTitle (newTitle) { + this.setState({projectTitle: newTitle}); + } + render () { + const childProps = omit(this.props, Object.keys(ScratchDesktopGUIComponent.propTypes)); + + return ( this.handleClickAbout() + }, + { + title: 'Privacy Policy', + onClick: () => showPrivacyPolicy() + }, + { + title: 'Data Settings', + onClick: () => this.props.onTelemetrySettingsClicked() + } + ]} + onProjectTelemetryEvent={this.handleProjectTelemetryEvent} + onShowPrivacyPolicy={showPrivacyPolicy} + onStorageInit={this.handleStorageInit} + onUpdateProjectTitle={this.handleUpdateProjectTitle} + + // allow passed-in props to override any of the above + {...childProps} + />); + } + } + + ScratchDesktopGUIComponent.propTypes = { + loadingState: PropTypes.oneOf(LoadingStates), + onFetchedInitialProjectData: PropTypes.func, + onHasInitialProject: PropTypes.func, + onLoadedProject: PropTypes.func, + onLoadingCompleted: PropTypes.func, + onLoadingStarted: PropTypes.func, + onRequestNewProject: PropTypes.func, + onTelemetrySettingsClicked: PropTypes.func, + // using PropTypes.instanceOf(VM) here will cause prop type warnings due to VM mismatch + vm: GUIComponent.WrappedComponent.propTypes.vm + }; + const mapStateToProps = state => { + const loadingState = state.scratchGui.projectState.loadingState; + return { + loadingState: loadingState, + vm: state.scratchGui.vm + }; + }; + const mapDispatchToProps = dispatch => ({ + onLoadingStarted: () => dispatch(openLoadingProject()), + onLoadingCompleted: () => dispatch(closeLoadingProject()), + onHasInitialProject: (hasInitialProject, loadingState) => { + if (hasInitialProject) { + // emulate sb-file-uploader + return dispatch(requestProjectUpload(loadingState)); + } + + // `createProject()` might seem more appropriate but it's not a valid state transition here + // setting the default project ID is a valid transition from NOT_LOADED and acts like "create new" + return dispatch(setProjectId(defaultProjectId)); + }, + onFetchedInitialProjectData: (projectData, loadingState) => + dispatch(onFetchedProjectData(projectData, loadingState)), + onLoadedProject: (loadingState, loadSuccess) => { + const canSaveToServer = false; + return dispatch(onLoadedProject(loadingState, canSaveToServer, loadSuccess)); + }, + onRequestNewProject: () => dispatch(requestNewProject(false)), + onTelemetrySettingsClicked: () => dispatch(openTelemetryModal()) + }); + + return connect(mapStateToProps, mapDispatchToProps)(ScratchDesktopGUIComponent); +}; + +export default ScratchDesktopGUIHOC; diff --git a/src/renderer/about.css b/src/renderer/about.css new file mode 100644 index 0000000..43f9ce7 --- /dev/null +++ b/src/renderer/about.css @@ -0,0 +1,39 @@ +html, body { + background-color: #4D97FF; + color: white; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: bolder; +} + +a:active, a:hover, a:link, a:visited { + color: currentColor; +} + +a:active, a:hover { + filter: brightness(0.9); +} + +.aboutBox { + margin: 0; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.aboutLogo { + max-width: 10rem; + max-height: 10rem; +} + +.aboutText { + margin: 1.5rem; +} + +.aboutDetails { + font-size: x-small; +} + +.aboutFooter { + font-size: small; +} diff --git a/src/renderer/about.jsx b/src/renderer/about.jsx index 8906fe3..204b829 100644 --- a/src/renderer/about.jsx +++ b/src/renderer/about.jsx @@ -1,43 +1,29 @@ import React from 'react'; -import ReactDOM from 'react-dom'; import {productName, version} from '../../package.json'; import logo from '../icon/ScratchDesktop.svg'; +import styles from './about.css'; -// TODO: localization? const AboutElement = () => ( -
+
{`${productName}
-

{productName}

-
Version {version}
- - { - ['Electron', 'Chrome'].map(component => { - const componentVersion = process.versions[component.toLowerCase()]; - return ; - }) - } -
{component}{componentVersion}
+
+

{productName}

+ Version {version} + + { + ['Electron', 'Chrome', 'Node'].map(component => { + const componentVersion = process.versions[component.toLowerCase()]; + return ; + }) + } +
{component}{componentVersion}
+
); -const appTarget = document.getElementById('app'); -ReactDOM.render(, appTarget); +export default ; diff --git a/src/renderer/app.jsx b/src/renderer/app.jsx index 81d3a7f..7e55fa3 100644 --- a/src/renderer/app.jsx +++ b/src/renderer/app.jsx @@ -1,132 +1,23 @@ -import {ipcRenderer, shell, remote} from 'electron'; -import fs from 'fs'; -import bindAll from 'lodash.bindall'; import React from 'react'; -import ReactDOM from 'react-dom'; import {compose} from 'redux'; import GUI, {AppStateHOC} from 'clipcc-gui'; -import ElectronStorageHelper from '../common/ElectronStorageHelper'; - +import ScratchDesktopAppStateHOC from './ScratchDesktopAppStateHOC.jsx'; +import ScratchDesktopGUIHOC from './ScratchDesktopGUIHOC.jsx'; import styles from './app.css'; -const defaultProjectId = 0; -const externalProjectId = -1; - -// override window.open so that it uses the OS's default browser, not an electron browser -window.open = function (url, target) { - if (target === '_blank') { - shell.openExternal(url); - } -}; -// Register "base" page view -// analytics.pageview('/'); - const appTarget = document.getElementById('app'); -appTarget.className = styles.app || 'app'; // TODO -document.body.appendChild(appTarget); +appTarget.className = styles.app || 'app'; GUI.setAppElement(appTarget); -const ScratchDesktopHOC = function (WrappedComponent) { - class ScratchDesktopComponent extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'handleProjectTelemetryEvent', - 'handleSetTitleFromSave', - 'handleStorageInit', - 'handleTelemetryModalOptIn', - 'handleTelemetryModalOptOut', - 'handleUpdateProjectTitle', - 'handleVmInit', - 'handleRef' - ]); - this.state = { - projectTitle: null - }; - } - componentDidMount () { - ipcRenderer.on('setTitleFromSave', this.handleSetTitleFromSave); - } - componentWillUnmount () { - ipcRenderer.removeListener('setTitleFromSave', this.handleSetTitleFromSave); - } - handleClickLogo () { - ipcRenderer.send('open-about-window'); - } - handleProjectTelemetryEvent (event, metadata) { - ipcRenderer.send(event, metadata); - } - handleSetTitleFromSave (event, args) { - this.handleUpdateProjectTitle(args.title); - } - handleStorageInit (storageInstance) { - storageInstance.addHelper(new ElectronStorageHelper(storageInstance)); - } - handleTelemetryModalOptIn () { - ipcRenderer.send('setTelemetryDidOptIn', true); - } - handleTelemetryModalOptOut () { - ipcRenderer.send('setTelemetryDidOptIn', false); - } - handleUpdateProjectTitle (newTitle) { - this.setState({projectTitle: newTitle}); - } - handleVmInit (vm) { - const argv = remote.getGlobal('sharedObject').argv; - if (argv.length > 1 && argv[1]) { - this.gui.props.onLoadingStarted(); - fs.readFile(argv[1], (err, data) => { - if (err) { - this.gui.props.onLoadingFinished(this.gui.props.loadingState, false); - } else { - vm.loadProject(data) - .then(() => { - this.gui.props.onLoadingFinished(this.gui.props.loadingState, true); - }) - .catch(error => { - console.warn(error); - this.gui.props.onLoadingFinished(this.gui.props.loadingState, false); - }); - } - }); - } - } - handleRef (gui) { - this.gui = gui; - } - render () { - const shouldLoadExternalProject = remote.getGlobal('sharedObject').argv.length > 1; - const shouldShowTelemetryModal = (typeof ipcRenderer.sendSync('getTelemetryDidOptIn') !== 'boolean'); - return (); - } - } - - return ScratchDesktopComponent; -}; - // note that redux's 'compose' function is just being used as a general utility to make // the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's // ability to compose reducers. const WrappedGui = compose( - ScratchDesktopHOC, - AppStateHOC + ScratchDesktopAppStateHOC, + AppStateHOC, + ScratchDesktopGUIHOC )(GUI); -ReactDOM.render(, appTarget); +export default ; diff --git a/src/renderer/index.html b/src/renderer/index.html index a9665fa..7efeb1d 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -20,6 +20,6 @@ -

Scratch Desktop is loading...

+

Scratch is loading...

diff --git a/src/renderer/index.js b/src/renderer/index.js index 49915ad..c0609b7 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -1,12 +1,33 @@ -// this is an async import so that it doesn't block the first render -// index.html contains a loading/splash screen which will display while this import loads +// This file does async imports of the heavy JSX, especially app.jsx, to avoid blocking the first render. +// The main index.html just contains a loading/splash screen which will display while this import loads. + +import {ipcRenderer} from 'electron'; + +import ReactDOM from 'react-dom'; + +ipcRenderer.on('ready-to-show', () => { + // Start without any element in focus, otherwise the first link starts with focus and shows an orange box. + // We shouldn't disable that box or the focus behavior in case someone wants or needs to navigate that way. + // This seems like a hack... maybe there's some better way to do avoid any element starting with focus? + document.activeElement.blur(); +}); const route = new URLSearchParams(window.location.search).get('route') || 'app'; +let routeModulePromise; switch (route) { case 'app': - import('./app.jsx'); // eslint-disable-line no-unused-expressions + routeModulePromise = import('./app.jsx'); break; case 'about': - import('./about.jsx'); // eslint-disable-line no-unused-expressions + routeModulePromise = import('./about.jsx'); + break; +case 'privacy': + routeModulePromise = import('./privacy.jsx'); break; } + +routeModulePromise.then(routeModule => { + const appTarget = document.getElementById('app'); + const routeElement = routeModule.default; + ReactDOM.render(routeElement, appTarget); +}); diff --git a/src/renderer/privacy.css b/src/renderer/privacy.css new file mode 100644 index 0000000..a40fb4f --- /dev/null +++ b/src/renderer/privacy.css @@ -0,0 +1,14 @@ +html, body { + background-color: #4D97FF; + color: white; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: normal; + line-height: 150%; +} + +.privacyBox { + background-color: white; + color: #575e75; + margin: 3rem; + padding: 2rem 3rem; +} diff --git a/src/renderer/privacy.jsx b/src/renderer/privacy.jsx new file mode 100644 index 0000000..ac7aaf0 --- /dev/null +++ b/src/renderer/privacy.jsx @@ -0,0 +1,235 @@ +import React from 'react'; + +import styles from './privacy.css'; + +const PrivacyElement = () => ( +
+

Privacy Policy

+ The Scratch Privacy Policy was last updated: October 5, 2020 +

+ The Scratch Foundation (“Scratch”, “we” or “us”) understands how + important privacy is to our community. We wrote this Privacy Policy to explain what Personal Information + (“Information”) we collect through our offline editor (the “Scratch App”), how we use, process, and share it, and what we're doing to keep it safe. It + also tells you about your rights and choices with respect to your Personal Information, and how you can contact us if you have any questions or concerns. +

+

What Information Does Scratch Collect About Me?

+

+ For the purpose of this Privacy Policy, “Information” means any information relating to an + identified or identifiable individual. The Scratch App automatically collects and stores locally the + following Information through its telemetry system: the title of your project in text form, language + setting, time zone and events related to your use of the Scratch App (namely when the Scratch App was + opened and closed, if a project file has been loaded or saved, or if a new project is created). If you + choose to turn on the telemetry sharing feature, the Scratch App will transmit this information to Scratch. + Projects created in the Scratch App are not transmitted to or accessible by Scratch. +

+

How Does Scratch Use My Information?

+

We use this Information for the following purposes:

+
    +
  • + Analytics and Improving the Scratch App - We use the Information to analyze use of the Scratch + App and to enhance your learning experience on the Scratch App. +
  • +
  • + Academic and Scientific Research - We de-identify and aggregate Information for statistical + analysis in the context of scientific and academic research. For example, to help us understand how + people learn through the Scratch App and how we can enhance learning tools for young people. The + results of such research are shared with educators and researchers through conferences, journals, and + other academic or scientific publications. You can find out more on our Research page. +
  • +
  • + Legal - We may use your Information to enforce our Terms of Use, to defend our legal rights, and to comply with our legal obligations and internal + policies. We may do this by analyzing your use of the Scratch App. +
  • +
+

What Are The Legal Grounds For Processing Your Information?

+

+ If you are located in the European Economic Area, the United Kingdom or Switzerland, we only process your + Information based on a valid legal ground. A “legal ground” is a reason that justifies our use + of your Information. In this case, we or a third party have a legitimate interest in using your Information + (if you choose to allow the Scratch App to send the Scratch team your Information) to create, analyze and + share your aggregated or de-identified Information for research purposes, to analyze and enhance your + learning experience on the Scratch App and otherwise ensure and improve the safety, security, and + performance of the Scratch App. We only rely on our or a third party’s legitimate interests to process your + Information when these interests are not overridden by your rights and interests. +

+

How Does Scratch Share My Information?

+

+ We disclose information that we collect through the Scratch App to third parties in the following + circumstances: +

+
    +
  • + Service Providers - To third parties who provide services such as website hosting, data + analysis, Information technology and related infrastructure provisions, customer service, email + delivery, and other services. +
  • +
  • + Academic and Scientific Research - To research institutions, such as the Massachusetts Institute + of Technology (MIT), to learn about how our users learn through the Scratch App and develop new + learning tools. The results of this research or the statistical analysis may be shared through + conferences, journals, and other publications. +
  • +
  • + Merger - To a potential or actual acquirer, successor, or assignee as part of any + reorganization, merger, sale, joint venture, assignment, transfer, or other disposition of all or any + portion of our organization or assets. You will have the opportunity to opt out of any such transfer if + the new entity's planned processing of your Information differs materially from that set forth in + this Privacy Policy. +
  • +
  • + Legal - If required to do so by law or in the good faith belief that such action is appropriate: + (a) under applicable law, including laws outside your country of residence; (b) to comply with legal + process; (c) to respond to requests from public and government authorities, such as school, school + districts, and law enforcement, including public and government authorities outside your country of + residence; (d) to enforce our terms and conditions; (e) to protect our operations or those of any of + our affiliates; (f) to protect our rights, privacy, safety, or property, and/or that of our affiliates, + you, or others; and (g) to allow us to pursue available remedies or limit the damages that we may + sustain. +
  • +
+

Children and Student Privacy

+

+ The Scratch Foundation is a 501(c)(3) nonprofit organization. As such, the Children's Online Privacy + Protection Act (COPPA) does not apply to Scratch. Nevertheless, Scratch takes children's privacy + seriously. Scratch collects only minimal information from its users, and only uses and discloses + information to provide the services and for limited other purposes, such as research, as described in this + Privacy Policy. +

+

+ Scratch does not collect information from a student's education record, as defined by the Family + Educational Rights and Privacy Act (FERPA). Scratch does not disclose information of students to any third + parties except as described in this Privacy Policy. +

+

Your Data Protection Rights (EEA)

+

+ If you are located in the European Economic Area, the United Kingdom or Switzerland, you have certain + rights in relation to your Information: +

+
    +
  • + Access, Correction and Data Portability - You may ask for an overview of the Information we + process about you and to receive a copy of your Information. You also have the right to request to + correct incomplete, inaccurate or outdated Information. To the extent required by applicable law, you + may request us to provide your Information to another company. +
  • +
  • + Objection – You may object to (this means “ask us to stop”) any use of your + Information that is not (i) processed to comply with a legal obligation, (ii) necessary to do what is + provided in a contract between Scratch and you, or (iii) if we have a compelling reason to do so (such + as, to ensure safety and security in our online community). If you do object, we will work with you to + find a reasonable solution. +
  • +
  • + Deletion - You may also request the deletion of your Information, as permitted under applicable + law. This applies, for instance, where your Information is outdated or the processing is not necessary + or is unlawful; where you withdraw your consent to our processing based on such consent; or where you + have objected to our processing. In some situations, we may need to retain your Information due to + legal obligations or for litigation purposes. If you want to have all of your Information removed from + our servers, please contact help@scratch.mit.edu for assistance. +
  • +
  • + Restriction Of Processing - You may request that we restrict processing of your Information + while we are processing a request relating to (i) the accuracy of your Information, (ii) the lawfulness + of the processing of your Information, or (iii) our legitimate interests to process this Information. + You may also request that we restrict processing of your Information if you wish to use the Information + for litigation purposes. +
  • +
  • + Withdrawal Of Consent – Where we rely on consent for the processing of your Information, you + have the right to withdraw it at any time and free of charge. When you do so, this will not affect the + lawfulness of the processing before your consent withdrawal. +
  • +
+

+ In addition to the above-mentioned rights, you also have the right to lodge a complaint with a competent + supervisory authority subject to applicable law. However, there are exceptions and limitations to each of + these rights. We may, for example, refuse to act on a request if the request is manifestly unfounded or + excessive, or if the request is likely to adversely affect the rights and freedoms of others, prejudice the + execution or enforcement of the law, interfere with pending or future litigation, or infringe applicable + law. To submit a request to exercise your rights, please contact help@scratch.mit.edu for assistance. +

+

Data Retention

+

+ We take measures to delete your Information or keep it in a form that does not allow you to be identified + when this Information is no longer necessary for the purposes for which we process it, unless we are + required by law to keep this Information for a longer period. When determining the retention period, we + take into account various criteria, such as the type of services requested by or provided to you, the + nature and length of our relationship with you, possible re-enrollment with our services, the impact on the + services we provide to you if we delete some Information from or about you, mandatory retention periods + provided by law and the statute of limitations. +

+

How Does Scratch Protect My Information?

+

+ Scratch has in place administrative, physical, and technical procedures that are intended to protect the + Information we collect on the Scratch App against accidental or unlawful destruction, accidental loss, + unauthorized alteration, unauthorized disclosure or access, misuse, and any other unlawful form of + processing of the Information. However, as effective as these measures are, no security system is + impenetrable. We cannot completely guarantee the security of our databases, nor can we guarantee that the + Information you supply will not be intercepted while being transmitted to us over the Internet. +

+

International Data Transfer

+

+ We may transfer your Information to countries other than the country where you are located, including to + the U.S. (where our Scratch servers are located) or any other country in which we or our service providers + maintain facilities. If you are located in the European Economic Area, the United Kingdom or Switzerland, + or other regions with laws governing data collection and use that may differ from U.S. law, please note + that we may transfer your Information to a country and jurisdiction that does not have the same data + protection laws as your jurisdiction. We apply appropriate safeguards to the Information processed and + transferred on our behalf. Please contact us for more information on the safeguards used. +

+

Notifications Of Changes To The Privacy Policy

+

+ We review our Privacy Policy on a periodic basis, and we may modify our policies as appropriate. We will + notify you of any material changes. We encourage you to review our Privacy Policy on a regular basis. The + “Last Updated” date at the top of this page indicates when this Privacy Policy was last + revised. Your continued use of the Scratch App following these changes means that you accept the revised + Privacy Policy. +

+

Contact Us

+

+ The Scratch Foundation is the entity responsible for the processing of your Information. If you have any + questions about this Privacy Policy, or if you would like to exercise your rights to your Information, you + may contact us at help@scratch.mit.edu or via mail at: +

+
+
Scratch Foundation
+
ATTN: Privacy Policy
+
+
201 South Street
+ Boston, MA 02111 +
+
+
+); + +export default ; diff --git a/src/renderer/showPrivacyPolicy.js b/src/renderer/showPrivacyPolicy.js new file mode 100644 index 0000000..5f4371c --- /dev/null +++ b/src/renderer/showPrivacyPolicy.js @@ -0,0 +1,13 @@ +import {ipcRenderer} from 'electron'; + +const showPrivacyPolicy = event => { + if (event) { + // Probably a click on a link; don't actually follow the link in the `href` attribute. + event.preventDefault(); + } + // tell the main process to open the privacy policy window + ipcRenderer.send('open-privacy-policy-window'); + return false; +}; + +export default showPrivacyPolicy; diff --git a/webpack.main.additions.js b/webpack.main.additions.js deleted file mode 100644 index 96a8ceb..0000000 --- a/webpack.main.additions.js +++ /dev/null @@ -1,11 +0,0 @@ -const path = require('path'); - -const makeConfig = require('./webpack.makeConfig.js'); - -module.exports = makeConfig({ - name: 'main', - useReact: false, - babelPaths: [ - path.resolve(__dirname, 'src', 'main') - ] -}); diff --git a/webpack.main.js b/webpack.main.js new file mode 100644 index 0000000..776071a --- /dev/null +++ b/webpack.main.js @@ -0,0 +1,16 @@ +const path = require('path'); + +const makeConfig = require('./webpack.makeConfig.js'); + +module.exports = defaultConfig => + makeConfig( + defaultConfig, + { + name: 'main', + useReact: false, + disableDefaultRulesForExtensions: ['js'], + babelPaths: [ + path.resolve(__dirname, 'src', 'main') + ] + } + ); diff --git a/webpack.makeConfig.js b/webpack.makeConfig.js index 0e1db69..3218786 100644 --- a/webpack.makeConfig.js +++ b/webpack.makeConfig.js @@ -1,17 +1,23 @@ const childProcess = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const util = require('util'); const electronPath = require('electron'); const webpack = require('webpack'); +const merge = require('webpack-merge'); + +// PostCss +const autoprefixer = require('autoprefixer'); +const postcssVars = require('postcss-simple-vars'); +const postcssImport = require('postcss-import'); const isProduction = (process.env.NODE_ENV === 'production'); const electronVersion = childProcess.execSync(`${electronPath} --version`, {encoding: 'utf8'}).trim(); console.log(`Targeting Electron ${electronVersion}`); // eslint-disable-line no-console -const makeConfig = function (options) { - // eslint-disable-next-line no-console - console.log(`Module "${options.name}" building in production mode? ${isProduction}`); - +const makeConfig = function (defaultConfig, options) { const babelOptions = { // Explicitly disable babelrc so we don't catch various config in much lower dependencies. babelrc: false, @@ -33,30 +39,68 @@ const makeConfig = function (options) { }]); } - return { + // TODO: consider adjusting these rules instead of discarding them in at least some cases + if (options.disableDefaultRulesForExtensions) { + defaultConfig.module.rules = defaultConfig.module.rules.filter(rule => { + if (!(rule.test instanceof RegExp)) { + // currently we don't support overriding other kinds of rules + return true; + } + // disable default rules for any file extension listed here + // we will handle these files in some other way (see below) + // OR we want to avoid any processing at all (such as with fonts) + const shouldDisable = options.disableDefaultRulesForExtensions.some( + ext => rule.test.test(`test.${ext}`) + ); + const statusWord = shouldDisable ? 'Discarding' : 'Keeping'; + console.log(`${options.name}: ${statusWord} electron-webpack default rule for ${rule.test}`); + return !shouldDisable; + }); + } + + const config = merge.smart(defaultConfig, { devtool: 'cheap-module-eval-source-map', mode: isProduction ? 'production' : 'development', module: { rules: [ - // Override the *.js defaults from electron-webpack - // The test/include/exclude must match the defaults exactly for webpack-merge to do the override - { - test: /\.js$/, - exclude: /(node_modules|bower_components)/, - loader: 'babel-loader', - options: babelOptions - }, - // Add a new rule for the other files we want to run through babel { test: sourceFileTest, include: options.babelPaths, loader: 'babel-loader', options: babelOptions }, + { // coped from scratch-gui + test: /\.css$/, + use: [{ + loader: 'style-loader' + }, { + loader: 'css-loader', + options: { + modules: true, + importLoaders: 1, + localIdentName: '[name]_[local]_[hash:base64:5]', + camelCase: true + } + }, { + loader: 'postcss-loader', + options: { + ident: 'postcss', + plugins: function () { + return [ + postcssImport, + postcssVars, + autoprefixer + ]; + } + } + }] + }, { - test: sourceFileTest, - loader: 'source-map-loader', - enforce: 'pre' + test: /\.(svg|png|wav|gif|jpg)$/, + loader: 'file-loader', + options: { + outputPath: 'static/assets/' + } } ] }, @@ -67,9 +111,27 @@ const makeConfig = function (options) { ].concat(options.plugins || []), resolve: { cacheWithContext: false, - symlinks: false + symlinks: false, + alias: { + // act like scratch-gui has this line in its package.json: + // "browser": "./src/index.js" + 'clipcc-gui$': path.resolve(__dirname, 'node_modules', 'clipcc-gui', 'src', 'index.js') + } } - }; + }); + + // If we're not on CI, enable Webpack progress output + // Note that electron-webpack enables this by default, so use '--no-progress' to avoid double-adding this plugin + if (!process.env.CI) { + config.plugins.push(new webpack.ProgressPlugin()); + } + + fs.writeFileSync( + `dist/webpack.${options.name}.js`, + `module.exports = ${util.inspect(config, {depth: null})};\n` + ); + + return config; }; module.exports = makeConfig; diff --git a/webpack.renderer.js b/webpack.renderer.js new file mode 100644 index 0000000..86bd9ca --- /dev/null +++ b/webpack.renderer.js @@ -0,0 +1,39 @@ +const path = require('path'); + +const CopyWebpackPlugin = require('copy-webpack-plugin'); + +const makeConfig = require('./webpack.makeConfig.js'); + +const getModulePath = moduleName => path.dirname(require.resolve(`${moduleName}/package.json`)); + +module.exports = defaultConfig => + makeConfig( + defaultConfig, + { + name: 'renderer', + useReact: true, + disableDefaultRulesForExtensions: ['js', 'jsx', 'css', 'svg', 'png', 'wav', 'gif', 'jpg', 'ttf'], + babelPaths: [ + path.resolve(__dirname, 'src', 'renderer'), + /node_modules[\\/]+scratch-[^\\/]+[\\/]+src/, + /node_modules[\\/]+clipcc-[^\\/]+[\\/]+src/, + /node_modules[\\/]+pify/, + /node_modules[\\/]+@vernier[\\/]+godirect/ + ], + plugins: [ + new CopyWebpackPlugin([{ + from: path.join(getModulePath('clipcc-blocks'), 'media'), + to: 'static/blocks-media' + }]), + new CopyWebpackPlugin([{ + from: 'extension-worker.{js,js.map}', + context: path.join(getModulePath('clipcc-vm'), 'dist', 'web') + }]), + new CopyWebpackPlugin([{ + from: path.join(getModulePath('clipcc-gui'), 'src', 'lib', 'libraries', '*.json'), + to: 'static/libraries', + flatten: true + }]) + ] + } + );