Compare commits

..

No commits in common. "master" and "v1.5.71" have entirely different histories.

280 changed files with 44601 additions and 58837 deletions

View File

@ -7,6 +7,7 @@ indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@ -1,10 +0,0 @@
module.exports = {
extends: ["./node_modules/@balena/lint/config/.eslintrc.js"],
root: true,
ignorePatterns: ["node_modules/"],
rules: {
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/ban-ts-comment": "off",
},
};

454
.eslintrc.yml Normal file
View File

@ -0,0 +1,454 @@
env:
browser: true
commonjs: true
es6: true
node: true
mocha: true
plugins:
- lodash
- jsdoc
- node
- react
extends: 'standard'
parserOptions:
sourceType: 'script'
ecmaFeatures:
jsx: true
settings:
jsdoc:
additionalTagNames:
customTags:
- fulfil
rules:
# Possible Errors
no-console:
- off
no-empty:
- error
no-extra-semi:
- error
no-negated-in-lhs:
- error
no-prototype-builtins:
- error
valid-jsdoc:
- error
- requireReturn: false
requireReturnDescription: false
requireReturnType: true
requireParamDescription: true
preferType:
boolean: "Boolean"
number: "Number"
object: "Object"
string: "String"
array: "Array"
prefer:
arg: "param"
return: "returns"
# Best Practices
array-callback-return:
- error
block-scoped-var:
- error
class-methods-use-this:
- error
complexity:
- off
consistent-return:
- error
curly:
- error
default-case:
- error
dot-notation:
- error
guard-for-in:
- error
no-alert:
- error
no-case-declarations:
- error
no-div-regex:
- error
no-else-return:
- error
no-empty-function:
- error
no-eq-null:
- error
no-extra-label:
- error
no-implicit-coercion:
- error
no-implicit-globals:
- error
no-loop-func:
- error
no-magic-numbers:
- error
no-native-reassign:
- error
no-param-reassign:
- error
no-restricted-properties:
- error
- property: __proto__
no-return-await:
- error
no-script-url:
- error
no-unused-expressions:
- error
no-unused-labels:
- error
no-useless-concat:
- error
no-void:
- error
no-warning-comments:
- off
radix:
- error
vars-on-top:
- off
# Strict mode
strict:
- error
- global
# Variables
init-declarations:
- error
- always
no-catch-shadow:
- error
no-restricted-globals:
- error
- event
no-shadow:
- error
no-undefined:
- error
no-unused-vars:
- error
no-use-before-define:
- error
# NodeJS and CommonJS
callback-return:
- error
global-require:
- off
no-mixed-requires:
- error
no-process-env:
- off
no-process-exit:
- off
no-sync:
- off
# Stylistic Issues
array-bracket-spacing:
- error
- always
capitalized-comments:
- error
- always
- ignoreConsecutiveComments: true
comma-spacing:
- error
- before: false
after: true
computed-property-spacing:
- error
- never
consistent-this:
- error
- self
func-name-matching:
- error
- always
func-names:
- error
- never
func-style:
- error
- expression
id-blacklist:
- error
id-length:
- error
- min: 2
exceptions:
- "_"
id-match:
- error
- "^[_0-9A-Za-z\\$]+$"
line-comment-position:
- error
- position: above
linebreak-style:
- error
- unix
lines-around-comment:
- error
- beforeBlockComment: true
afterBlockComment: false
beforeLineComment: true
afterLineComment: false
allowBlockStart: true
allowBlockEnd: false
allowObjectStart: true
allowObjectEnd: false
allowArrayStart: true
allowArrayEnd: false
lines-around-directive:
- error
- always
max-len:
- error
- code: 130
comments: 150
ignoreComments: false
ignoreTrailingComments: false
ignoreUrls: true
max-params:
- off
max-statements-per-line:
- error
- max: 1
multiline-ternary:
- off
newline-per-chained-call:
- off
no-bitwise:
- error
no-continue:
- error
no-inline-comments:
- error
no-lonely-if:
- error
no-mixed-operators:
- error
no-multi-assign:
- error
no-negated-condition:
- error
no-nested-ternary:
- error
no-plusplus:
- error
no-restricted-syntax:
- error
- WithStatement
- ForInStatement
no-spaced-func:
- error
no-underscore-dangle:
- error
- allowAfterThis: false
object-curly-newline:
- error
- minProperties: 3
consistent: true
object-curly-spacing:
- error
- always
one-var-declaration-per-line:
- error
- always
operator-assignment:
- error
- always
quotes:
- error
- single
quote-props:
- error
- as-needed
require-jsdoc:
- error
- require:
FunctionDeclaration: true
ClassDeclaration: true
MethodDefinition: true
ArrowFunctionExpression: true
space-before-function-paren:
- error
- anonymous: always
named: always
asyncArrow: always
template-tag-spacing:
- error
- always
unicode-bom:
- error
# ECMAScript 6
arrow-parens:
- error
- always
arrow-spacing:
- error
- before: true
after: true
generator-star-spacing:
- error
- before: true
after: false
no-confusing-arrow:
- error
no-var:
- error
object-shorthand:
- error
- always
prefer-const:
- error
prefer-spread:
- error
prefer-numeric-literals:
- error
prefer-rest-params:
- error
prefer-template:
- error
prefer-arrow-callback:
- error
- allowNamedFunctions: false
require-yield:
- error
symbol-description:
- error
# Lodash
lodash/chain-style:
- error
- explicit
lodash/identity-shorthand:
- error
- always
lodash/import-scope:
- error
- full
lodash/matches-prop-shorthand:
- error
- always
lodash/matches-shorthand:
- error
- always
lodash/no-commit:
- error
lodash/path-style:
- error
- array
lodash/prefer-compact:
- error
lodash/prefer-filter:
- error
- 5
lodash/prefer-flat-map:
- error
lodash/prefer-invoke-map:
- error
lodash/prefer-map:
- error
lodash/prefer-reject:
- error
lodash/prefer-thru:
- error
lodash/prefer-wrapper-method:
- error
lodash/prop-shorthand:
- error
- always
lodash/prefer-constant:
- error
- true
- true
lodash/prefer-get:
- error
- 2
lodash/prefer-includes:
- error
- includeNative: true
lodash/prefer-is-nil:
- error
lodash/prefer-lodash-chain:
- error
lodash/prefer-lodash-method:
- error
lodash/prefer-lodash-typecheck:
- error
lodash/prefer-matches:
- error
- 3
lodash/prefer-noop:
- error
lodash/prefer-over-quantifier:
- error
lodash/prefer-startswith:
- error
lodash/prefer-times:
- error
# JSDoc
jsdoc/check-param-names:
- error
jsdoc/check-tag-names:
- error
jsdoc/newline-after-description:
- error
jsdoc/require-example:
- error
jsdoc/require-hyphen-before-param-description:
- error
jsdoc/require-param:
- error
jsdoc/require-param-description:
- error
jsdoc/require-param-type:
- error
jsdoc/require-returns-type:
- error
# Node
node/no-deprecated-api:
- error
node/no-missing-import:
- error
node/no-missing-require:
- error
node/process-exit-as-throw:
- error
node/no-extraneous-require:
- error
node/no-extraneous-import:
- error
# React
react/jsx-uses-vars:
- error
overrides:
files: ['*.jsx']
rules:
require-jsdoc:
- off

4
.gitattributes vendored
View File

@ -1,6 +1,3 @@
# default
* text
# Javascript files must retain LF line-endings (to keep eslint happy)
*.js text eol=lf
*.jsx text eol=lf
@ -30,7 +27,6 @@ Makefile text
*.yml text
*.patch text
*.txt text
*.tpl text
CODEOWNERS text
*.plist text

View File

@ -1,11 +1,6 @@
- **Etcher version:**
- **Operating system and architecture:**
- **Image flashed:**
- **What do you think should have happened:** <!-- or a step by step reproduction process -->
- **What happened:**
- **Do you see any meaningful error information in the DevTools?**
<!-- You can open DevTools by pressing `Ctrl+Shift+I` (`Ctrl+Alt+I` for Etcher before v1.3.x), or `Cmd+Opt+I` if you're on macOS. -->
<!-- issues with missing information will be labeled as not-enough-info and closed shortly -->
<!-- please try to include as many influencing elements as possible are you root, does any other process block the device, etc. -->
<!-- if you find a solution in the meantime thank you for sharing the fix and not just closing / abandoning your issue -->
<!-- You can open DevTools by pressing `Ctrl+Shift+I` (`Ctrl+Alt+I` for Etcher before v1.3.x), or `Cmd+Alt+I` if you're on Mac OS. -->

View File

@ -1,205 +0,0 @@
---
name: package and publish GitHub (draft) release
# https://github.com/product-os/flowzone/tree/master/.github/actions
inputs:
json:
description: 'JSON stringified object containing all the inputs from the calling workflow'
required: true
secrets:
description: 'JSON stringified object containing all the secrets from the calling workflow'
required: true
# --- custom environment
NODE_VERSION:
type: string
# Beware that native modules will be built for this version,
# which might not be compatible with the one used by pkg (see forge.sidecar.ts)
# https://github.com/vercel/pkg-fetch/releases
default: '20.19'
VERBOSE:
type: string
default: 'true'
runs:
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
using: 'composite'
steps:
- name: Download custom source artifact
uses: actions/download-artifact@v4
with:
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}-${{ runner.arch }}
path: ${{ runner.temp }}
- name: Extract custom source artifact
if: runner.os != 'Windows'
shell: bash
working-directory: .
run: tar -xf ${{ runner.temp }}/custom.tgz
- name: Extract custom source artifact
if: runner.os == 'Windows'
shell: pwsh
working-directory: .
run: C:\"Program Files"\Git\usr\bin\tar.exe --force-local -xf ${{ runner.temp }}\custom.tgz
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ inputs.NODE_VERSION }}
cache: npm
- name: Install host dependencies
if: runner.os == 'Linux'
shell: bash
run: sudo apt-get install -y --no-install-recommends fakeroot dpkg rpm
# rpmbuild will strip binaries by default, which breaks the sidecar.
# Use a macro to override the "strip" to bypass stripping.
- name: Configure rpmbuild to not strip executables
if: runner.os == 'Linux'
shell: bash
run: echo '%__strip /usr/bin/true' > ~/.rpmmacros
- name: Install host dependencies
if: runner.os == 'macOS'
# FIXME: Python 3.12 dropped distutils that node-gyp depends upon.
# This is a temporary workaround to make the job use Python 3.11 until
# we update to npm 10+.
uses: actions/setup-python@d27e3f3d7c64b4bbf8e4abfb9b63b83e846e0435 # v4
with:
python-version: '3.11'
# https://www.electron.build/code-signing.html
# https://dev.to/rwwagner90/signing-electron-apps-with-github-actions-4cof
- name: Import Apple code signing certificate
if: runner.os == 'macOS'
shell: bash
run: |
KEY_CHAIN=build.keychain
CERTIFICATE_P12=certificate.p12
# Recreate the certificate from the secure environment variable
echo $CERTIFICATE_P12_B64 | base64 --decode > $CERTIFICATE_P12
# Create a keychain
security create-keychain -p actions $KEY_CHAIN
# Make the keychain the default so identities are found
security default-keychain -s $KEY_CHAIN
# Unlock the keychain
security unlock-keychain -p actions $KEY_CHAIN
security import $CERTIFICATE_P12 -k $KEY_CHAIN -P $CERTIFICATE_PASSWORD -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k actions $KEY_CHAIN
# remove certs
rm -fr *.p12
env:
CERTIFICATE_P12_B64: ${{ fromJSON(inputs.secrets).APPLE_SIGNING }}
CERTIFICATE_PASSWORD: ${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}
- name: Import Windows code signing certificate
if: runner.os == 'Windows'
id: import_win_signing_cert
shell: powershell
run: |
Set-Content -Path ${{ runner.temp }}/certificate.base64 -Value $env:SM_CLIENT_CERT_FILE_B64
certutil -decode ${{ runner.temp }}/certificate.base64 ${{ runner.temp }}/Certificate_pkcs12.p12
Remove-Item -path ${{ runner.temp }} -include certificate.base64
echo "certFilePath=${{ runner.temp }}/Certificate_pkcs12.p12" >> $GITHUB_OUTPUT
env:
SM_CLIENT_CERT_FILE_B64: ${{ fromJSON(inputs.secrets).SM_CLIENT_CERT_FILE_B64 }}
- name: Package release
shell: bash
# IMPORTANT: before making changes to this step please consult @engineering in balena's chat.
run: |
## FIXME: causes issues with `xxhash` which tries to load a debug build which doens't exist and cannot be compiled
# if [[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]]; then
# export DEBUG='electron-forge:*,sidecar'
# fi
APPLICATION_VERSION="$(jq -r '.version' package.json)"
HOST_ARCH="$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')"
if [[ "${RUNNER_OS}" == Linux ]]; then
PLATFORM=Linux
SHA256SUM_BIN=sha256sum
elif [[ "${RUNNER_OS}" == macOS ]]; then
PLATFORM=Darwin
SHA256SUM_BIN='shasum -a 256'
elif [[ "${RUNNER_OS}" == Windows ]]; then
PLATFORM=Windows
SHA256SUM_BIN=sha256sum
# Install DigiCert Signing Manager Tools
curl --silent --retry 3 --fail https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download \
-H "x-api-key:$SM_API_KEY" \
-o smtools-windows-x64.msi
msiexec -i smtools-windows-x64.msi -qn
PATH="/c/Program Files/DigiCert/DigiCert One Signing Manager Tools:${PATH}"
smksp_registrar.exe list
smctl.exe keypair ls
smctl.exe windows certsync
/c/Windows/System32/certutil.exe -csp "DigiCert Signing Manager KSP" -key -user
# (signtool.exe) https://github.com/actions/runner-images/blob/main/images/win/Windows2019-Readme.md#installed-windows-sdks
PATH="/c/Program Files (x86)/Windows Kits/10/bin/${runner_arch}:${PATH}"
else
echo "ERROR: unexpected runner OS: ${RUNNER_OS}"
exit 1
fi
# Currently, we can only build for the host architecture.
npx electron-forge make
echo "version=${APPLICATION_VERSION}" >> $GITHUB_OUTPUT
# collect all artifacts from subdirectories under a common top-level directory
mkdir -p dist
find ./out/make -type f \( \
-iname "*.zip" -o \
-iname "*.dmg" -o \
-iname "*.rpm" -o \
-iname "*.deb" -o \
-iname "*.AppImage" -o \
-iname "*Setup.exe" \
\) -ls -exec cp '{}' dist/ \;
if [[ -n "${SHA256SUM_BIN}" ]]; then
# Compute and save digests.
cd dist/
${SHA256SUM_BIN} *.* >"SHA256SUMS.${PLATFORM}.${HOST_ARCH}.txt"
fi
env:
# ensure we sign the artifacts
NODE_ENV: production
# analytics tokens
SENTRY_TOKEN: https://739bbcfc0ba4481481138d3fc831136d@o95242.ingest.sentry.io/4504451487301632
AMPLITUDE_TOKEN: 'balena-etcher'
# Apple notarization
XCODE_APP_LOADER_EMAIL: ${{ fromJSON(inputs.secrets).XCODE_APP_LOADER_EMAIL }}
XCODE_APP_LOADER_PASSWORD: ${{ fromJSON(inputs.secrets).XCODE_APP_LOADER_PASSWORD }}
XCODE_APP_LOADER_TEAM_ID: ${{ fromJSON(inputs.secrets).XCODE_APP_LOADER_TEAM_ID }}
# Windows signing
SM_CLIENT_CERT_PASSWORD: ${{ fromJSON(inputs.secrets).SM_CLIENT_CERT_PASSWORD }}
SM_CLIENT_CERT_FILE: '${{ runner.temp }}\Certificate_pkcs12.p12'
SM_HOST: ${{ fromJSON(inputs.secrets).SM_HOST }}
SM_API_KEY: ${{ fromJSON(inputs.secrets).SM_API_KEY }}
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ fromJSON(inputs.secrets).SM_CODE_SIGNING_CERT_SHA1_HASH }}
TIMESTAMP_SERVER: http://timestamp.digicert.com
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: gh-release-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}-${{ runner.arch }}
path: dist
retention-days: 1
if-no-files-found: error

View File

@ -1,98 +0,0 @@
---
name: test release
# https://github.com/product-os/flowzone/tree/master/.github/actions
inputs:
json:
description: 'JSON stringified object containing all the inputs from the calling workflow'
required: true
secrets:
description: 'JSON stringified object containing all the secrets from the calling workflow'
required: true
# --- custom environment
NODE_VERSION:
type: string
default: '20.19'
VERBOSE:
type: string
default: 'true'
runs:
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
using: 'composite'
steps:
# https://github.com/actions/setup-node#caching-global-packages-data
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ inputs.NODE_VERSION }}
cache: npm
- name: Install host dependencies
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb libudev-dev
cat < package.json | jq -r '.hostDependencies[][]' - | \
xargs -L1 echo | sed 's/|//g' | xargs -L1 \
sudo apt-get --ignore-missing install || true
- name: Install host dependencies
if: runner.os == 'macOS'
# FIXME: Python 3.12 dropped distutils that node-gyp depends upon.
# This is a temporary workaround to make the job use Python 3.11 until
# we update to npm 10+.
uses: actions/setup-python@d27e3f3d7c64b4bbf8e4abfb9b63b83e846e0435 # v4
with:
python-version: '3.11'
- name: Test release
shell: bash
run: |
## FIXME: causes issues with `xxhash` which tries to load a debug build which doens't exist and cannot be compiled
# if [[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]]; then
# export DEBUG='electron-forge:*,sidecar'
# fi
npm ci
# as the shrinkwrap might have been done on mac/linux, this is ensure the package is there for windows
if [[ "$RUNNER_OS" == "Windows" ]]; then
npm i -D winusb-driver-generator
# need to modifies @yao-pkg/pkg-fetch
# expected-shas.json and patches.json files to force use of nodejs v20.11.1 instead of latest minor (v20.19.4 at the time of writing).
# this is required for Windows compatibility as 20.15.1 introduced a regression that breaks the flasher on Windows.
# As soon as nodejs the fix is backported to node20 and, or node 22, this script can be removed: https://github.com/nodejs/node/pull/55623
# Add entry to expected-shas.json
sed -i 's/}$/,\n "node-v20.11.1-win-x64": "140c377c2c91751832e673cb488724cbd003f01aa237615142cd2907f34fa1a2"\n}/' node_modules/@yao-pkg/pkg-fetch/lib-es5/expected-shas.json
# Replace any "v20..." key with "v20.11.1" in patches.json (keeps value)
sed -i -E 's/"v20[^"]*":/"v20.11.1":/' node_modules/@yao-pkg/pkg-fetch/patches/patches.json
fi
npm run lint
npm run package
npm run wdio # test stage, note that it requires the package to be done first
env:
# https://www.electronjs.org/docs/latest/api/environment-variables
ELECTRON_NO_ATTACH_CONSOLE: 'true'
- name: Compress custom source
if: runner.os != 'Windows'
shell: bash
run: tar -acf ${{ runner.temp }}/custom.tgz .
- name: Compress custom source
if: runner.os == 'Windows'
shell: pwsh
run: C:\"Program Files"\Git\usr\bin\tar.exe --force-local -acf ${{ runner.temp }}\custom.tgz .
- name: Upload custom artifact
uses: actions/upload-artifact@v4
with:
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}-${{ runner.arch }}
path: ${{ runner.temp }}/custom.tgz
retention-days: 1

View File

@ -1,41 +0,0 @@
name: Flowzone
on:
pull_request:
types: [opened, synchronize, closed]
branches: [main, master]
# allow external contributions to use secrets within trusted code
pull_request_target:
types: [opened, synchronize, closed]
branches: [main, master]
jobs:
flowzone:
name: Flowzone
uses: product-os/flowzone/.github/workflows/flowzone.yml@master
# prevent duplicate workflows and only allow one `pull_request` or `pull_request_target` for
# internal or external contributions respectively
if: |
(github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request') ||
(github.event.pull_request.head.repo.full_name != github.repository && github.event_name == 'pull_request_target')
secrets: inherit
with:
custom_test_matrix: >
{
"os": [
["ubuntu-22.04"],
["windows-2022"],
["macos-13"],
["macos-latest-xlarge"]
]
}
custom_publish_matrix: >
{
"os": [
["ubuntu-22.04"],
["windows-2022"],
["macos-13"],
["macos-latest-xlarge"]
]
}
restrict_custom_actions: false
github_prerelease: true
cloudflare_website: 'etcher'

View File

@ -1,14 +0,0 @@
name: Publish to WinGet
on:
release:
types: [released]
jobs:
publish:
runs-on: windows-latest # action can only be run on windows
steps:
- uses: vedantmgoyal2009/winget-releaser@v2
with:
identifier: Balena.Etcher
# matches something like "balenaEtcher-1.19.0.Setup.exe"
installers-regex: 'balenaEtcher-[\d.-]+\.Setup.exe$'
token: ${{ secrets.WINGET_PAT }}

112
.gitignore vendored
View File

@ -1,103 +1,40 @@
# -- ADD NEW ENTRIES AT THE END OF THE FILE ---
# Logs
logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
/lib-cov
# Image stream output directory
/tests/image-stream/output
# Coverage directory used by tools like istanbul
coverage
*.lcov
/coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Compiled binary addons (http://nodejs.org/api/addons.html)
/build
# Dependency directories
node_modules/
jspm_packages/
# Generated files
/generated
# TypeScript v1 declaration files
typings/
# Dependency directory
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
node_modules
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Webpack
.webpack/
# Vite
.vite/
# Electron-Forge
out/
# ---- Do not modify entries above this line ----
# Build artifacts
dist/
# Compiled Etcher releases
/dist
# Certificates
*.spc
@ -107,17 +44,6 @@ dist/
*.crt
*.pem
# Secrets
.gitsecret/keys/random_seed
!*.secret
secrets/APPLE_SIGNING_PASSWORD.txt
secrets/WINDOWS_SIGNING_PASSWORD.txt
secrets/XCODE_APP_LOADER_PASSWORD.txt
secrets/WINDOWS_SIGNING.pfx
# OSX files
# Image stream output directory
/tests/image-stream/output
#local development
.yalc
yalc.lock
.DS_Store

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "scripts/resin"]
path = scripts/resin
url = https://github.com/balena-io/scripts.git
branch = master

1
.nvmrc
View File

@ -1 +0,0 @@
18

View File

@ -1,6 +0,0 @@
const fs = require("fs");
const path = require("path");
module.exports = JSON.parse(
fs.readFileSync(path.join(__dirname, "node_modules", "@balena", "lint", "config", ".prettierrc"), "utf8"),
);

84
.resinci.json Normal file
View File

@ -0,0 +1,84 @@
{
"electron": {
"npm_version": "6.7.0",
"dependencies": {
"linux": [
"libudev-dev",
"libusb-1.0-0-dev",
"libyaml-dev",
"libgtk-3-0",
"libatk-bridge2.0-0",
"libdbus-1-3",
"libc6"
]
},
"builder": {
"appId": "io.balena.etcher",
"copyright": "Copyright 2016-2019 Balena Ltd",
"productName": "balenaEtcher",
"nodeGypRebuild": true,
"afterPack": "./afterPack.js",
"files": [
"build/Release/elevator.node",
"generated",
"lib/shared/catalina-sudo/sudo-askpass.osascript.js",
"lib/gui/app/index.html",
"lib/gui/css/*.css",
"lib/gui/css/fonts/*.woff2",
"lib/gui/assets/*.svg",
"assets/icon.png",
"!node_modules/**/**",
"node_modules/**/*.js",
"node_modules/**/*.json",
"node_modules/**/*.node",
"node_modules/**/*.dll",
"node_modules/node-raspberrypi-usbboot/blobs/**",
"node_modules/flexboxgrid/dist/flexboxgrid.css",
"node_modules/roboto-fontface/fonts/roboto/Roboto-Thin.woff",
"node_modules/roboto-fontface/fonts/roboto/Roboto-Light.woff",
"node_modules/roboto-fontface/fonts/roboto/Roboto-Regular.woff",
"node_modules/roboto-fontface/fonts/roboto/Roboto-Medium.woff",
"node_modules/roboto-fontface/fonts/roboto/Roboto-Bold.woff",
"node_modules/bootstrap-sass/assets/fonts/bootstrap/glyphicons-halflings-regular.woff2"
],
"afterSign": "./afterSignHook.js",
"mac": {
"asar": false,
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
"entitlements": "entitlements.mac.plist",
"entitlementsInherit": "entitlements.mac.plist"
},
"dmg": {
"iconSize": 110,
"contents": [
{
"x": 140,
"y": 245
},
{
"x": 415,
"y": 245,
"type": "link",
"path": "/Applications"
}
],
"window": {
"width": 544,
"height": 407
}
},
"linux": {
"category": "Utility",
"packageCategory": "utils",
"synopsis": "balenaEtcher is a powerful OS image flasher built with web technologies to ensure flashing an SDCard or USB drive is a pleasant and safe experience. It protects you from accidentally writing to your hard-drives, ensures every byte of data was written correctly and much more."
},
"deb": {
"priority": "optional",
"depends": [
"polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1"
]
}
}
}
}

17
.sass-lint.yml Normal file
View File

@ -0,0 +1,17 @@
# sass-lint config generated by make-sass-lint-config v0.1.2
files:
include: lib/gui/scss/**/*.scss
options:
formatter: stylish
merge-default-rules: false
rules:
no-css-comments: 0
no-important: 0
no-qualifying-elements: 0
placeholder-in-extend: 0
property-sort-order: 0
quotes:
- 1
- style: double

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2
CODEOWNERS Normal file
View File

@ -0,0 +1,2 @@
* @thundron @zvin @jviotti
/scripts @nazrhom

View File

@ -37,16 +37,10 @@ modules=xwayland.so
Sometimes, things might go wrong, and you end up with a half-flashed drive that is unusable by your operating systems, and common graphical tools might even refuse to get it back to a normal state.
To solve these kinds of problems, we've collected [a list of fail-proof methods](https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md#recovering-broken-drives) to completely erase your drive in major operating systems.
## I receive "No polkit authentication agent found" error in GNU/Linux
## I receive ”No polkit authentication agent found” error in GNU/Linux
Etcher requires an available [polkit authentication agent](https://wiki.archlinux.org/index.php/Polkit#Authentication_agents) in your system in order to show a secure password prompt dialog to perform elevation. Make sure you have one installed for the desktop environment of your choice.
## May I run Etcher in older macOS versions?
Etcher GUI is based on the [Electron](http://electron.atom.io/) framework, [which only supports macOS 10.10 and newer versions](https://github.com/electron/electron/blob/master/docs/tutorial/support.md#supported-platforms).
## Can I use the Flash With Etcher button on my site?
You can use the Flash with Etcher button on your site or blog, if you have an OS that you want your users to be able to easily flash using Etcher, add the following code where you want to button to be:
`<a href="https://efp.balena.io/open-image-url?imageUrl=<your image URL>"><img src="http://balena.io/flash-with-etcher.png" /></a>`
Etcher GUI is based on the [Electron](http://electron.atom.io/) framework, [which only supports macOS 10.9 and newer versions](https://github.com/electron/electron/blob/master/docs/tutorial/support.md#supported-platforms).

215
Makefile Normal file
View File

@ -0,0 +1,215 @@
# ---------------------------------------------------------------------
# Build configuration
# ---------------------------------------------------------------------
RESIN_SCRIPTS ?= ./scripts/resin
export NPM_VERSION ?= 6.7.0
S3_BUCKET = artifacts.ci.balena-cloud.com
# This directory will be completely deleted by the `clean` rule
BUILD_DIRECTORY ?= dist
# See http://stackoverflow.com/a/20763842/1641422
BUILD_DIRECTORY_PARENT = $(dir $(BUILD_DIRECTORY))
ifeq ($(wildcard $(BUILD_DIRECTORY_PARENT).),)
$(error $(BUILD_DIRECTORY_PARENT) does not exist)
endif
BUILD_TEMPORARY_DIRECTORY = $(BUILD_DIRECTORY)/.tmp
$(BUILD_DIRECTORY):
mkdir $@
$(BUILD_TEMPORARY_DIRECTORY): | $(BUILD_DIRECTORY)
mkdir $@
# See https://stackoverflow.com/a/13468229/1641422
SHELL := /bin/bash
PATH := $(shell pwd)/node_modules/.bin:$(PATH)
# ---------------------------------------------------------------------
# Operating system and architecture detection
# ---------------------------------------------------------------------
# http://stackoverflow.com/a/12099167
ifeq ($(OS),Windows_NT)
PLATFORM = win32
ifeq ($(PROCESSOR_ARCHITEW6432),AMD64)
HOST_ARCH = x64
else
ifeq ($(PROCESSOR_ARCHITECTURE),AMD64)
HOST_ARCH = x64
endif
ifeq ($(PROCESSOR_ARCHITECTURE),x86)
HOST_ARCH = x86
endif
endif
else
ifeq ($(shell uname -s),Linux)
PLATFORM = linux
ifeq ($(shell uname -m),x86_64)
HOST_ARCH = x64
endif
ifneq ($(filter %86,$(shell uname -m)),)
HOST_ARCH = x86
endif
ifeq ($(shell uname -m),armv7l)
HOST_ARCH = armv7hf
endif
ifeq ($(shell uname -m),aarch64)
HOST_ARCH = aarch64
endif
ifeq ($(shell uname -m),armv8)
HOST_ARCH = aarch64
endif
ifeq ($(shell uname -m),arm64)
HOST_ARCH = aarch64
endif
endif
ifeq ($(shell uname -s),Darwin)
PLATFORM = darwin
ifeq ($(shell uname -m),x86_64)
HOST_ARCH = x64
endif
endif
endif
ifndef PLATFORM
$(error We could not detect your host platform)
endif
ifndef HOST_ARCH
$(error We could not detect your host architecture)
endif
# Default to host architecture. You can override by doing:
#
# make <target> TARGET_ARCH=<arch>
#
TARGET_ARCH ?= $(HOST_ARCH)
# ---------------------------------------------------------------------
# Electron
# ---------------------------------------------------------------------
electron-develop: | $(BUILD_TEMPORARY_DIRECTORY)
$(RESIN_SCRIPTS)/electron/install.sh \
-b $(shell pwd) \
-r $(TARGET_ARCH) \
-s $(PLATFORM) \
-m $(NPM_VERSION)
electron-test:
$(RESIN_SCRIPTS)/electron/test.sh \
-b $(shell pwd) \
-s $(PLATFORM)
assets/dmg/background.tiff: assets/dmg/background.png assets/dmg/background@2x.png
tiffutil -cathidpicheck $^ -out $@
electron-build: assets/dmg/background.tiff | $(BUILD_TEMPORARY_DIRECTORY)
$(RESIN_SCRIPTS)/electron/build.sh \
-b $(shell pwd) \
-r $(TARGET_ARCH) \
-s $(PLATFORM) \
-v production \
-n $(BUILD_TEMPORARY_DIRECTORY)/npm \
-w $(BUILD_TEMPORARY_DIRECTORY)
# ---------------------------------------------------------------------
# Phony targets
# ---------------------------------------------------------------------
TARGETS = \
help \
info \
lint \
lint-js \
lint-sass \
lint-cpp \
lint-html \
lint-spell \
test-spectron \
test-gui \
test \
sanity-checks \
clean \
distclean \
webpack \
electron-develop \
electron-test \
electron-build
webpack:
./node_modules/.bin/webpack
.PHONY: $(TARGETS)
sass:
npm rebuild node-sass
node-sass lib/gui/app/scss/main.scss > lib/gui/css/main.css
lint-ts:
resin-lint --typescript lib
lint-js:
eslint --ignore-pattern scripts/resin/**/*.js lib tests scripts bin webpack.config.js
lint-sass:
sass-lint lib/gui/scss
lint-cpp:
cpplint --recursive src
lint-html:
node scripts/html-lint.js
lint-spell:
codespell \
--dictionary - \
--dictionary dictionary.txt \
--skip *.svg *.gz,*.bz2,*.xz,*.zip,*.img,*.dmg,*.iso,*.rpi-sdcard,*.wic,.DS_Store,*.dtb,*.dtbo,*.dat,*.elf,*.bin,*.foo,xz-without-extension \
lib tests docs Makefile *.md LICENSE
lint: lint-ts lint-js lint-sass lint-cpp lint-html lint-spell
MOCHA_OPTIONS=--recursive --reporter spec --require ts-node/register
# See https://github.com/electron/spectron/issues/127
ETCHER_SPECTRON_ENTRYPOINT ?= $(shell node -e 'console.log(require("electron"))')
test-spectron:
ETCHER_SPECTRON_ENTRYPOINT="$(ETCHER_SPECTRON_ENTRYPOINT)" mocha $(MOCHA_OPTIONS) tests/spectron
test-gui:
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox --renderer tests/gui
test-sdk:
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox tests/shared
test: test-gui test-sdk test-spectron
help:
@echo "Available targets: $(TARGETS)"
info:
@echo "Platform : $(PLATFORM)"
@echo "Host arch : $(HOST_ARCH)"
@echo "Target arch : $(TARGET_ARCH)"
sanity-checks:
./scripts/ci/ensure-staged-sass.sh
./scripts/ci/ensure-npm-dependencies-compatibility.sh
./scripts/ci/ensure-all-file-extensions-in-gitattributes.sh
clean:
rm -rf $(BUILD_DIRECTORY)
distclean: clean
rm -rf node_modules
rm -rf build
rm -rf dist
rm -rf generated
rm -rf $(BUILD_TEMPORARY_DIRECTORY)
.DEFAULT_GOAL = help

107
README.md
View File

@ -5,83 +5,120 @@
Etcher is a powerful OS image flasher built with web technologies to ensure
flashing an SDCard or USB drive is a pleasant and safe experience. It protects
you from accidentally writing to your hard-drives, ensures every byte of data
was written correctly, and much more. It can also directly flash Raspberry Pi devices that support [USB device boot mode](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#usb-device-boot-mode).
was written correctly and much more. It can also flash directly Raspberry Pi devices that support the usbboot protocol
[![Current Release](https://img.shields.io/github/release/balena-io/etcher.svg?style=flat-square)](https://balena.io/etcher)
[![License](https://img.shields.io/github/license/balena-io/etcher.svg?style=flat-square)](https://github.com/balena-io/etcher/blob/master/LICENSE)
[![Dependency status](https://img.shields.io/david/balena-io/etcher.svg?style=flat-square)](https://david-dm.org/balena-io/etcher)
[![Balena.io Forums](https://img.shields.io/discourse/https/forums.balena.io/topics.svg?style=flat-square&label=balena.io%20forums)](https://forums.balena.io/c/etcher)
---
***
[**Download**][etcher] | [**Support**][support] | [**Documentation**][user-documentation] | [**Contributing**][contributing] | [**Roadmap**][milestones]
[**Download**][etcher] | [**Support**][SUPPORT] | [**Documentation**][USER-DOCUMENTATION] | [**Contributing**][CONTRIBUTING] | [**Roadmap**][milestones]
## Supported Operating Systems
- Linux; most distros; Intel 64-bit.
- Windows 10 and later; Intel 64-bit.
- macOS 10.13 (High Sierra) and later; both Intel and Apple Silicon.
- Linux (most distros)
- macOS 10.10 (Yosemite) and later
- Microsoft Windows 7 and later
Note that Etcher will run on any platform officially supported by
[Electron][electron]. Read more in their
[documentation][electron-supported-platforms].
## Installers
Refer to the [downloads page][etcher] for the latest pre-made
installers for all supported operating systems.
## Packages
#### Debian and Ubuntu based Package Repository (GNU/Linux x86/x64)
Package for Debian and Ubuntu can be downloaded from the [Github release page](https://github.com/balena-io/etcher/releases/)
1. Add Etcher debian repository:
##### Install .deb file using apt
```sh
echo "deb https://deb.etcher.io stable etcher" | sudo tee /etc/apt/sources.list.d/balena-etcher.list
```
```sh
sudo apt install ./balena-etcher_******_amd64.deb
```
2. Trust Bintray.com's GPG key:
```sh
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 379CE192D401AB61
```
3. Update and install:
```sh
sudo apt-get update
sudo apt-get install balena-etcher-electron
```
##### Uninstall
```sh
sudo apt remove balena-etcher
```
```sh
sudo apt-get remove balena-etcher-electron
sudo rm /etc/apt/sources.list.d/balena-etcher.list
sudo apt-get update
```
#### Redhat (RHEL) and Fedora based Package Repository (GNU/Linux x86/x64)
#### Redhat (RHEL) and Fedora-based Package Repository (GNU/Linux x86/x64)
1. Add Etcher rpm repository:
##### Yum
```sh
sudo wget https://balena.io/etcher/static/etcher-rpm.repo -O /etc/yum.repos.d/etcher-rpm.repo
```
Package for Fedora-based and Redhat can be downloaded from the [Github release page](https://github.com/balena-io/etcher/releases/)
2. Update and install:
1. Install using yum
```sh
sudo yum install -y balena-etcher-electron
```
or
```sh
sudo dnf install -y balena-etcher-electron
```
##### Uninstall
```sh
sudo yum localinstall balena-etcher-***.x86_64.rpm
sudo yum remove -y balena-etcher-electron
sudo rm /etc/yum.repos.d/etcher-rpm.repo
sudo yum clean all
sudo yum makecache fast
```
or
```sh
sudo dnf remove -y balena-etcher-electron
sudo rm /etc/yum.repos.d/etcher-rpm.repo
sudo dnf clean all
sudo dnf makecache
```
#### Arch/Manjaro Linux (GNU/Linux x64)
Etcher is offered through the Arch User Repository and can be installed on both Manjaro and Arch systems. You can compile it from the source code in this repository using [`balena-etcher`](https://aur.archlinux.org/packages/balena-etcher/). The following example uses a common AUR helper to install the latest release:
#### Solus (GNU/Linux x64)
```sh
yay -S balena-etcher
sudo eopkg it etcher
```
##### Uninstall
```sh
yay -R balena-etcher
sudo eopkg rm etcher
```
#### WinGet (Windows)
#### Brew Cask (macOS)
This package is updated by [gh-action](https://github.com/vedantmgoyal2009/winget-releaser), and is kept up to date automatically.
Note that the Etcher Cask has to be updated manually to point to new versions,
so it might not refer to the latest version immediately after an Etcher
release.
```sh
winget install balenaEtcher #or Balena.Etcher
brew cask install balenaetcher
```
##### Uninstall
```sh
winget uninstall balenaEtcher
brew cask uninstall balenaetcher
```
#### Chocolatey (Windows)
@ -101,20 +138,20 @@ choco uninstall etcher
## Support
If you're having any problem, please [raise an issue][newissue] on GitHub, and
If you're having any problem, please [raise an issue][newissue] on GitHub and
the balena.io team will be happy to help.
## License
Etcher is free software and may be redistributed under the terms specified in
Etcher is free software, and may be redistributed under the terms specified in
the [license].
[etcher]: https://balena.io/etcher
[electron]: https://electronjs.org/
[electron-supported-platforms]: https://electronjs.org/docs/tutorial/support#supported-platforms
[support]: https://github.com/balena-io/etcher/blob/master/docs/SUPPORT.md
[contributing]: https://github.com/balena-io/etcher/blob/master/docs/CONTRIBUTING.md
[user-documentation]: https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
[SUPPORT]: https://github.com/balena-io/etcher/blob/master/SUPPORT.md
[CONTRIBUTING]: https://github.com/balena-io/etcher/blob/master/docs/CONTRIBUTING.md
[USER-DOCUMENTATION]: https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
[milestones]: https://github.com/balena-io/etcher/milestones
[newissue]: https://github.com/balena-io/etcher/issues/new
[license]: https://github.com/balena-io/etcher/blob/master/LICENSE

View File

@ -1,16 +1,9 @@
Getting help with BalenaEtcher
===============================
Getting help with Etcher
========================
There are various ways to get support for Etcher if you experience an issue or
have an idea you'd like to share with us.
Documentation
------
We have answers to a variety of frequently asked questions in the [user
documentation][documentation] and also in the [FAQs][faq] on the Etcher website.
Forums
------
@ -22,7 +15,7 @@ a look at the existing threads before opening a new one!
Make sure to mention the following information to help us provide better
support:
- The BalenaEtcher version you're running.
- The Etcher version you're running.
- The operating system you're running Etcher in.
@ -32,12 +25,10 @@ support:
GitHub
------
If you encounter an issue or have a suggestion, head on over to BalenaEtcher's [issue
If you encounter an issue or have a suggestion, head on over to Etcher's [issue
tracker][issues] and if there isn't a ticket covering it, [create
one][new-issue].
[discourse]: https://forums.balena.io/c/etcher
[issues]: https://github.com/balena-io/etcher/issues
[new-issue]: https://github.com/balena-io/etcher/issues/new
[documentation]: https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
[faq]: https://etcher.io

View File

@ -1,11 +0,0 @@
#!/bin/bash
# Link to the binary
# Must hardcode balenaEtcher directory; no variable available
ln -sf '/opt/balenaEtcher/${executable}' '/usr/bin/${executable}'
# SUID chrome-sandbox for Electron 5+
chmod 4755 '/opt/balenaEtcher/chrome-sandbox' || true
update-mime-database /usr/share/mime || true
update-desktop-database /usr/share/applications || true

27
afterPack.js Normal file
View File

@ -0,0 +1,27 @@
'use strict'
const cp = require('child_process')
const fs = require('fs')
const outdent = require('outdent')
const path = require('path')
exports.default = function(context) {
if (context.packager.platform.name !== 'linux') {
return
}
const scriptPath = path.join(context.appOutDir, context.packager.executableName)
const binPath = scriptPath + '.bin'
cp.execFileSync('mv', [scriptPath, binPath])
fs.writeFileSync(
scriptPath,
outdent`
#!/bin/bash
if [[ $EUID -ne 0 ]] || [[ $ELECTRON_RUN_AS_NODE ]]; then
"\${BASH_SOURCE%/*}"/${context.packager.executableName}.bin "$@"
else
"\${BASH_SOURCE%/*}"/${context.packager.executableName}.bin "$@" --no-sandbox
fi
`
)
cp.execFileSync('chmod', ['+x', scriptPath])
}

22
afterSignHook.js Normal file
View File

@ -0,0 +1,22 @@
'use strict'
const { notarize } = require('electron-notarize')
async function main(context) {
const { electronPlatformName, appOutDir } = context
if (electronPlatformName !== 'darwin') {
return
}
const appName = context.packager.appInfo.productFilename
const appleId = 'accounts+apple@balena.io'
await notarize({
appBundleId: 'io.balena.etcher',
appPath: `${appOutDir}/${appName}.app`,
appleId,
appleIdPassword: `@keychain:Application Loader: ${appleId}`
})
}
exports.default = main

Binary file not shown.

35
binding.gyp Normal file
View File

@ -0,0 +1,35 @@
{
"targets": [
{
"target_name": "elevator",
"include_dirs" : [
"src",
"<!(node -e \"require('nan')\")"
],
'conditions': [
[ 'OS=="win"', {
"sources": [
"src/utils/v8utils.cpp",
"src/os/win32/elevate.cpp",
"src/elevator_init.cpp",
],
"libraries": [
"-lShell32.lib",
],
} ],
[ 'OS=="mac"', {
"xcode_settings": {
"OTHER_CPLUSPLUSFLAGS": [
"-stdlib=libc++"
],
"OTHER_LDFLAGS": [
"-stdlib=libc++"
]
}
} ]
],
}
],
}

4
dev-app-update.yml Normal file
View File

@ -0,0 +1,4 @@
owner: balena-io
repo: etcher
provider: github
updaterCacheDirName: balena-etcher-updater

9
dictionary.txt Normal file
View File

@ -0,0 +1,9 @@
boolen->boolean
aknowledge->acknowledge
seleted->selected
reming->remind
locl->local
subsribe->subscribe
unsubsribe->unsubscribe
calcluate->calculate
dictionaty->dictionary

View File

@ -12,9 +12,12 @@ technologies used in Etcher that you should become familiar with:
- [Electron][electron]
- [NodeJS][nodejs]
- [AngularJS][angularjs]
- [Redux][redux]
- [ImmutableJS][immutablejs]
- [Bootstrap][bootstrap]
- [Sass][sass]
- [Flexbox Grid][flexbox-grid]
- [Mocha][mocha]
- [JSDoc][jsdoc]
@ -63,8 +66,11 @@ be documented instead!
[gui-dir]: https://github.com/balena-io/etcher/tree/master/lib/gui
[electron]: http://electron.atom.io
[nodejs]: https://nodejs.org
[angularjs]: https://angularjs.org
[redux]: http://redux.js.org
[immutablejs]: http://facebook.github.io/immutable-js/
[bootstrap]: http://getbootstrap.com
[sass]: http://sass-lang.com
[flexbox-grid]: http://flexboxgrid.com
[mocha]: http://mochajs.org
[jsdoc]: http://usejsdoc.org

View File

@ -12,29 +12,67 @@ over the commit history.
- Be able to automatically reference relevant changes from a dependency
upgrade.
The guidelines are inspired by the [AngularJS git commit
guidelines][angular-commit-guidelines].
Commit structure
----------------
Each commit message needs to specify the semver-type. Which can be `patch|minor|major`.
See the [Semantic Versioning][semver] specification for a more detailed explanation of the meaning of these types.
See balena commit guidelines for more info about the whole commit structure.
Each commit message consists of a header, a body and a footer. The header has a
special format that includes a type, a scope and a subject.
```
<semver-type>: <subject>
```
or
```
<subject>
<type>(<scope>): <subject>
<BLANK LINE>
<details>
<body>
<BLANK LINE>
Change-Type: <semver-type>
<footer>
```
The subject should not contain more than 70 characters, including the type and
scope, and the body should be wrapped at 72 characters.
Type
----
Must be one of the following:
- `feat`: A new feature.
- `fix`: A bug fix.
- `minifix`: A minimal fix that doesn't warrant an entry in the CHANGELOG.
- `docs`: Documentation only changes.
- `style`: Changes that do not affect the meaning of the code (white-space,
formatting, missing semi-colons, JSDoc annotations, comments, etc).
- `refactor`: A code change that neither fixes a bug nor adds a feature.
- `perf`: A code change that improves performance.
- `test`: Adding missing tests.
- `chore`: Changes to the build process or auxiliary tools and libraries.
- `upgrade`: A version upgrade of a project dependency.
Scope
-----
The scope is required for types that make sense, such as `feat`, `fix`,
`test`, etc. Certain commit types, such as `chore` might not have a clearly
defined scope, in which case its better to omit it.
Subject
-------
The subject should contain a short description of the change:
- Use the imperative, present tense.
- Don't capitalize the first letter.
- No dot (.) at the end.
Footer
------
The footer contains extra information about the commit, such as tags.
**Breaking Changes** should start with the word BREAKING CHANGE: with a space
or two newlines. The rest of the commit message is then used for this.
Tags
----
@ -83,4 +121,125 @@ Closes: https://github.com/balena-io/etcher/issues/XXX
Fixes: https://github.com/balena-io/etcher/issues/XXX
```
### `Change-Type: <type>`
This tag is used to determine the change type that a commit introduces. The
following types are supported:
- `major`
- `minor`
- `patch`
This tag can be omitted for commits that don't change the application from the
user's point of view, such as for refactoring commits.
Examples:
```
Change-Type: major
Change-Type: minor
Change-Type: patch
```
See the [Semantic Versioning][semver] specification for a more detailed
explanation of the meaning of these types.
### `Changelog-Entry: <message>`
This tag is used to describe the changes introduced by the commit in a more
human style that would fit the `CHANGELOG.md` better.
If the commit type is either `fix` or `feat`, the commit will take part in the
CHANGELOG. If this tag is not defined, then the commit subject will be used
instead.
You explicitly can use this tag to make a commit whose type is not `fix` nor
`feat` appear in the `CHANGELOG.md`.
Since whatever your write here will be shown *as it is* in the `CHANGELOG.md`,
take some time to write a decent entry. Consider the following guidelines:
- Use the imperative, present tense.
- Capitalize the first letter.
There is no fixed length limit for the contents of this tag, but always strive
to make as short as possible without compromising its quality.
Examples:
```
Changelog-Entry: Fix EPERM errors when flashing to a GPT drive.
```
Complete examples
-----------------
```
fix(GUI): ignore extensions before the first non-compressed extension
Currently, we extract all the extensions from an image path and report back
that the image is invalid if *any* of the extensions is not valid , however
this can cause trouble with images including information between dots that are
not strictly extensions, for example:
elementaryos-0.3.2-stable-i386.20151209.iso
Etcher will consider `20151209` to be an invalid extension and therefore
will prevent such image from being selected at all.
As a way to allow these corner cases but still make use of our enforced check
controls, the validation routine now only consider extensions starting from the
first non compressed extension.
Change-Type: patch
Changelog-Entry: Don't interpret image file name information between dots as image extensions.
Fixes: https://github.com/balena-io/etcher/issues/492
```
***
```
upgrade: etcher-image-write to v5.0.2
This version contains a fix to an `EPERM` issue happening to some Windows user,
triggered by the `write` system call during the first ~5% of a flash given that
the operating system still thinks the drive has a file system.
Change-Type: patch
Changelog-Entry: Upgrade `etcher-image-write` to v5.0.2.
Link: https://github.com/balena-io-modules/etcher-image-write/blob/master/CHANGELOG.md#502---2016-06-27
Fixes: https://github.com/balena-io/etcher/issues/531
```
***
```
feat(GUI): implement update notifier functionality
Auto-update functionality is not ready for usage. As a workaround to
prevent users staying with older versions, we now check for updates at
startup, and if the user is not running the latest version, we present a
modal informing the user of the availiblity of a new version, and
provide a call to action to open the Etcher website in his web browser.
Extra features:
- The user can skip the update, and tell the program to delay the
notification for 7 days.
Misc changes:
- Center modal with flexbox, to allow more flexibility on the modal height.
interacting with the S3 server.
- Implement `ManifestBindService`, which now serves as a backend for the
`manifest-bind` directive to allow the directive's functionality to be
re-used by other services.
- Namespace checkbox styles that are specific to the settings page.
Change-Type: minor
Changelog-Entry: Check for updates and show a modal prompting the user to download the latest version.
Closes: https://github.com/balena-io/etcher/issues/396
```
[angular-commit-guidelines]: https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#commit
[semver]: http://semver.org

View File

@ -17,11 +17,11 @@ Developing
#### Common
- [NodeJS](https://nodejs.org) (at least v16.11)
- [Python 3](https://www.python.org)
- [NodeJS](https://nodejs.org) (at least v6.11)
- [Python 2.7](https://www.python.org)
- [jq](https://stedolan.github.io/jq/)
- [curl](https://curl.haxx.se/)
- [npm](https://www.npmjs.com/)
- [npm](https://www.npmjs.com/) (version 6.7)
```sh
pip install -r requirements.txt
@ -33,16 +33,16 @@ You might need to run this with `sudo` or administrator permissions.
- [NSIS v2.51](http://nsis.sourceforge.net/Main_Page) (v3.x won't work)
- Either one of the following:
- [Visual C++ 2019 Build Tools](https://visualstudio.microsoft.com/vs/features/cplusplus/) containing standalone compilers, libraries and scripts
- The [windows-build-tools](https://github.com/felixrieseberg/windows-build-tools#windows-build-tools) should be installed along with NodeJS
- [Visual Studio Community 2019](https://visualstudio.microsoft.com/vs/) (free) (other editions, like Professional and Enterprise, should work too)
**NOTE:** Visual Studio doesn't install C++ by default. You have to rerun the
- [Visual C++ 2015 Build Tools](http://landinghub.visualstudio.com/visual-cpp-build-tools) containing standalone compilers, libraries and scripts
- Install the [windows-build-tools](https://github.com/felixrieseberg/windows-build-tools) via npm with `npm install --global windows-build-tools`
- [Visual Studio Community 2015](https://www.microsoft.com/en-us/download/details.aspx?id=48146) (free) (other editions, like Professional and Enterprise, should work too)
**NOTE:** Visual Studio 2015 doesn't install C++ by default. You have to rerun the
setup, select "Modify" and then check `Visual C++ -> Common Tools for Visual
C++` (see http://stackoverflow.com/a/31955339)
C++ 2015` (see http://stackoverflow.com/a/31955339)
- [MinGW](http://www.mingw.org)
You might need to `npm config set msvs_version 2019` for node-gyp to correctly detect
the version of Visual Studio you're using (in this example VS2019).
You might need to `npm config set msvs_version 2015` for node-gyp to correctly detect
the version of Visual Studio you're using (in this example VS2015).
The following MinGW packages are required:
@ -61,7 +61,7 @@ as well.
#### Linux
- `libudev-dev` for libusb (for example install with `sudo apt install libudev-dev`, or on fedora `systemd-devel` contains the required package)
- `libudev-dev` for libusb (install with `sudo apt install libudev-dev` for example)
### Cloning the project
@ -70,12 +70,29 @@ git clone --recursive https://github.com/balena-io/etcher
cd etcher
```
### Installing npm dependencies
**NOTE:** Please make use of the following command to install npm dependencies rather
than simply running `npm install` given that we need to do extra configuration
to make sure native dependencies are correctly compiled for Electron, otherwise
the application might not run successfully.
If you're on Windows, **run the command from the _Developer Command Prompt for
VS2015_**, to ensure all Visual Studio command utilities are available in the
`%PATH%`.
```sh
make electron-develop
```
### Running the application
#### GUI
```sh
# Build and start application
# Build the GUI
make webpack
# Start Electron
npm start
```
@ -102,6 +119,11 @@ systems as they can before sending a pull request.
*The test suite is run automatically by CI servers when you send a pull
request.*
We also rely on various `make` targets to perform some common tasks:
- `make lint`: Run the linter.
- `make sass`: Compile SCSS files.
We make use of [EditorConfig] to communicate indentation, line endings and
other text editing default. We encourage you to install the relevant plugin in
your text editor of choice to avoid having to fix any issues during the review
@ -110,7 +132,19 @@ process.
Updating a dependency
---------------------
- Install new version of dependency using npm
Given we use [npm shrinkwrap][shrinkwrap], we have to take extra steps to make
sure the `npm-shrinkwrap.json` file gets updated correctly when we update a
dependency.
Use the following steps to ensure everything goes flawlessly:
- Run `make electron-develop` to ensure you don't have extraneous dependencies
you might have brought during development, or you are running older
dependencies because you come from another branch or reference.
- Install the new version of the dependency. For example: `npm install --save
<package>@<version>`. This will update the `npm-shrinkwrap.json` file.
- Commit *both* `package.json` and `npm-shrinkwrap.json`.
Diffing Binaries

View File

@ -1,25 +1,24 @@
# Maintaining Etcher
Maintaining Etcher
==================
This document is meant to serve as a guide for maintainers to perform common tasks.
## Releasing
Releasing
---------
### Release Types
- **draft**: A continues snapshot of current master, made by the CI services
- **pre-release** (default): A continues snapshot of current master, made by the CI services
- **release**: Full releases
- **snapshot** (default): A continues snapshot of current master, made by the CI services
- **production**: Full releases
Draft release is created from each PR, tagged with the branch name.
All merged PR will generate a new tag/version as a _pre-release_.
Mark the pre-release as final when it is necessary, then distribute the packages in alternative channels as necessary.
### Flight Plan
#### Preparation
- [Prepare the new version](#preparing-a-new-version)
- [Generate build artifacts](#generating-binaries) (binaries, archives, etc.)
- [Draft a release on GitHub](https://github.com/balena-io/etcher/releases)
- Upload build artifacts to GitHub release draft
- Upload build artifacts to GitHub release draft
#### Testing
@ -32,10 +31,11 @@ Mark the pre-release as final when it is necessary, then distribute the packages
- [Post release note to forums](https://forums.balena.io/c/etcher)
- [Submit Windows binaries to Symantec for whitelisting](#submitting-binaries-to-symantec)
- [Update the website](https://github.com/balena-io/etcher-homepage)
- Wait 2-3 hours for analytics (Sentry) to trickle in and check for elevated error rates, or regressions
- Wait 2-3 hours for analytics (Sentry, Mixpanel) to trickle in and check for elevated error rates, or regressions
- If regressions arise; pull the release, and release a patched version, else:
- [Upload deb & rpm packages to Cloudfront](#uploading-packages-to-cloudfront)
- Post changelog with `#release-notes` tag on internal chat
- [Upload deb & rpm packages to Bintray](#uploading-packages-to-bintray)
- [Upload build artifacts to Amazon S3](#uploading-binaries-to-amazon-s3)
- Post changelog with `#release-notes` tag on Flowdock
- If this release packs noteworthy major changes:
- Write a blog post about it, and / or
- Write about it to the Etcher mailing list
@ -48,28 +48,95 @@ Make sure to set the analytics tokens when generating production release binarie
```bash
export ANALYTICS_SENTRY_TOKEN="xxxxxx"
export ANALYTICS_MIXPANEL_TOKEN="xxxxxx"
```
#### Linux
##### Clean dist folder
Delete `.webpack` and `out/`.
**NOTE:** Make sure to adjust the path as necessary (here the Etcher repository has been cloned to `/home/$USER/code/etcher`)
```bash
./scripts/build/docker/run-command.sh -r x64 -s . -c "make distclean"
```
##### Generating artifacts
The artifacts are generated by the CI and published as draft-release or pre-release.
Etcher is built with electron-forge. Run:
```bash
# x64
```
npm run make
# Build Debian packages
./scripts/build/docker/run-command.sh -r x64 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-debian"
# Build RPM packages
./scripts/build/docker/run-command.sh -r x64 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-redhat"
# Build AppImages
./scripts/build/docker/run-command.sh -r x64 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-appimage"
# x86
# Build Debian packages
./scripts/build/docker/run-command.sh -r x86 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-debian"
# Build RPM packages
./scripts/build/docker/run-command.sh -r x86 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-redhat"
# Build AppImages
./scripts/build/docker/run-command.sh -r x86 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-appimage"
```
Our CI will appropriately sign artifacts for macOS and some Windows targets.
#### Mac OS
### Uploading packages to Cloudfront
**ATTENTION:** For production releases you'll need the code-signing key,
and set `CSC_NAME` to generate signed binaries on Mac OS.
Log in to cloudfront and upload the `rpm` and `deb` files.
```bash
make electron-develop
# Build the zip
make RELEASE_TYPE=production electron-installer-app-zip
# Build the dmg
make RELEASE_TYPE=production electron-installer-dmg
```
#### Windows
**ATTENTION:** For production releases you'll need the code-signing key,
and set `CSC_LINK`, and `CSC_KEY_PASSWORD` to generate signed binaries on Windows.
**NOTE:**
- Keep in mind to also generate artifacts for x86, with `TARGET_ARCH=x86`.
```bash
make electron-develop
# Build the Portable version
make RELEASE_TYPE=production electron-installer-portable
# Build the Installer
make RELEASE_TYPE=production electron-installer-nsis
```
### Uploading packages to Bintray
```bash
export BINTRAY_USER="username@account"
export BINTRAY_API_KEY="youruserapikey"
```
```bash
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "debian" -y "debian" -r "x64" -f "dist/etcher-electron_1.2.1_amd64.deb"
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "debian" -y "debian" -r "x86" -f "dist/etcher-electron_1.2.1_i386.deb"
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "redhat" -y "redhat" -r "x64" -f "dist/etcher-electron-1.2.1.x86_64.rpm"
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "redhat" -y "redhat" -r "x86" -f "dist/etcher-electron-1.2.1.i686.rpm"
```
### Uploading binaries to Amazon S3
```bash
export S3_KEY="..."
```
```bash
./scripts/publish/aws-s3.sh -b "balena-production-downloads" -v "1.2.1" -p "etcher" -f "dist/<filename>"
```
### Dealing with a Problematic Release
@ -94,6 +161,7 @@ aws s3api delete-object --bucket <bucket name> --key <file name>
The Bintray dashboard provides an easy way to delete a version's files.
### Submitting binaries to Symantec
- [Report a Suspected Erroneous Detection](https://submit.symantec.com/false_positive/standard/)

View File

@ -1,19 +1,22 @@
# Manual Testing
Manual Testing
==============
This document describes a high-level script of manual tests to check for. We
should aim to replace items on this list with automated Spectron test cases.
## Image Selection
Image Selection
---------------
- [ ] Cancel image selection dialog
- [ ] Select an unbootable image (without a partition table), and expect a
sensible warning
sensible warning
- [ ] Attempt to select a ZIP archive with more than one image
- [ ] Attempt to select a tar archive (with any compression method)
- [ ] Change image selection
- [ ] Select a Windows image, and expect a sensible warning
## Drive Selection
Drive Selection
---------------
- [ ] Open the drive selection modal
- [ ] Switch drive selection
@ -22,15 +25,16 @@ should aim to replace items on this list with automated Spectron test cases.
- [ ] Insert a locked SD Card and expect a warning
- [ ] Insert a too small drive and expect a warning
- [ ] Put an image into a drive and attempt to flash the image to the drive
that contains it
that contains it
- [ ] Attempt to flash a compressed image (for which we can get the
uncompressed size) into a drive that is big enough to hold the compressed
image, but not big enough to hold the uncompressed version
uncompressed size) into a drive that is big enough to hold the compressed
image, but not big enough to hold the uncompressed version
- [ ] Enable "Unsafe Mode" and attempt to select a system drive
- [ ] Enable "Unsafe Mode", and if there is only one system drive (and no
removable ones), don't expect autoselection
removable ones), don't expect autoselection
## Image Support
Image Support
-------------
Run the following tests with and without validation enabled:
@ -47,17 +51,18 @@ Run the following tests with and without validation enabled:
- [ ] Flash an archive image containing a blockmap file
- [ ] Flash an archive image containing a manifest metadata file
## Flashing Process
Flashing Process
----------------
- [ ] Unplug the drive during flash or validation
- [ ] Click "Flash", cancel elevation dialog, and click "Flash" again
- [ ] Start flashing an image, try to close Etcher, cancel the application
close warning dialog, and check that Etcher continues to flash the image
close warning dialog, and check that Etcher continues to flash the image
### Child Writer
- [ ] Kill the child writer process (i.e. with `SIGINT` or `SIGKILL`), and
check that the UI reacts appropriately
check that the UI reacts appropriately
- [ ] Close the application while flashing using the window manager close icon
- [ ] Close the application while flashing using the OS keyboard shortcut
- [ ] Close the application from the terminal using Ctrl-C while flashing
@ -67,10 +72,11 @@ In all these cases, the child writer process should not remain alive. Note that
in some systems you need to open your process monitor tool of choice with extra
permissions to see the elevated child writer process.
## GUI
GUI
----
- [ ] Close application from the terminal using Ctrl-C while the application is
idle
idle
- [ ] Click footer links that take you to an external website
- [ ] Attempt to change image or drive selection while flashing
- [ ] Go to the settings page while flashing and come back
@ -79,20 +85,31 @@ permissions to see the elevated child writer process.
- [ ] Minimize the application
- [ ] Start the application given no internet connection
## Success Banner
Success Banner
--------------
- [ ] Click an external link on the success banner (with and without internet
connection)
connection)
## Elevation Prompt
Elevation Prompt
----------------
- [ ] Flash an image as `root`/administrator
- [ ] Reject elevation prompt
- [ ] Put incorrect elevation prompt password
- [ ] Unplug the drive during elevation
## Unmounting
Unmounting
----------
- [ ] Disable unmounting and flash an image
- [ ] Flash an image with a file system that is readable by the host OS, and
check that is unmounted correctly
check that is unmounted correctly
Analytics
---------
- [ ] Disable analytics, open DevTools Network pane or a packet sniffer, and
check that no request is sent
- [ ] **Disable analytics, refresh application from DevTools (using Cmd-R or
F5), and check that initial events are not sent to Mixpanel**

View File

@ -7,9 +7,44 @@ systems.
Release Types
-------------
Etcher supports **pre-release** and **final** release types as does Github. Each is
published to Github releases.
The release version is generated automatically from the commit messasges.
Etcher supports **production** and **snapshot** release types. Each is
published to a different S3 bucket, and production release types are code
signed, while snapshot release types aren't and include a short git commit-hash
as a build number. For example, `1.0.0-beta.19` is a production release type,
while `1.0.0-beta.19+531ab82` is a snapshot release type.
In terms of comparison: `1.0.0-beta.19` (production) < `1.0.0-beta.19+531ab82`
(snapshot) < `1.0.0-rc.1` (production) < `1.0.0-rc.1+7fde24a` (snapshot) <
`1.0.0` (production) < `1.0.0+2201e5f` (snapshot). Keep in mind that if you're
running a production release type, you'll only be prompted to update to
production release types, and if you're running a snapshot release type, you'll
only be prompted to update to other snapshot release types.
The build system creates (and publishes) snapshot release types by default, but
you can build a specific release type by setting the `RELEASE_TYPE` make
variable. For example:
```sh
make <target> RELEASE_TYPE=snapshot
make <target> RELEASE_TYPE=production
```
We can control the version range a specific Etcher version will consider when
showing the update notification dialog by tweaking the `updates.semverRange`
property of `package.json`.
Update Channels
---------------
Etcher has a setting to include the unstable update channel. If this option is
set, Etcher will consider both stable and unstable versions when showing the
update notifier dialog. Unstable versions are the ones that contain a `beta`
pre-release tag. For example:
- Production unstable version: `1.4.0-beta.1`
- Snapshot unstable version: `1.4.0-beta.1+7fde24a`
- Production stable version: `1.4.0`
- Snapshot stable version: `1.4.0+7fde24a`
Signing
-------
@ -36,24 +71,65 @@ employee by asking for it from the relevant people.
Packaging
---------
Run the following command on each platform:
The resulting installers will be saved to `dist/out`.
Run the following commands:
### OS X
```sh
npm run make
make electron-installer-dmg
make electron-installer-app-zip
```
This will produce all targets (eg. zip, dmg) specified in forge.config.ts for the
host platform and architecture.
### GNU/Linux
The resulting artifacts can be found in `out/make`.
```sh
make electron-installer-appimage
make electron-installer-debian
```
### Windows
Publishing to Cloudfront
```sh
make electron-installer-zip
make electron-installer-nsis
```
Publishing to Bintray
---------------------
We publish GNU/Linux Debian packages to [Cloudfront][cloudfront].
We publish GNU/Linux Debian packages to [Bintray][bintray].
Log in to cloudfront and upload the `rpm` and `deb` files.
Make sure you set the following environment variables:
- `BINTRAY_USER`
- `BINTRAY_API_KEY`
Run the following command:
```sh
make publish-bintray-debian
```
Publishing to S3
----------------
- [AWS CLI][aws-cli]
Make sure you have the [AWS CLI tool][aws-cli] installed and configured to
access balena.io's production or snapshot S3 bucket.
Run the following command to publish all files for the current combination of
_platform_ and _arch_ (building them if necessary):
```sh
make publish-aws-s3
```
Also add links to each AWS S3 file in [GitHub Releases][github-releases]. See
[`v1.0.0-beta.17`](https://github.com/balena-io/etcher/releases/tag/v1.0.0-beta.17)
as an example.
Publishing to Homebrew Cask
---------------------------
@ -71,12 +147,8 @@ Post messages to the [Etcher forum][balena-forum-etcher] announcing the new vers
of Etcher, and including the relevant section of the Changelog.
[aws-cli]: https://aws.amazon.com/cli
[cloudfront]: https://cloudfront.com
[bintray]: https://bintray.com
[etcher-cask-file]: https://github.com/caskroom/homebrew-cask/blob/master/Casks/balenaetcher.rb
[homebrew-cask]: https://github.com/caskroom/homebrew-cask
[balena-forum-etcher]: https://forums.balena.io/c/etcher
[github-releases]: https://github.com/balena-io/etcher/releases
Updating EFP / Success-Banner
-----------------------------
Etcher Featured Project is automatically run based on an algorithm which promoted projects from the balena marketplace which have been contributed by the community, the algorithm prioritises projects which give uses the best experience. Editing both EFP and the Etcher Success-Banner can only be done by someone from balena, instruction are on the [Etcher-EFP repo (private)](https://github.com/balena-io/etcher-efp)

View File

@ -3,11 +3,6 @@ Etcher User Documentation
This document contains how-tos and FAQs oriented to Etcher users.
Config
------
Etcher's configuration is saved to the `config.json` file in the apps folder.
Not all the options are surfaced to the UI. You may edit this file to tweak settings even before launching the app.
Why is my drive not bootable?
-----------------------------
@ -122,6 +117,7 @@ run Etcher on a GNU/Linux system.
- xrender
- xtst
- xscrnsaver
- gconf-2.0
- gmodule-2.0
- nss
@ -163,18 +159,6 @@ pre-installed in all modern Windows versions.
- Run `clean`. This command will completely clean your drive by erasing any
existent filesystem.
- Run `create partition primary`. This command will create a new partition.
- Run `active`. This command will active the partition.
- Run `list partition`. This command will show available partition.
- Run `select partition N`, where `N` corresponds to the id of the newly available partition.
- Run `format override quick`. This command will format the partition. You can choose a specific formatting by adding `FS=xx` where `xx` could be `NTFS or FAT or FAT32` after `format`. Example : `format FS=NTFS override quick`
- Run `exit` to quit diskpart.
### OS X
@ -182,7 +166,7 @@ Run the following command in `Terminal.app`, replacing `N` by the corresponding
disk number, which you can find by running `diskutil list`:
```sh
diskutil eraseDisk FAT32 UNTITLED MBRFormat /dev/diskN
diskutil eraseDisk free UNTITLED /dev/diskN
```
### GNU/Linux
@ -222,5 +206,3 @@ macOS 10.10 (Yosemite) and newer versions][electron-supported-platforms].
[unetbootin]: https://unetbootin.github.io
[windows-iot-dashboard]: https://developer.microsoft.com/en-us/windows/iot/downloads
[woeusb]: https://github.com/slacka/WoeUSB
See [PUBLISHING](/docs/PUBLISHING.md) for more details about release types.

111
electron-builder.yml Normal file
View File

@ -0,0 +1,111 @@
appId: io.balena.etcher
copyright: Copyright 2016-2019 Balena Ltd
productName: balenaEtcher
npmRebuild: true
nodeGypRebuild: true
publish: null
afterPack: "./afterPack.js"
files:
- build/Release/elevator.node
- generated
- lib/shared/catalina-sudo/sudo-askpass.osascript.js
- lib/gui/app/index.html
- lib/gui/css/*.css
- lib/gui/css/fonts/*.woff2
- lib/gui/assets/*.svg
- assets/icon.png
- "!node_modules/**/**"
- "node_modules/**/*.js"
- "node_modules/**/*.json"
- "node_modules/**/*.node"
- "node_modules/**/*.dll"
- node_modules/node-raspberrypi-usbboot/blobs/**
- node_modules/flexboxgrid/dist/flexboxgrid.css
- node_modules/roboto-fontface/fonts/roboto/Roboto-Thin.woff
- node_modules/roboto-fontface/fonts/roboto/Roboto-Light.woff
- node_modules/roboto-fontface/fonts/roboto/Roboto-Regular.woff
- node_modules/roboto-fontface/fonts/roboto/Roboto-Medium.woff
- node_modules/roboto-fontface/fonts/roboto/Roboto-Bold.woff
- node_modules/bootstrap-sass/assets/fonts/bootstrap/glyphicons-halflings-regular.woff2
mac:
asar: false
icon: assets/icon.icns
category: public.app-category.developer-tools
hardenedRuntime: true
entitlements: "entitlements.mac.plist"
entitlementsInherit: "entitlements.mac.plist"
dmg:
background: assets/dmg/background.tiff
icon: assets/icon.icns
iconSize: 110
contents:
- x: 140
y: 225
- x: 415
y: 225
type: link
path: /Applications
window:
width: 540
height: 405
win:
icon: assets/icon.ico
nsis:
oneClick: true
runAfterFinish: true
installerIcon: assets/icon.ico
uninstallerIcon: assets/icon.ico
deleteAppDataOnUninstall: true
license: LICENSE
artifactName: "${productName}-Setup-${version}.${ext}"
portable:
artifactName: "${productName}-Portable-${version}.${ext}"
requestExecutionLevel: user
linux:
category: Utility
packageCategory: utils
executableName: balena-etcher-electron
synopsis: balenaEtcher is a powerful OS image flasher built with web technologies to ensure flashing an SDCard or USB drive is a pleasant and safe experience. It protects you from accidentally writing to your hard-drives, ensures every byte of data was written correctly and much more.
icon: assets/iconset
deb:
priority: optional
depends:
- gconf2
- gconf-service
- libappindicator1
- libasound2
- libatk1.0-0
- libc6
- libcairo2
- libcups2
- libdbus-1-3
- libexpat1
- libfontconfig1
- libfreetype6
- libgcc1
- libgconf-2-4
- libgdk-pixbuf2.0-0
- libglib2.0-0
- libgtk-3-0
- liblzma5
- libnotify4
- libnspr4
- libnss3
- libpango1.0-0
- libstdc++6
- libx11-6
- libxcomposite1
- libxcursor1
- libxdamage1
- libxext6
- libxfixes3
- libxi6
- libxrandr2
- libxrender1
- libxss1
- libxtst6
- polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1
rpm:
depends:
- lsb
- libXScrnSaver

View File

@ -14,11 +14,5 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.get-task-allow</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
</dict>
</plist>

View File

@ -1,159 +0,0 @@
import type { ForgeConfig } from '@electron-forge/shared-types';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { MakerDMG } from '@electron-forge/maker-dmg';
// import { MakerAppImage } from '@reforged/maker-appimage';
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
import { WebpackPlugin } from '@electron-forge/plugin-webpack';
import { exec } from 'child_process';
import { mainConfig, rendererConfig } from './webpack.config';
import * as sidecar from './forge.sidecar';
import { hostDependencies, productDescription } from './package.json';
const osxSigningConfig: any = {};
let winSigningConfig: any = {};
if (process.env.NODE_ENV === 'production') {
osxSigningConfig.osxNotarize = {
tool: 'notarytool',
appleId: process.env.XCODE_APP_LOADER_EMAIL,
appleIdPassword: process.env.XCODE_APP_LOADER_PASSWORD,
teamId: process.env.XCODE_APP_LOADER_TEAM_ID,
};
winSigningConfig = {
signWithParams: `-sha1 ${process.env.SM_CODE_SIGNING_CERT_SHA1_HASH} -tr ${process.env.TIMESTAMP_SERVER} -td sha256 -fd sha256 -d balena-etcher`,
};
}
const config: ForgeConfig = {
packagerConfig: {
asar: true,
icon: './assets/icon',
executableName:
process.platform === 'linux' ? 'balena-etcher' : 'balenaEtcher',
appBundleId: 'io.balena.etcher',
appCategoryType: 'public.app-category.developer-tools',
appCopyright: 'Copyright 2016-2023 Balena Ltd',
darwinDarkModeSupport: true,
protocols: [{ name: 'etcher', schemes: ['etcher'] }],
extraResource: [
'lib/shared/sudo/sudo-askpass.osascript-zh.js',
'lib/shared/sudo/sudo-askpass.osascript-en.js',
],
osxSign: {
optionsForFile: () => ({
entitlements: './entitlements.mac.plist',
hardenedRuntime: true,
}),
},
...osxSigningConfig,
},
rebuildConfig: {
onlyModules: [], // prevent rebuilding *any* native modules as they won't be used by electron but by the sidecar
},
makers: [
new MakerZIP(),
new MakerSquirrel({
setupIcon: 'assets/icon.ico',
loadingGif: 'assets/icon.png',
...winSigningConfig,
}),
new MakerDMG({
background: './assets/dmg/background.tiff',
icon: './assets/icon.icns',
iconSize: 110,
contents: ((opts: { appPath: string }) => {
return [
{ x: 140, y: 250, type: 'file', path: opts.appPath },
{ x: 415, y: 250, type: 'link', path: '/Applications' },
];
}) as any, // type of MakerDMGConfig omits `appPath`
additionalDMGOptions: {
window: {
size: {
width: 540,
height: 425,
},
position: {
x: 400,
y: 500,
},
},
},
}),
// new MakerAppImage({
// options: {
// icon: './assets/icon.png',
// categories: ['Utility'],
// },
// }),
new MakerRpm({
options: {
icon: './assets/icon.png',
categories: ['Utility'],
productDescription,
requires: ['util-linux'],
},
}),
new MakerDeb({
options: {
icon: './assets/icon.png',
categories: ['Utility'],
section: 'utils',
priority: 'optional',
productDescription,
scripts: {
postinst: './after-install.tpl',
},
depends: hostDependencies['debian'],
},
}),
],
plugins: [
new AutoUnpackNativesPlugin({}),
new WebpackPlugin({
mainConfig,
renderer: {
config: rendererConfig,
nodeIntegration: true,
entryPoints: [
{
html: './lib/gui/app/index.html',
js: './lib/gui/app/renderer.ts',
name: 'main_window',
preload: {
js: './lib/gui/app/preload.ts',
},
},
],
},
}),
new sidecar.SidecarPlugin(),
],
hooks: {
postPackage: async (_forgeConfig, options) => {
if (options.platform === 'linux') {
// symlink the etcher binary from balena-etcher to balenaEtcher to ensure compatibility with the wdio suite and the old name
await new Promise<void>((resolve, reject) => {
exec(
`ln -s "${options.outputPaths}/balena-etcher" "${options.outputPaths}/balenaEtcher"`,
(err) => {
if (err) {
reject(err);
} else {
resolve();
}
},
);
});
}
},
},
};
export default config;

View File

@ -1,168 +0,0 @@
import { PluginBase } from '@electron-forge/plugin-base';
import type {
ForgeMultiHookMap,
ResolvedForgeConfig,
} from '@electron-forge/shared-types';
import { WebpackPlugin } from '@electron-forge/plugin-webpack';
import { DefinePlugin } from 'webpack';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import debug from 'debug';
const log = debug('sidecar');
function isStartScrpt(): boolean {
return process.env.npm_lifecycle_event === 'start';
}
function addWebpackDefine(
config: ResolvedForgeConfig,
defineName: string,
binDir: string,
binName: string,
): ResolvedForgeConfig {
config.plugins.forEach((plugin) => {
if (plugin.name !== 'webpack' || !(plugin instanceof WebpackPlugin)) {
return;
}
const { mainConfig } = plugin.config as any;
if (mainConfig.plugins == null) {
mainConfig.plugins = [];
}
const value = isStartScrpt()
? // on `npm start`, point directly to the binary
path.resolve(binDir, binName)
: // otherwise point relative to the resources folder of the bundled app
binName;
log(`define '${defineName}'='${value}'`);
mainConfig.plugins.push(
new DefinePlugin({
// expose path to helper via this webpack define
[defineName]: JSON.stringify(value),
}),
);
});
return config;
}
function build(
sourcesDir: string,
buildForArchs: string,
binDir: string,
binName: string,
) {
const commands: Array<[string, string[], object?]> = [
['tsc', ['--project', 'tsconfig.sidecar.json', '--outDir', sourcesDir]],
];
buildForArchs.split(',').forEach((arch) => {
const binPath = isStartScrpt()
? // on `npm start`, we don't know the arch we're building for at the time we're
// adding the webpack define, so we just build under binDir
path.resolve(binDir, binName)
: // otherwise build in arch-specific directory within binDir
path.resolve(binDir, arch, binName);
// FIXME: rebuilding mountutils shouldn't be necessary, but it is.
// It's coming from etcher-sdk, a fix has been upstreamed but to use
// the latest etcher-sdk we need to upgrade axios at the same time.
commands.push(['npm', ['rebuild', 'mountutils', `--arch=${arch}`]]);
commands.push([
'pkg',
[
path.join(sourcesDir, 'util', 'api.js'),
'-c',
'pkg-sidecar.json',
// `--no-bytecode` so that we can cross-compile for arm64 on x64
'--no-bytecode',
'--public',
'--public-packages',
'"*"',
// always build for host platform and node version
// https://github.com/vercel/pkg-fetch/releases
'--target',
`node20-${arch}`,
'--output',
binPath,
],
]);
});
commands.forEach(([cmd, args, opt]) => {
log('running command:', cmd, args.join(' '));
execFileSync(cmd, args, { shell: true, stdio: 'inherit', ...opt });
});
}
function copyArtifact(
buildPath: string,
arch: string,
binDir: string,
binName: string,
) {
const binPath = isStartScrpt()
? // on `npm start`, we don't know the arch we're building for at the time we're
// adding the webpack define, so look for the binary directly under binDir
path.resolve(binDir, binName)
: // otherwise look into arch-specific directory within binDir
path.resolve(binDir, arch, binName);
// buildPath points to appPath, which is inside resources dir which is the one we actually want
const resourcesPath = path.dirname(buildPath);
const dest = path.resolve(resourcesPath, path.basename(binPath));
log(`copying '${binPath}' to '${dest}'`);
fs.copyFileSync(binPath, dest);
}
export class SidecarPlugin extends PluginBase<void> {
name = 'sidecar';
constructor() {
super();
this.getHooks = this.getHooks.bind(this);
log('isStartScript:', isStartScrpt());
}
getHooks(): ForgeMultiHookMap {
const DEFINE_NAME = 'ETCHER_UTIL_BIN_PATH';
const BASE_DIR = path.join('out', 'sidecar');
const SRC_DIR = path.join(BASE_DIR, 'src');
const BIN_DIR = path.join(BASE_DIR, 'bin');
const BIN_NAME = `etcher-util${process.platform === 'win32' ? '.exe' : ''}`;
return {
resolveForgeConfig: async (currentConfig) => {
log('resolveForgeConfig');
return addWebpackDefine(currentConfig, DEFINE_NAME, BIN_DIR, BIN_NAME);
},
generateAssets: async (_config, platform, arch) => {
log('generateAssets', { platform, arch });
build(SRC_DIR, arch, BIN_DIR, BIN_NAME);
},
packageAfterCopy: async (
_config,
buildPath,
electronVersion,
platform,
arch,
) => {
log('packageAfterCopy', {
buildPath,
electronVersion,
platform,
arch,
});
copyArtifact(buildPath, arch, BIN_DIR, BIN_NAME);
},
};
}
}

456
lib/gui/app/app.js Normal file
View File

@ -0,0 +1,456 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @module Etcher
*/
'use strict'
/* eslint-disable no-var */
var angular = require('angular')
/* eslint-enable no-var */
const electron = require('electron')
const sdk = require('etcher-sdk')
const _ = require('lodash')
const uuidV4 = require('uuid/v4')
const EXIT_CODES = require('../../shared/exit-codes')
const messages = require('../../shared/messages')
const store = require('./models/store')
const packageJSON = require('../../../package.json')
const flashState = require('./models/flash-state')
const settings = require('./models/settings')
const windowProgress = require('./os/window-progress')
const analytics = require('./modules/analytics')
const availableDrives = require('./models/available-drives')
const driveScanner = require('./modules/drive-scanner')
const osDialog = require('./os/dialog')
const exceptionReporter = require('./modules/exception-reporter')
const updateLock = require('./modules/update-lock')
/* eslint-disable lodash/prefer-lodash-method,lodash/prefer-get */
// Enable debug information from all modules that use `debug`
// See https://github.com/visionmedia/debug#browser-support
//
// Enable drivelist debugging information
// See https://github.com/balena-io-modules/drivelist
process.env.DRIVELIST_DEBUG = /drivelist|^\*$/i.test(process.env.DEBUG) ? '1' : ''
window.localStorage.debug = process.env.DEBUG
window.addEventListener('unhandledrejection', (event) => {
// Promise: event.reason
// Bluebird: event.detail.reason
// Anything else: event
const error = event.reason || (event.detail && event.detail.reason) || event
analytics.logException(error)
event.preventDefault()
})
// Set application session UUID
store.dispatch({
type: store.Actions.SET_APPLICATION_SESSION_UUID,
data: uuidV4()
})
// Set first flashing workflow UUID
store.dispatch({
type: store.Actions.SET_FLASHING_WORKFLOW_UUID,
data: uuidV4()
})
const applicationSessionUuid = store.getState().toJS().applicationSessionUuid
const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid
const app = angular.module('Etcher', [
require('angular-ui-router'),
require('angular-ui-bootstrap'),
require('angular-if-state'),
// Components
require('./components/svg-icon'),
require('./components/safe-webview'),
// Pages
require('./pages/main/main.ts').MODULE_NAME,
require('./components/finish/index.ts').MODULE_NAME
])
app.run(() => {
console.log([
' _____ _ _',
'| ___| | | |',
'| |__ | |_ ___| |__ ___ _ __',
'| __|| __/ __| \'_ \\ / _ \\ \'__|',
'| |___| || (__| | | | __/ |',
'\\____/ \\__\\___|_| |_|\\___|_|',
'',
'Interested in joining the Etcher team?',
'Drop us a line at join+etcher@balena.io',
'',
`Version = ${packageJSON.version}, Type = ${packageJSON.packageType}`
].join('\n'))
})
app.run(() => {
const currentVersion = packageJSON.version
analytics.logEvent('Application start', {
packageType: packageJSON.packageType,
version: currentVersion,
applicationSessionUuid
})
})
app.run(() => {
store.observe(() => {
if (!flashState.isFlashing()) {
return
}
const currentFlashState = flashState.getFlashState()
const stateType = !currentFlashState.flashing && currentFlashState.verifying
? `Verifying ${currentFlashState.verifying}`
: `Flashing ${currentFlashState.flashing}`
// NOTE: There is usually a short time period between the `isFlashing()`
// property being set, and the flashing actually starting, which
// might cause some non-sense flashing state logs including
// `undefined` values.
analytics.logDebug(
`${stateType} devices, ` +
`${currentFlashState.percentage}% at ${currentFlashState.speed} MB/s ` +
`(total ${currentFlashState.totalSpeed} MB/s) ` +
`eta in ${currentFlashState.eta}s ` +
`with ${currentFlashState.failed} failed devices`
)
windowProgress.set(currentFlashState)
})
})
/**
* @summary The radix used by USB ID numbers
* @type {Number}
* @constant
*/
const USB_ID_RADIX = 16
/**
* @summary The expected length of a USB ID number
* @type {Number}
* @constant
*/
const USB_ID_LENGTH = 4
/**
* @summary Convert a USB id (e.g. product/vendor) to a string
* @function
* @private
*
* @param {Number} id - USB id
* @returns {String} string id
*
* @example
* console.log(usbIdToString(2652))
* > '0x0a5c'
*/
const usbIdToString = (id) => {
return `0x${_.padStart(id.toString(USB_ID_RADIX), USB_ID_LENGTH, '0')}`
}
/**
* @summary Product ID of BCM2708
* @type {Number}
* @constant
*/
const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763
/**
* @summary Product ID of BCM2710
* @type {Number}
* @constant
*/
const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764
/**
* @summary Compute module descriptions
* @type {Object}
* @constant
*/
const COMPUTE_MODULE_DESCRIPTIONS = {
[USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1',
[USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3'
}
app.run(($timeout) => {
const BLACKLISTED_DRIVES = settings.has('driveBlacklist')
? settings.get('driveBlacklist').split(',')
: []
// eslint-disable-next-line require-jsdoc
const driveIsAllowed = (drive) => {
return !(
BLACKLISTED_DRIVES.includes(drive.devicePath) ||
BLACKLISTED_DRIVES.includes(drive.device) ||
BLACKLISTED_DRIVES.includes(drive.raw)
)
}
// eslint-disable-next-line require-jsdoc,consistent-return
const prepareDrive = (drive) => {
if (drive instanceof sdk.sourceDestination.BlockDevice) {
return drive.drive
} else if (drive instanceof sdk.sourceDestination.UsbbootDrive) {
// This is a workaround etcher expecting a device string and a size
drive.device = drive.usbDevice.portId
drive.size = null
drive.progress = 0
drive.disabled = true
drive.on('progress', (progress) => {
updateDriveProgress(drive, progress)
})
return drive
} else if (drive instanceof sdk.sourceDestination.DriverlessDevice) {
const description = COMPUTE_MODULE_DESCRIPTIONS[drive.deviceDescriptor.idProduct] || 'Compute Module'
return {
device: `${usbIdToString(drive.deviceDescriptor.idVendor)}:${usbIdToString(drive.deviceDescriptor.idProduct)}`,
displayName: 'Missing drivers',
description,
mountpoints: [],
isReadOnly: false,
isSystem: false,
disabled: true,
icon: 'warning',
size: null,
link: 'https://www.raspberrypi.org/documentation/hardware/computemodule/cm-emmc-flashing.md',
linkCTA: 'Install',
linkTitle: 'Install missing drivers',
linkMessage: [
'Would you like to download the necessary drivers from the Raspberry Pi Foundation?',
'This will open your browser.\n\n',
'Once opened, download and run the installer from the "Windows Installer" section to install the drivers.'
].join(' ')
}
}
}
// eslint-disable-next-line require-jsdoc
const setDrives = (drives) => {
availableDrives.setDrives(_.values(drives))
// Safely trigger a digest cycle.
// In some cases, AngularJS doesn't acknowledge that the
// available drives list has changed, and incorrectly
// keeps asking the user to "Connect a drive".
$timeout()
}
// eslint-disable-next-line require-jsdoc
const getDrives = () => {
return _.keyBy(availableDrives.getDrives() || [], 'device')
}
// eslint-disable-next-line require-jsdoc
const addDrive = (drive) => {
const preparedDrive = prepareDrive(drive)
if (!driveIsAllowed(preparedDrive)) {
return
}
const drives = getDrives()
drives[preparedDrive.device] = preparedDrive
setDrives(drives)
}
// eslint-disable-next-line require-jsdoc
const removeDrive = (drive) => {
const preparedDrive = prepareDrive(drive)
const drives = getDrives()
// eslint-disable-next-line prefer-reflect
delete drives[preparedDrive.device]
setDrives(drives)
}
// eslint-disable-next-line require-jsdoc
const updateDriveProgress = (drive, progress) => {
const drives = getDrives()
const driveInMap = drives[drive.device]
if (driveInMap) {
driveInMap.progress = progress
setDrives(drives)
}
}
driveScanner.on('attach', addDrive)
driveScanner.on('detach', removeDrive)
driveScanner.on('error', (error) => {
// Stop the drive scanning loop in case of errors,
// otherwise we risk presenting the same error over
// and over again to the user, while also heavily
// spamming our error reporting service.
driveScanner.stop()
return exceptionReporter.report(error)
})
driveScanner.start()
})
app.run(($window) => {
let popupExists = false
$window.addEventListener('beforeunload', (event) => {
if (!flashState.isFlashing() || popupExists) {
analytics.logEvent('Close application', {
isFlashing: flashState.isFlashing(),
applicationSessionUuid
})
return
}
// Don't close window while flashing
event.returnValue = false
// Don't open any more popups
popupExists = true
analytics.logEvent('Close attempt while flashing', { applicationSessionUuid, flashingWorkflowUuid })
osDialog.showWarning({
confirmationLabel: 'Yes, quit',
rejectionLabel: 'Cancel',
title: 'Are you sure you want to close Etcher?',
description: messages.warning.exitWhileFlashing()
}).then((confirmed) => {
if (confirmed) {
analytics.logEvent('Close confirmed while flashing', {
flashInstanceUuid: flashState.getFlashUuid(),
applicationSessionUuid,
flashingWorkflowUuid
})
// This circumvents the 'beforeunload' event unlike
// electron.remote.app.quit() which does not.
electron.remote.process.exit(EXIT_CODES.SUCCESS)
}
analytics.logEvent('Close rejected while flashing', { applicationSessionUuid, flashingWorkflowUuid })
popupExists = false
}).catch(exceptionReporter.report)
})
/**
* @summary Helper fn for events
* @function
* @private
* @example
* window.addEventListener('click', extendLock)
*/
const extendLock = () => {
updateLock.extend()
}
$window.addEventListener('click', extendLock)
$window.addEventListener('touchstart', extendLock)
// Initial update lock acquisition
extendLock()
})
app.run(($rootScope) => {
$rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
// Ignore first navigation
if (!fromState.name) {
return
}
analytics.logEvent('Navigate', {
to: toState.name,
from: fromState.name,
applicationSessionUuid
})
})
})
app.config(($urlRouterProvider) => {
$urlRouterProvider.otherwise('/main')
})
app.config(($provide) => {
$provide.decorator('$exceptionHandler', ($delegate) => {
return (exception, cause) => {
exceptionReporter.report(exception)
$delegate(exception, cause)
}
})
})
app.config(($locationProvider) => {
// NOTE(Shou): this seems to invoke a minor perf decrease when set to true
$locationProvider.html5Mode({
rewriteLinks: false
})
})
app.controller('StateController', function ($rootScope, $scope) {
const unregisterStateChange = $rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
this.previousName = fromState.name
this.currentName = toState.name
})
$scope.$on('$destroy', unregisterStateChange)
/**
* @summary Get the previous state name
* @function
* @public
*
* @returns {String} previous state name
*
* @example
* if (StateController.previousName === 'main') {
* console.log('We left the main screen!');
* }
*/
this.previousName = null
/**
* @summary Get the current state name
* @function
* @public
*
* @returns {String} current state name
*
* @example
* if (StateController.currentName === 'main') {
* console.log('We are on the main screen!');
* }
*/
this.currentName = null
})
// Ensure user settings are loaded before
// we bootstrap the Angular.js application
angular.element(document).ready(() => {
settings.load().then(() => {
angular.bootstrap(document, [ 'Etcher' ])
}).catch(exceptionReporter.report)
})

View File

@ -1,215 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as electron from 'electron';
import * as remote from '@electron/remote';
import type { Dictionary } from 'lodash';
import { debounce, capitalize, values } from 'lodash';
import outdent from 'outdent';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { v4 as uuidV4 } from 'uuid';
import * as packageJSON from '../../../package.json';
import type { DrivelistDrive } from '../../shared/drive-constraints';
import * as EXIT_CODES from '../../shared/exit-codes';
import * as messages from '../../shared/messages';
import * as availableDrives from './models/available-drives';
import * as flashState from './models/flash-state';
import * as settings from './models/settings';
import { Actions, observe, store } from './models/store';
import * as analytics from './modules/analytics';
import { spawnChildAndConnect } from './modules/api';
import * as exceptionReporter from './modules/exception-reporter';
import * as osDialog from './os/dialog';
import * as windowProgress from './os/window-progress';
import MainPage from './pages/main/MainPage';
import './css/main.css';
import * as i18next from 'i18next';
import type { SourceMetadata } from '../../shared/typings/source-selector';
window.addEventListener(
'unhandledrejection',
(event: PromiseRejectionEvent | any) => {
// Promise: event.reason
// Anything else: event
const error = event.reason || event;
analytics.logException(error);
event.preventDefault();
},
);
// Set application session UUID
store.dispatch({
type: Actions.SET_APPLICATION_SESSION_UUID,
data: uuidV4(),
});
// Set first flashing workflow UUID
store.dispatch({
type: Actions.SET_FLASHING_WORKFLOW_UUID,
data: uuidV4(),
});
console.log(outdent`
${outdent}
_____ _ _
| ___| | | |
| |__ | |_ ___| |__ ___ _ __
| __|| __/ __| '_ \\ / _ \\ '__|
| |___| || (__| | | | __/ |
\\____/ \\__\\___|_| |_|\\___|_|
Interested in joining the Etcher team?
Drop us a line at join+etcher@balena.io
Version = ${packageJSON.version}, Type = ${packageJSON.packageType}
`);
const debouncedLog = debounce(console.log, 1000, { maxWait: 1000 });
function pluralize(word: string, quantity: number) {
return `${quantity} ${word}${quantity === 1 ? '' : 's'}`;
}
observe(() => {
if (!flashState.isFlashing()) {
return;
}
const currentFlashState = flashState.getFlashState();
windowProgress.set(currentFlashState);
let eta = '';
if (currentFlashState.eta !== undefined) {
eta = `eta in ${currentFlashState.eta.toFixed(0)}s`;
}
let active = '';
if (currentFlashState.type !== 'decompressing') {
active = pluralize('device', currentFlashState.active);
}
// NOTE: There is usually a short time period between the `isFlashing()`
// property being set, and the flashing actually starting, which
// might cause some non-sense flashing state logs including
// `undefined` values.
debouncedLog(outdent({ newline: ' ' })`
${capitalize(currentFlashState.type)}
${active},
${currentFlashState.percentage}%
at
${(currentFlashState.speed || 0).toFixed(2)}
MB/s
(total ${(currentFlashState.speed * currentFlashState.active).toFixed(2)} MB/s)
${eta}
with
${pluralize('failed device', currentFlashState.failed)}
`);
});
function setDrives(drives: Dictionary<DrivelistDrive>) {
// prevent setting drives while flashing otherwise we might lose some while we unmount them
if (!flashState.isFlashing()) {
availableDrives.setDrives(values(drives));
}
}
// Spawning the child process without privileges to get the drives list
// TODO: clean up this mess of exports
export let requestMetadata: any;
// start the api and spawn the child process
spawnChildAndConnect({
withPrivileges: false,
})
.then(({ emit, registerHandler }) => {
// start scanning
emit('scan', {});
// make the sourceMetada awaitable to be used on source selection
requestMetadata = async (params: any): Promise<SourceMetadata> => {
emit('sourceMetadata', JSON.stringify(params));
return new Promise((resolve) =>
registerHandler('sourceMetadata', (data: any) => {
resolve(JSON.parse(data));
}),
);
};
registerHandler('drives', (data: any) => {
setDrives(JSON.parse(data));
});
})
.catch((error: any) => {
throw new Error(`Failed to start the flasher process. error: ${error}`);
});
let popupExists = false;
analytics.initAnalytics();
window.addEventListener('beforeunload', async (event) => {
if (!flashState.isFlashing() || popupExists) {
return;
}
// Don't close window while flashing
event.returnValue = false;
// Don't open any more popups
popupExists = true;
try {
const confirmed = await osDialog.showWarning({
confirmationLabel: i18next.t('yesExit'),
rejectionLabel: i18next.t('cancel'),
title: i18next.t('reallyExit'),
description: messages.warning.exitWhileFlashing(),
});
if (confirmed) {
// This circumvents the 'beforeunload' event unlike
// remote.app.quit() which does not.
remote.process.exit(EXIT_CODES.SUCCESS);
}
popupExists = false;
} catch (error: any) {
exceptionReporter.report(error);
}
});
export async function main() {
try {
const { init: ledsInit } = require('./models/leds');
await ledsInit();
} catch (error: any) {
exceptionReporter.report(error);
}
ReactDOM.render(
React.createElement(MainPage),
document.getElementById('main'),
// callback to set the correct zoomFactor for webviews as well
async () => {
const fullscreen = await settings.get('fullscreen');
const width = fullscreen ? window.screen.width : window.outerWidth;
try {
electron.webFrame.setZoomFactor(width / settings.DEFAULT_WIDTH);
} catch (err) {
// noop
}
},
);
}

View File

@ -0,0 +1,337 @@
/*
* Copyright 2019 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const _ = require('lodash')
const React = require('react')
const { Modal } = require('rendition')
const {
isDriveValid,
getDriveImageCompatibilityStatuses,
hasListDriveImageCompatibilityStatus,
COMPATIBILITY_STATUS_TYPES
} = require('../../../../shared/drive-constraints')
const store = require('../../models/store')
const analytics = require('../../modules/analytics')
const availableDrives = require('../../models/available-drives')
const selectionState = require('../../models/selection-state')
const { bytesToClosestUnit } = require('../../../../shared/units')
const utils = require('../../../../shared/utils')
const { open: openExternal } = require('../../os/open-external/services/open-external')
/**
* @summary Determine if we can change a drive's selection state
* @function
* @private
*
* @param {Object} drive - drive
* @returns {Promise}
*
* @example
* shouldChangeDriveSelectionState(drive)
* .then((shouldChangeDriveSelectionState) => {
* if (shouldChangeDriveSelectionState) doSomething();
* });
*/
const shouldChangeDriveSelectionState = (drive) => {
return isDriveValid(drive, selectionState.getImage())
}
/**
* @summary Toggle a drive selection
* @function
* @public
*
* @param {Object} drive - drive
* @returns {void}
*
* @example
* toggleDrive({
* device: '/dev/disk2',
* size: 999999999,
* name: 'Cruzer USB drive'
* });
*/
const toggleDrive = (drive) => {
const canChangeDriveSelectionState = shouldChangeDriveSelectionState(drive)
if (canChangeDriveSelectionState) {
analytics.logEvent('Toggle drive', {
drive,
previouslySelected: selectionState.isCurrentDrive(availableDrives.device),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
selectionState.toggleDrive(drive.device)
}
}
/**
* @summary Memoized getDrives function
* @function
* @public
*
* @returns {Array<Object>} - memoized list of drives
*
* @example
* const drives = getDrives()
* // Do something with drives
*/
const getDrives = utils.memoize(availableDrives.getDrives, _.isEqual)
/**
* @summary Get a drive's compatibility status object(s)
* @function
* @public
*
* @description
* Given a drive, return its compatibility status with the selected image,
* containing the status type (ERROR, WARNING), and accompanying
* status message.
*
* @returns {Object[]} list of objects containing statuses
*
* @example
* const statuses = getDriveStatuses(drive);
*
* for ({ type, message } of statuses) {
* // do something
* }
*/
const getDriveStatuses = utils.memoize((drive) => {
return getDriveImageCompatibilityStatuses(drive, selectionState.getImage())
}, _.isEqual)
/**
* @summary Keyboard event drive toggling
* @function
* @public
*
* @description
* Keyboard-event specific entry to the toggleDrive function.
*
* @param {Object} drive - drive
* @param {Object} evt - event
*
* @example
* <div tabindex="1" onKeyPress="keyboardToggleDrive(drive, evt)">
* Tab-select me and press enter or space!
* </div>
*/
const keyboardToggleDrive = (drive, evt) => {
const ENTER = 13
const SPACE = 32
if (_.includes([ ENTER, SPACE ], evt.keyCode)) {
toggleDrive(drive)
}
}
const DriveSelectorModal = ({ close }) => {
const [ confirmModal, setConfirmModal ] = React.useState({ open: false })
const [ drives, setDrives ] = React.useState(getDrives())
React.useEffect(() => {
const unsubscribe = store.subscribe(() => {
setDrives(availableDrives.getDrives())
})
return unsubscribe
})
/**
* @summary Prompt the user to install missing usbboot drivers
* @function
* @public
*
* @param {Object} drive - drive
* @returns {void}
*
* @example
* installMissingDrivers({
* linkTitle: 'Go to example.com',
* linkMessage: 'Examples are great, right?',
* linkCTA: 'Call To Action',
* link: 'https://example.com'
* });
*/
const installMissingDrivers = (drive) => {
if (drive.link) {
analytics.logEvent('Open driver link modal', {
url: drive.link,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
setConfirmModal({
open: true,
options: {
width: 400,
title: drive.linkTitle,
cancel: () => setConfirmModal({ open: false }),
done: async (shouldContinue) => {
try {
if (shouldContinue) {
openExternal(drive.link)
} else {
setConfirmModal({ open: false })
}
} catch (error) {
analytics.logException(error)
}
},
action: 'Yes, continue',
cancelButtonProps: {
children: 'Cancel'
},
children: drive.linkMessage || `Etcher will open ${drive.link} in your browser`
}
})
}
}
/**
* @summary Select a drive and close the modal
* @function
* @public
*
* @param {Object} drive - drive
* @returns {void}
*
* @example
* selectDriveAndClose({
* device: '/dev/disk2',
* size: 999999999,
* name: 'Cruzer USB drive'
* });
*/
const selectDriveAndClose = async (drive) => {
const canChangeDriveSelectionState = await shouldChangeDriveSelectionState(drive)
if (canChangeDriveSelectionState) {
selectionState.selectDrive(drive.device)
analytics.logEvent('Drive selected (double click)', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
close()
}
}
const hasStatus = hasListDriveImageCompatibilityStatus(selectionState.getSelectedDrives(), selectionState.getImage())
return (
<Modal
className='modal-drive-selector-modal'
title='Select a Drive'
done={close}
action='Continue'
style={{
padding: '20px 30px 11px 30px'
}}
primaryButtonProps={{
primary: !hasStatus,
warning: hasStatus
}}
>
<div>
<ul style={{
height: '250px',
overflowX: 'hidden',
overflowY: 'auto',
padding: '0'
}}>
{_.map(drives, (drive, index) => {
return (
<li
key={`item-${drive.displayName}`}
className="list-group-item"
disabled={!isDriveValid(drive, selectionState.getImage())}
onDoubleClick={() => selectDriveAndClose(drive, close)}
onClick={() => toggleDrive(drive)}
>
{drive.icon && <img className="list-group-item-section" alt="Drive device type logo"
src={`../assets/${drive.icon}.svg`}
width="25"
height="30"/>}
<div
className="list-group-item-section list-group-item-section-expanded"
// eslint-disable-next-line no-magic-numbers
tabIndex={ 15 + index }
onKeyPress={(evt) => keyboardToggleDrive(drive, evt)}>
<h6 className="list-group-item-heading">
{ drive.description }
{drive.size && <span className="word-keep"> - { bytesToClosestUnit(drive.size) }</span>}
</h6>
{!drive.link && <p className="list-group-item-text">
{ drive.displayName }
</p>}
{drive.link && <p className="list-group-item-text">
{ drive.displayName } - <b><a onClick={() => installMissingDrivers(drive)}>{ drive.linkCTA }</a></b>
</p>}
<footer className="list-group-item-footer">
{_.map(getDriveStatuses(drive), (status, idx) => {
const className = {
[COMPATIBILITY_STATUS_TYPES.WARNING]: 'label-warning',
[COMPATIBILITY_STATUS_TYPES.ERROR]: 'label-danger'
}
return (
<span key={`${drive.displayName}-status-${idx}`} className={`label ${className[status.type]}`}>
{ status.message }
</span>
)
})}
</footer>
{Boolean(drive.progress) && (
<progress
className='drive-init-progress'
value={ drive.progress }
max="100">
</progress>
)}
</div>
{isDriveValid(drive, selectionState.getImage()) && (
<span className="list-group-item-section tick tick--success"
disabled={!selectionState.isDriveSelected(drive.device)}>
</span>
)}
</li>
)
})}
{!availableDrives.hasAvailableDrives() && <li className="list-group-item">
<div>
<b>Connect a drive!</b>
<div>No removable drive detected.</div>
</div>
</li>}
</ul>
</div>
{confirmModal.open && <Modal
{...confirmModal.options}
>
</Modal>
}
</Modal>
)
}
module.exports = DriveSelectorModal

View File

@ -1,569 +0,0 @@
/*
* Copyright 2019 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/triangle-exclamation.svg';
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg';
import type * as sourceDestination from 'etcher-sdk/build/source-destination/';
import * as React from 'react';
import type { ModalProps, TableColumn } from 'rendition';
import { Flex, Txt, Badge, Link } from 'rendition';
import styled from 'styled-components';
import type {
DriveStatus,
DrivelistDrive,
} from '../../../../shared/drive-constraints';
import {
getDriveImageCompatibilityStatuses,
isDriveValid,
isDriveSizeLarge,
} from '../../../../shared/drive-constraints';
import { compatibility, warning } from '../../../../shared/messages';
import prettyBytes from 'pretty-bytes';
import { getDrives, hasAvailableDrives } from '../../models/available-drives';
import { getImage, isDriveSelected } from '../../models/selection-state';
import { store } from '../../models/store';
import { logException } from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external';
import type { GenericTableProps } from '../../styled-components';
import { Alert, Modal, Table } from '../../styled-components';
import type { SourceMetadata } from '../../../../shared/typings/source-selector';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import * as i18next from 'i18next';
interface UsbbootDrive extends sourceDestination.UsbbootDrive {
progress: number;
}
interface DriverlessDrive {
displayName: string; // added in app.ts
description: string;
link: string;
linkTitle: string;
linkMessage: string;
linkCTA: string;
}
type Drive = DrivelistDrive | DriverlessDrive | UsbbootDrive;
function isUsbbootDrive(drive: Drive): drive is UsbbootDrive {
return (drive as UsbbootDrive).progress !== undefined;
}
function isDriverlessDrive(drive: Drive): drive is DriverlessDrive {
return (drive as DriverlessDrive).link !== undefined;
}
function isDrivelistDrive(drive: Drive): drive is DrivelistDrive {
return typeof (drive as DrivelistDrive).size === 'number';
}
const DrivesTable = styled((props: GenericTableProps<Drive>) => (
<Table<Drive> {...props} />
))`
[data-display='table-head'],
[data-display='table-body'] {
> [data-display='table-row'] > [data-display='table-cell'] {
&:nth-child(2) {
width: 32%;
}
&:nth-child(3) {
width: 15%;
}
&:nth-child(4) {
width: 15%;
}
&:nth-child(5) {
width: 32%;
}
}
}
`;
function badgeShadeFromStatus(status: string) {
switch (status) {
case compatibility.containsImage():
return 16;
case compatibility.system():
case compatibility.tooSmall():
return 5;
default:
return 14;
}
}
const InitProgress = styled(
({
value,
...props
}: {
value: number;
props?: React.ProgressHTMLAttributes<Element>;
}) => {
return <progress max="100" value={value} {...props} />;
},
)`
/* Reset the default appearance */
appearance: none;
::-webkit-progress-bar {
width: 130px;
height: 4px;
background-color: #dde1f0;
border-radius: 14px;
}
::-webkit-progress-value {
background-color: #1496e1;
border-radius: 14px;
}
`;
export interface DriveSelectorProps
extends Omit<ModalProps, 'done' | 'cancel' | 'onSelect'> {
write: boolean;
multipleSelection: boolean;
showWarnings?: boolean;
cancel: (drives: DrivelistDrive[]) => void;
done: (drives: DrivelistDrive[]) => void;
titleLabel: string;
emptyListLabel: string;
emptyListIcon: JSX.Element;
selectedList?: DrivelistDrive[];
updateSelectedList?: () => DrivelistDrive[];
onSelect?: (drive: DrivelistDrive) => void;
}
interface DriveSelectorState {
drives: Drive[];
image?: SourceMetadata;
missingDriversModal: { drive?: DriverlessDrive };
selectedList: DrivelistDrive[];
showSystemDrives: boolean;
}
function isSystemDrive(drive: Drive) {
return isDrivelistDrive(drive) && drive.isSystem;
}
export class DriveSelector extends React.Component<
DriveSelectorProps,
DriveSelectorState
> {
private unsubscribe: (() => void) | undefined;
tableColumns: Array<TableColumn<Drive>>;
originalList: DrivelistDrive[];
constructor(props: DriveSelectorProps) {
super(props);
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
const selectedList = this.props.selectedList || [];
this.originalList = [...(this.props.selectedList || [])];
this.state = {
drives: getDrives(),
image: getImage(),
missingDriversModal: defaultMissingDriversModalState,
selectedList,
showSystemDrives: false,
};
this.tableColumns = [
{
field: 'description',
label: i18next.t('drives.name'),
render: (description: string, drive: Drive) => {
if (isDrivelistDrive(drive)) {
const isLargeDrive = isDriveSizeLarge(drive);
const hasWarnings =
this.props.showWarnings && (isLargeDrive || drive.isSystem);
return (
<Flex alignItems="center">
{hasWarnings && (
<ExclamationTriangleSvg
height="1em"
fill={drive.isSystem ? '#fca321' : '#8f9297'}
/>
)}
<Txt ml={(hasWarnings && 8) || 0}>
{middleEllipsis(description, 32)}
</Txt>
</Flex>
);
}
return <Txt>{description}</Txt>;
},
},
{
field: 'description',
key: 'size',
label: i18next.t('drives.size'),
render: (_description: string, drive: Drive) => {
if (isDrivelistDrive(drive) && drive.size !== null) {
return prettyBytes(drive.size);
}
},
},
{
field: 'description',
key: 'link',
label: i18next.t('drives.location'),
render: (_description: string, drive: Drive) => {
return (
<Txt>
{drive.displayName}
{isDriverlessDrive(drive) && (
<>
{' '}
-{' '}
<b>
<a onClick={() => this.installMissingDrivers(drive)}>
{drive.linkCTA}
</a>
</b>
</>
)}
</Txt>
);
},
},
{
field: 'description',
key: 'extra',
// We use an empty React fragment otherwise it uses the field name as label
label: <></>,
render: (_description: string, drive: Drive) => {
if (isUsbbootDrive(drive)) {
return this.renderProgress(drive.progress);
} else if (isDrivelistDrive(drive)) {
return this.renderStatuses(drive);
}
},
},
];
}
private driveShouldBeDisabled(drive: Drive, image?: SourceMetadata) {
return (
isUsbbootDrive(drive) ||
isDriverlessDrive(drive) ||
!isDriveValid(drive, image, this.props.write) ||
(this.props.write && drive.isReadOnly)
);
}
private getDisplayedDrives(drives: Drive[]): Drive[] {
return drives.filter((drive) => {
return (
isUsbbootDrive(drive) ||
isDriverlessDrive(drive) ||
isDriveSelected(drive.device) ||
this.state.showSystemDrives ||
!drive.isSystem
);
});
}
private getDisabledDrives(drives: Drive[], image?: SourceMetadata): string[] {
return drives
.filter((drive) => this.driveShouldBeDisabled(drive, image))
.map((drive) => drive.displayName);
}
private renderProgress(progress: number) {
return (
<Flex flexDirection="column">
<Txt fontSize={12}>Initializing device</Txt>
<InitProgress value={progress} />
</Flex>
);
}
private warningFromStatus(
status: string,
drive: { device: string; size: number },
) {
switch (status) {
case compatibility.containsImage():
return warning.sourceDrive();
case compatibility.largeDrive():
return warning.largeDriveSize();
case compatibility.system():
return warning.systemDrive();
case compatibility.tooSmall():
return warning.tooSmall(
{
size:
this.state.image?.recommendedDriveSize ||
this.state.image?.size ||
0,
},
drive,
);
default:
return '';
}
}
private renderStatuses(drive: DrivelistDrive) {
const statuses: DriveStatus[] = getDriveImageCompatibilityStatuses(
drive,
this.state.image,
this.props.write,
).slice(0, 2);
return (
// the column render fn expects a single Element
<>
{statuses.map((status) => {
const badgeShade = badgeShadeFromStatus(status.message);
const warningMessage = this.warningFromStatus(status.message, {
device: drive.device,
size: drive.size || 0,
});
return (
<Badge
key={status.message}
shade={badgeShade}
mr="8px"
tooltip={this.props.showWarnings ? warningMessage : ''}
>
{status.message}
</Badge>
);
})}
</>
);
}
private installMissingDrivers(drive: DriverlessDrive) {
if (drive.link) {
this.setState({ missingDriversModal: { drive } });
}
}
componentDidMount() {
this.unsubscribe = store.subscribe(() => {
const drives = getDrives();
const image = getImage();
this.setState({
drives,
image,
selectedList:
(this.props.updateSelectedList && this.props.updateSelectedList()) ||
[],
});
});
}
componentWillUnmount() {
this.unsubscribe?.();
}
render() {
const { cancel, done, ...props } = this.props;
const { selectedList, drives, image, missingDriversModal } = this.state;
const displayedDrives = this.getDisplayedDrives(drives);
const disabledDrives = this.getDisabledDrives(drives, image);
const numberOfSystemDrives = drives.filter(isSystemDrive).length;
const numberOfDisplayedSystemDrives =
displayedDrives.filter(isSystemDrive).length;
const numberOfHiddenSystemDrives =
numberOfSystemDrives - numberOfDisplayedSystemDrives;
const hasSystemDrives = selectedList.filter(isSystemDrive).length;
const showWarnings = this.props.showWarnings && hasSystemDrives;
return (
<Modal
titleElement={
<Flex alignItems="baseline" mb={18}>
<Txt fontSize={24} align="left">
{this.props.titleLabel}
</Txt>
<Txt
fontSize={11}
ml={12}
color="#5b82a7"
style={{ fontWeight: 600 }}
>
{i18next.t('drives.find', { length: drives.length })}
</Txt>
</Flex>
}
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
cancel={() => cancel(this.originalList)}
done={() => done(selectedList)}
action={i18next.t('drives.select', { select: selectedList.length })}
primaryButtonProps={{
primary: !showWarnings,
warning: showWarnings,
disabled: !hasAvailableDrives(),
}}
{...props}
>
{!hasAvailableDrives() ? (
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
width="100%"
>
{this.props.emptyListIcon}
<b>{this.props.emptyListLabel}</b>
</Flex>
) : (
<>
<DrivesTable
refFn={() => {
// noop
}}
checkedItems={selectedList}
checkedRowsNumber={selectedList.length}
multipleSelection={this.props.multipleSelection}
columns={this.tableColumns}
data={displayedDrives}
disabledRows={disabledDrives}
getRowClass={(row: Drive) =>
isDrivelistDrive(row) && row.isSystem ? ['system'] : []
}
rowKey="displayName"
onCheck={(rows) => {
if (rows == null) {
rows = [];
}
let newSelection = rows.filter(isDrivelistDrive);
if (this.props.multipleSelection) {
if (rows.length === 0) {
newSelection = [];
}
const deselecting = selectedList.filter(
(selected) =>
newSelection.filter(
(row) => row.device === selected.device,
).length === 0,
);
const selecting = newSelection.filter(
(row) =>
selectedList.filter(
(selected) => row.device === selected.device,
).length === 0,
);
deselecting.concat(selecting).forEach((row) => {
if (this.props.onSelect) {
this.props.onSelect(row);
}
});
this.setState({
selectedList: newSelection,
});
return;
}
if (this.props.onSelect) {
this.props.onSelect(newSelection[newSelection.length - 1]);
}
this.setState({
selectedList: newSelection.slice(newSelection.length - 1),
});
}}
onRowClick={(row: Drive) => {
if (
!isDrivelistDrive(row) ||
this.driveShouldBeDisabled(row, image)
) {
return;
}
if (this.props.onSelect) {
this.props.onSelect(row);
}
const index = selectedList.findIndex(
(d) => d.device === row.device,
);
const newList = this.props.multipleSelection
? [...selectedList]
: [];
if (index === -1) {
newList.push(row);
} else {
// Deselect if selected
newList.splice(index, 1);
}
this.setState({
selectedList: newList,
});
}}
/>
{numberOfHiddenSystemDrives > 0 && (
<Link
mt={15}
mb={15}
fontSize="14px"
onClick={() => this.setState({ showSystemDrives: true })}
>
<Flex alignItems="center">
<ChevronDownSvg height="1em" fill="currentColor" />
<Txt ml={8}>
{i18next.t('drives.showHidden', {
num: numberOfHiddenSystemDrives,
})}
</Txt>
</Flex>
</Link>
)}
</>
)}
{this.props.showWarnings && hasSystemDrives ? (
<Alert className="system-drive-alert" style={{ width: '67%' }}>
{i18next.t('drives.systemDriveDanger')}
</Alert>
) : null}
{missingDriversModal.drive !== undefined && (
<Modal
width={400}
title={missingDriversModal.drive.linkTitle}
cancel={() => this.setState({ missingDriversModal: {} })}
done={() => {
try {
if (missingDriversModal.drive !== undefined) {
openExternal(missingDriversModal.drive.link);
}
} catch (error: any) {
logException(error);
} finally {
this.setState({ missingDriversModal: {} });
}
}}
action={i18next.t('yesContinue')}
cancelButtonProps={{
children: i18next.t('cancel'),
}}
children={
missingDriversModal.drive.linkMessage ||
i18next.t('drives.openInBrowser', {
link: missingDriversModal.drive.link,
})
}
/>
)}
</Modal>
);
}
}

View File

@ -0,0 +1,113 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.modal-drive-selector-modal .modal-content {
width: 315px;
height: 320px;
}
.modal-drive-selector-modal .modal-body {
padding-top: 0;
padding-bottom: 0;
}
.modal-drive-selector-modal .list-group-item[disabled] {
cursor: not-allowed;
}
.modal-drive-selector-modal {
.list-group-item-footer:has(span) {
margin-top: 8px;
}
.list-group-item-heading,
.list-group-item-text {
word-break: break-all;
}
.list-group {
margin-bottom: 0;
}
.list-group-item {
display: flex;
align-items: center;
border-left: 0;
border-right: 0;
border-radius: 0;
border-color: darken($palette-theme-light-background, 7%);
padding: 12px 0;
.list-group-item-section-expanded {
flex-grow: 1;
margin-left: 15px;
}
.list-group-item-section + .list-group-item-section {
margin-left: 10px;
display: inline-block;
vertical-align: middle;
}
> .tick {
font-size: 11px;
}
&:first-child {
border-top: 0;
}
&[disabled] .list-group-item-heading {
color: $palette-theme-light-soft-foreground;
}
.drive-init-progress {
appearance: none;
width: 100%;
height: 2.5px;
border: none;
border-radius: 50% 50%;
}
.drive-init-progress::-webkit-progress-bar {
background-color: $palette-theme-default-background;
border: none;
outline: none;
}
.drive-init-progress::-webkit-progress-value {
border-bottom: 1px solid darken($palette-theme-primary-background, 15);
background-color: $palette-theme-primary-background;
}
}
.list-group-item-heading {
font-size: 13px;
}
.list-group-item-text {
line-height: 1;
font-size: 11px;
color: $palette-theme-light-soft-foreground;
}
.word-keep {
word-break: keep-all;
}
}

View File

@ -0,0 +1,164 @@
/*
* Copyright 2019 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-magic-numbers */
'use strict'
// eslint-disable-next-line no-unused-vars
const React = require('react')
const propTypes = require('prop-types')
const { default: styled } = require('styled-components')
const {
ChangeButton,
DetailsText,
StepButton,
StepNameButton
} = require('./../../styled-components')
const { Txt } = require('rendition')
const middleEllipsis = require('./../../utils/middle-ellipsis')
const { bytesToClosestUnit } = require('./../../../../shared/units')
const TargetDetail = styled((props) => (
<Txt.span {...props}>
</Txt.span>
)) `
float: ${({ float }) => float}
`
const TargetDisplayText = ({
description,
size,
...props
}) => {
return (
<Txt.span {...props}>
<TargetDetail
float='left'>
{description}
</TargetDetail>
<TargetDetail
float='right'
>
{size}
</TargetDetail>
</Txt.span>
)
}
const TargetSelector = (props) => {
const targets = props.selection.getSelectedDrives()
if (targets.length === 1) {
const target = targets[0]
return (
<React.Fragment>
<StepNameButton
plain
tooltip={props.tooltip}
>
{/* eslint-disable no-magic-numbers */}
{ middleEllipsis(target.description, 20) }
</StepNameButton>
{!props.flashing &&
<ChangeButton
plain
mb={14}
onClick={props.reselectDrive}
>
Change
</ChangeButton>
}
<DetailsText>
{ props.constraints.hasListDriveImageCompatibilityStatus(targets, props.image) &&
<Txt.span className='glyphicon glyphicon-exclamation-sign'
ml={2}
tooltip={
props.constraints.getListDriveImageCompatibilityStatuses(targets, props.image)[0].message
}
/>
}
{ bytesToClosestUnit(target.size) }
</DetailsText>
</React.Fragment>
)
}
if (targets.length > 1) {
const targetsTemplate = []
for (const target of targets) {
targetsTemplate.push((
<DetailsText
key={target.device}
tooltip={
`${target.description} ${target.displayName} ${bytesToClosestUnit(target.size)}`
}
px={21}
>
<TargetDisplayText
description={middleEllipsis(target.description, 14)}
size={bytesToClosestUnit(target.size)}
>
</TargetDisplayText>
</DetailsText>
))
}
return (
<React.Fragment>
<StepNameButton
plain
tooltip={props.tooltip}
>
{targets.length} Targets
</StepNameButton>
{ !props.flashing &&
<ChangeButton
plain
onClick={props.reselectDrive}
mb={14}
>
Change
</ChangeButton>
}
{targetsTemplate}
</React.Fragment>
)
}
return (
<StepButton
tabindex={(targets.length > 0) ? -1 : 2 }
disabled={props.disabled}
onClick={props.openDriveSelector}
>
Select target
</StepButton>
)
}
TargetSelector.propTypes = {
targets: propTypes.array,
disabled: propTypes.bool,
openDriveSelector: propTypes.func,
selection: propTypes.object,
reselectDrive: propTypes.func,
flashing: propTypes.bool,
constraints: propTypes.object,
show: propTypes.bool,
tooltip: propTypes.string
}
module.exports = TargetSelector

View File

@ -1,83 +0,0 @@
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/triangle-exclamation.svg';
import * as React from 'react';
import type { ModalProps } from 'rendition';
import { Badge, Flex, Txt } from 'rendition';
import { Modal, ScrollableFlex } from '../../styled-components';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import prettyBytes from 'pretty-bytes';
import type { DriveWithWarnings } from '../../pages/main/Flash';
import * as i18next from 'i18next';
const DriveStatusWarningModal = ({
done,
cancel,
isSystem,
drivesWithWarnings,
}: ModalProps & {
isSystem: boolean;
drivesWithWarnings: DriveWithWarnings[];
}) => {
let warningSubtitle = i18next.t('drives.largeDriveWarning');
let warningCta = i18next.t('drives.largeDriveWarningMsg');
if (isSystem) {
warningSubtitle = i18next.t('drives.systemDriveWarning');
warningCta = i18next.t('drives.systemDriveWarningMsg');
}
return (
<Modal
footerShadow={false}
reverseFooterButtons={true}
done={done}
cancel={cancel}
cancelButtonProps={{
primary: false,
warning: true,
children: i18next.t('drives.changeTarget'),
}}
action={i18next.t('sure')}
primaryButtonProps={{
primary: false,
outline: true,
}}
>
<Flex
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
>
<Flex flexDirection="column">
<ExclamationTriangleSvg height="2em" fill="#fca321" />
<Txt fontSize="24px" color="#fca321">
{i18next.t('warning')}
</Txt>
</Flex>
<Txt fontSize="24px">{warningSubtitle}</Txt>
<ScrollableFlex
flexDirection="column"
backgroundColor="#fff5e6"
m="2em 0"
p="1em 2em"
width="420px"
maxHeight="100px"
>
{drivesWithWarnings.map((drive, i, array) => (
<>
<Flex justifyContent="space-between" alignItems="baseline">
<strong>{middleEllipsis(drive.description, 28)}</strong>{' '}
{drive.size && prettyBytes(drive.size) + ' '}
<Badge shade={5}>{drive.statuses[0].message}</Badge>
</Flex>
{i !== array.length - 1 ? <hr style={{ width: '100%' }} /> : null}
</>
))}
</ScrollableFlex>
<Txt style={{ fontWeight: 600 }}>{warningCta}</Txt>
</Flex>
</Modal>
);
};
export default DriveStatusWarningModal;

View File

@ -0,0 +1,57 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const React = require('react')
const propTypes = require('prop-types')
const SafeWebview = require('../safe-webview/safe-webview.jsx')
const settings = require('../../models/settings')
const analytics = require('../../modules/analytics')
class FeaturedProject extends React.Component {
constructor (props) {
super(props)
this.state = {
endpoint: null
}
}
componentDidMount () {
return settings.load()
.then(() => {
const endpoint = settings.get('featuredProjectEndpoint') || 'https://assets.balena.io/etcher-featured/index.html'
this.setState({ endpoint })
})
.catch(analytics.logException)
}
render () {
return (this.state.endpoint) ? (
<SafeWebview
src={this.state.endpoint}
{...this.props}>
</SafeWebview>
) : null
}
}
FeaturedProject.propTypes = {
onWebviewShow: propTypes.func
}
module.exports = FeaturedProject

View File

@ -14,109 +14,122 @@
* limitations under the License.
*/
import * as _ from 'lodash';
import * as React from 'react';
import { Flex } from 'rendition';
import { v4 as uuidV4 } from 'uuid';
import * as uuidV4 from 'uuid/v4';
import * as messages from '../../../../shared/messages';
import * as flashState from '../../models/flash-state';
import * as selectionState from '../../models/selection-state';
import * as settings from '../../models/settings';
import { Actions, store } from '../../models/store';
import * as store from '../../models/store';
import * as analytics from '../../modules/analytics';
import * as updateLock from '../../modules/update-lock';
import { open as openExternal } from '../../os/open-external/services/open-external';
import { FlashAnother } from '../flash-another/flash-another';
import type { FlashError } from '../flash-results/flash-results';
import { FlashResults } from '../flash-results/flash-results';
import { SafeWebview } from '../safe-webview/safe-webview';
import * as SVGIcon from '../svg-icon/svg-icon';
function restart(goToMain: () => void) {
const restart = (options: any, $state: any) => {
const {
applicationSessionUuid,
flashingWorkflowUuid,
// @ts-ignore
} = store.getState().toJS();
if (!options.preserveImage) {
selectionState.deselectImage();
}
selectionState.deselectAllDrives();
analytics.logEvent('Restart', {
...options,
applicationSessionUuid,
flashingWorkflowUuid,
});
// Re-enable lock release on inactivity
updateLock.resume();
// Reset the flashing workflow uuid
store.dispatch({
type: Actions.SET_FLASHING_WORKFLOW_UUID,
type: 'SET_FLASHING_WORKFLOW_UUID',
data: uuidV4(),
});
goToMain();
}
$state.go('main');
};
async function getSuccessBannerURL() {
return (
(await settings.get('successBannerURL')) ??
'https://efp.balena.io/success-banner?borderTop=false&darkBackground=true'
);
}
function FinishPage({ goToMain }: { goToMain: () => void }) {
const [webviewShowing, setWebviewShowing] = React.useState(false);
const [successBannerURL, setSuccessBannerURL] = React.useState('');
(async () => {
setSuccessBannerURL(await getSuccessBannerURL());
})();
const flashResults = flashState.getFlashResults();
const errors: FlashError[] = (
store.getState().toJS().failedDeviceErrors || []
).map(([, error]: [string, FlashError]) => ({
...error,
}));
const { averageSpeed, blockmappedSize, bytesWritten, failed, size } =
flashState.getFlashState();
const {
skip,
results = {
bytesWritten,
sourceMetadata: {
size,
blockmappedSize,
},
averageFlashingSpeed: averageSpeed,
devices: { failed, successful: 0 },
const formattedErrors = () => {
const errors = _.map(
_.get(flashState.getFlashResults(), ['results', 'errors']),
error => {
return `${error.device}: ${error.message || error.code}`;
},
} = flashResults;
return (
<Flex height="100%" justifyContent="space-between">
<Flex
width={webviewShowing ? '36.2vw' : '100vw'}
height="100vh"
alignItems="center"
justifyContent="center"
flexDirection="column"
style={{
position: 'absolute',
top: 0,
zIndex: 1,
boxShadow: '0 2px 15px 0 rgba(0, 0, 0, 0.2)',
}}
>
<FlashResults
image={selectionState.getImage()?.name}
results={results}
skip={skip}
errors={errors}
mb="32px"
goToMain={goToMain}
/>
);
return errors.join('\n');
};
<FlashAnother
onClick={() => {
restart(goToMain);
}}
/>
</Flex>
{successBannerURL.length && (
<SafeWebview
src={successBannerURL}
onWebviewShow={setWebviewShowing}
style={{
display: webviewShowing ? 'flex' : 'none',
position: 'absolute',
right: 0,
bottom: 0,
width: '63.8vw',
height: '100vh',
}}
/>
)}
</Flex>
function FinishPage({ $state }: any) {
// @ts-ignore
const results = flashState.getFlashResults().results || {};
const progressMessage = messages.progress;
return (
<div className="page-finish row around-xs">
<div className="col-xs">
<div className="box center">
<FlashResults
results={results}
message={progressMessage}
errors={formattedErrors}
></FlashResults>
<FlashAnother
onClick={(options: any) => restart(options, $state)}
></FlashAnother>
</div>
<div className="box center">
<div className="fallback-banner">
<div className="caption caption-big">
Thanks for using
<span
style={{ cursor: 'pointer' }}
onClick={() =>
openExternal(
'https://balena.io/etcher?ref=etcher_offline_banner',
)
}
>
<SVGIcon
paths={['../../assets/etcher.svg']}
width="165px"
height="auto"
></SVGIcon>
</span>
</div>
<div className="caption caption-small fallback-footer">
made with
<SVGIcon
paths={['../../assets/love.svg']}
width="auto"
height="20px"
></SVGIcon>
by
<span
style={{ cursor: 'pointer' }}
onClick={() =>
openExternal('https://balena.io?ref=etcher_success')
}
>
<SVGIcon
paths={['../../assets/balena.svg']}
width="auto"
height="20px"
></SVGIcon>
</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2017 balena.io
* Copyright 2019 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,23 +14,22 @@
* limitations under the License.
*/
import * as remote from '@electron/remote';
import * as settings from '../models/settings';
/**
* @summary Send a notification
* @module Etcher.Pages.Finish
*/
export async function send(title: string, body: string, icon: string) {
// Bail out if desktop notifications are disabled
if (!(await settings.get('desktopNotifications'))) {
return;
}
// `app.dock` is only defined in OS X
if (remote.app.dock) {
remote.app.dock.bounce();
}
import * as angular from 'angular';
import { react2angular } from 'react2angular';
import FinishPage from './finish';
return new window.Notification(title, { body, icon });
}
export const MODULE_NAME = 'Etcher.Pages.Finish';
const Finish = angular.module(MODULE_NAME, []);
Finish.component('finish', react2angular(FinishPage, [], ['$state']));
Finish.config(($stateProvider: any) => {
$stateProvider.state('success', {
url: '/success',
template: '<finish style="width:100%"></finish>',
});
});

View File

@ -15,18 +15,30 @@
*/
import * as React from 'react';
import styled from 'styled-components';
import { position, right } from 'styled-system';
import { BaseButton, ThemedProvider } from '../../styled-components';
import { BaseButton } from '../../styled-components';
import * as i18next from 'i18next';
const Div = styled.div<any>`
${position}
${right}
`;
export interface FlashAnotherProps {
onClick: () => void;
onClick: (options: { preserveImage: boolean }) => void;
}
export const FlashAnother = (props: FlashAnotherProps) => {
return (
<BaseButton primary onClick={props.onClick}>
{i18next.t('flash.another')}
</BaseButton>
<ThemedProvider>
<Div position="absolute" right="152px">
<BaseButton
primary
onClick={props.onClick.bind(null, { preserveImage: true })}
>
Flash Another
</BaseButton>
</Div>
</ThemedProvider>
);
};

View File

@ -0,0 +1,24 @@
/*
* Copyright 2019 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as angular from 'angular';
import { react2angular } from 'react2angular';
import { FlashAnother } from './flash-another';
export const MODULE_NAME = 'Etcher.Components.FlashAnother';
const FlashAnotherModule = angular.module(MODULE_NAME, []);
FlashAnotherModule.component('flashAnother', react2angular(FlashAnother));

View File

@ -14,231 +14,52 @@
* limitations under the License.
*/
import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg';
import CheckCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle-check.svg';
import TimesCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle-xmark.svg';
import * as _ from 'lodash';
import * as React from 'react';
import type { FlexProps, TableColumn } from 'rendition';
import { Flex, Link, Txt } from 'rendition';
import styled from 'styled-components';
import { left, position, space, top } from 'styled-system';
import { Underline } from '../../styled-components';
import { progress } from '../../../../shared/messages';
import { bytesToMegabytes } from '../../../../shared/units';
import FlashSvg from '../../../assets/flash.svg';
import { getDrives } from '../../models/available-drives';
import { resetState } from '../../models/flash-state';
import * as selection from '../../models/selection-state';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import { Modal, Table } from '../../styled-components';
import * as i18next from 'i18next';
const ErrorsTable = styled((props) => <Table<FlashError> {...props} />)`
&&& [data-display='table-head'],
&&& [data-display='table-body'] {
> [data-display='table-row'] {
> [data-display='table-cell'] {
&:first-child {
width: 30%;
}
&:nth-child(2) {
width: 20%;
}
&:last-child {
width: 50%;
}
}
}
}
const Div: any = styled.div<any>`
${position}
${top}
${left}
${space}
`;
const DoneIcon = (props: {
skipped: boolean;
color: string;
allFailed: boolean;
}) => {
const svgProps = {
width: '28px',
fill: props.color,
style: {
marginTop: '-25px',
marginLeft: '13px',
zIndex: 1,
},
};
return props.allFailed && !props.skipped ? (
<TimesCircleSvg {...svgProps} />
) : (
<CheckCircleSvg {...svgProps} />
);
};
export interface FlashError extends Error {
description: string;
device: string;
code: string;
}
function formattedErrors(errors: FlashError[]) {
return errors
.map((error) => `${error.device}: ${error.message || error.code}`)
.join('\n');
}
const columns: Array<TableColumn<FlashError>> = [
{
field: 'description',
label: i18next.t('flash.target'),
},
{
field: 'device',
label: i18next.t('flash.location'),
},
{
field: 'message',
label: i18next.t('flash.error'),
render: (message: string, { code }: FlashError) => {
return message ?? code;
},
},
];
function getEffectiveSpeed(results: {
sourceMetadata: {
size: number;
blockmappedSize?: number;
};
averageFlashingSpeed: number;
}) {
const flashedSize =
results.sourceMetadata.blockmappedSize ?? results.sourceMetadata.size;
const timeSpent = flashedSize / results.averageFlashingSpeed;
return results.sourceMetadata.size / timeSpent;
}
export function FlashResults({
goToMain,
image = '',
export const FlashResults: any = ({
errors,
results,
skip,
...props
message,
}: {
goToMain: () => void;
image?: string;
errors: FlashError[];
skip: boolean;
results: {
sourceMetadata: {
size: number;
blockmappedSize?: number;
};
averageFlashingSpeed: number;
devices: { failed: number; successful: number };
};
} & FlexProps) {
const [showErrorsInfo, setShowErrorsInfo] = React.useState(false);
const allFailed = !skip && results?.devices?.successful === 0;
const someFailed = results?.devices?.failed !== 0 || errors?.length !== 0;
const effectiveSpeed = bytesToMegabytes(getEffectiveSpeed(results)).toFixed(
1,
);
errors: () => string;
results: any;
message: any;
}) => {
return (
<Flex flexDirection="column" {...props}>
<Flex alignItems="center" flexDirection="column">
<Flex
alignItems="center"
mt="50px"
mb="32px"
color="#7e8085"
flexDirection="column"
>
<FlashSvg width="40px" height="40px" className="disabled" />
<DoneIcon
skipped={skip}
allFailed={allFailed}
color={allFailed || someFailed ? '#c6c8c9' : '#1ac135'}
/>
<Txt>{middleEllipsis(image, 24)}</Txt>
</Flex>
<Txt fontSize={24} color="#fff" mb="17px">
{allFailed
? i18next.t('flash.flashFailed')
: i18next.t('flash.flashCompleted')}
</Txt>
{skip ? <Txt color="#7e8085">{i18next.t('flash.skip')}</Txt> : null}
</Flex>
<Flex flexDirection="column" color="#7e8085">
{results.devices.successful !== 0 ? (
<Flex alignItems="center">
<CircleSvg width="14px" fill="#1ac135" />
<Txt ml="10px" color="#fff">
{results.devices.successful}
</Txt>
<Txt ml="10px">
{progress.successful(results.devices.successful)}
</Txt>
</Flex>
) : null}
{errors.length !== 0 ? (
<Flex alignItems="center">
<CircleSvg width="14px" fill="#ff4444" />
<Txt ml="10px" color="#fff">
{errors.length}
</Txt>
<Txt ml="10px" tooltip={formattedErrors(errors)}>
{progress.failed(errors.length)}
</Txt>
<Link ml="10px" onClick={() => setShowErrorsInfo(true)}>
{i18next.t('flash.moreInfo')}
</Link>
</Flex>
) : null}
{!allFailed && (
<Txt
fontSize="10px"
style={{
fontWeight: 500,
textAlign: 'center',
}}
tooltip={i18next.t('flash.speedTip')}
>
{i18next.t('flash.speed', { speed: effectiveSpeed })}
</Txt>
)}
</Flex>
{showErrorsInfo && (
<Modal
titleElement={
<Flex alignItems="baseline" mb={18}>
<Txt fontSize={24} align="left">
{i18next.t('failedTarget')}
</Txt>
</Flex>
}
action={i18next.t('failedRetry')}
cancel={() => setShowErrorsInfo(false)}
done={() => {
setShowErrorsInfo(false);
resetState();
getDrives()
.map((drive) => {
selection.deselectDrive(drive.device);
return drive.device;
})
.filter((driveDevice) =>
errors.some((error) => error.device === driveDevice),
)
.forEach((driveDevice) => selection.selectDrive(driveDevice));
goToMain();
}}
>
<ErrorsTable columns={columns} data={errors} />
</Modal>
)}
</Flex>
<Div position="absolute" left="153px" top="66px">
<div className="inline-flex title">
<span className="tick tick--success space-right-medium"></span>
<h3>Flash Complete!</h3>
</div>
<Div className="results" mt="11px" mr="0" mb="0" ml="40px">
<Underline tooltip={errors()}>
{_.map(results.devices, (quantity, type) => {
return quantity ? (
<div
key={type}
className={`target-status-line target-status-${type}`}
>
<span className="target-status-dot"></span>
<span className="target-status-quantity">{quantity}</span>
<span className="target-status-message">
{message[type](quantity)}
</span>
</div>
) : null;
})}
</Underline>
</Div>
</Div>
);
}
};

View File

@ -0,0 +1,28 @@
/*
* Copyright 2019 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @module Etcher.Components.FlashResults
*/
import * as angular from 'angular';
import { react2angular } from 'react2angular';
import { FlashResults } from './flash-results';
export const MODULE_NAME = 'Etcher.Components.FlashResults';
const FlashResultsModule = angular.module(MODULE_NAME, []);
FlashResultsModule.component('flashResults', react2angular(FlashResults));

View File

@ -0,0 +1,404 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const Bluebird = require('bluebird')
const sdk = require('etcher-sdk')
const _ = require('lodash')
const path = require('path')
const propTypes = require('prop-types')
const React = require('react')
const Dropzone = require('react-dropzone').default
const errors = require('../../../../shared/errors')
const messages = require('../../../../shared/messages')
const supportedFormats = require('../../../../shared/supported-formats')
const shared = require('../../../../shared/units')
const selectionState = require('../../models/selection-state')
const store = require('../../models/store')
const analytics = require('../../modules/analytics')
const exceptionReporter = require('../../modules/exception-reporter')
const osDialog = require('../../os/dialog')
const { replaceWindowsNetworkDriveLetter } = require('../../os/windows-network-drives')
const {
StepButton,
StepNameButton,
StepSelection,
Footer,
Underline,
DetailsText,
ChangeButton
} = require('../../styled-components')
const {
Modal
} = require('rendition')
const middleEllipsis = require('../../utils/middle-ellipsis')
const SVGIcon = require('../svg-icon/svg-icon.jsx')
const { default: styled } = require('styled-components')
// TODO move these styles to rendition
const ModalText = styled.p `
a {
color: rgb(0, 174, 239);
&:hover {
color: rgb(0, 139, 191);
}
}
`
/**
* @summary Main supported extensions
* @constant
* @type {String[]}
* @public
*/
const mainSupportedExtensions = _.intersection([
'img',
'iso',
'zip'
], supportedFormats.getAllExtensions())
/**
* @summary Extra supported extensions
* @constant
* @type {String[]}
* @public
*/
const extraSupportedExtensions = _.difference(
supportedFormats.getAllExtensions(),
mainSupportedExtensions
).sort()
const getState = () => {
return {
hasImage: selectionState.hasImage(),
imageName: selectionState.getImageName(),
imageSize: selectionState.getImageSize()
}
}
class ImageSelector extends React.Component {
constructor (props) {
super(props)
this.state = {
...getState(),
warning: null,
showImageDetails: false
}
this.openImageSelector = this.openImageSelector.bind(this)
this.reselectImage = this.reselectImage.bind(this)
this.handleOnDrop = this.handleOnDrop.bind(this)
this.showSelectedImageDetails = this.showSelectedImageDetails.bind(this)
}
componentDidMount () {
this.unsubscribe = store.observe(() => {
this.setState(getState())
})
}
componentWillUnmount () {
this.unsubscribe()
}
reselectImage () {
analytics.logEvent('Reselect image', {
previousImage: selectionState.getImage(),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
this.openImageSelector()
}
selectImage (image) {
if (!supportedFormats.isSupportedImage(image.path)) {
const invalidImageError = errors.createUserError({
title: 'Invalid image',
description: messages.error.invalidImage(image)
})
osDialog.showError(invalidImageError)
analytics.logEvent('Invalid image', _.merge({
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
}, image))
return
}
Bluebird.try(() => {
let message = null
let title = null
if (supportedFormats.looksLikeWindowsImage(image.path)) {
analytics.logEvent('Possibly Windows image', {
image,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
message = messages.warning.looksLikeWindowsImage()
title = 'Possible Windows image detected'
} else if (!image.hasMBR) {
analytics.logEvent('Missing partition table', {
image,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
title = 'Missing partition table'
message = messages.warning.missingPartitionTable()
}
if (message) {
this.setState({
warning: {
message,
title
}
})
return
}
return false
}).then(() => {
selectionState.selectImage(image)
// An easy way so we can quickly identify if we're making use of
// certain features without printing pages of text to DevTools.
image.logo = Boolean(image.logo)
image.blockMap = Boolean(image.blockMap)
return analytics.logEvent('Select image', {
image,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
}).catch(exceptionReporter.report)
}
async selectImageByPath (imagePath) {
try {
// eslint-disable-next-line no-param-reassign
imagePath = await replaceWindowsNetworkDriveLetter(imagePath)
} catch (error) {
analytics.logException(error)
}
if (!supportedFormats.isSupportedImage(imagePath)) {
const invalidImageError = errors.createUserError({
title: 'Invalid image',
description: messages.error.invalidImage(imagePath)
})
osDialog.showError(invalidImageError)
analytics.logEvent('Invalid image', { path: imagePath })
return
}
const source = new sdk.sourceDestination.File(imagePath, sdk.sourceDestination.File.OpenFlags.Read)
try {
const innerSource = await source.getInnerSource()
const metadata = await innerSource.getMetadata()
const partitionTable = await innerSource.getPartitionTable()
if (partitionTable) {
metadata.hasMBR = true
metadata.partitions = partitionTable.partitions
}
metadata.path = imagePath
// eslint-disable-next-line no-magic-numbers
metadata.extension = path.extname(imagePath).slice(1)
this.selectImage(metadata)
} catch (error) {
const imageError = errors.createUserError({
title: 'Error opening image',
description: messages.error.openImage(path.basename(imagePath), error.message)
})
osDialog.showError(imageError)
analytics.logException(error)
} finally {
try {
await source.close()
} catch (error) {
// Noop
}
}
}
/**
* @summary Open image selector
* @function
* @public
*
* @example
* ImageSelectionController.openImageSelector();
*/
openImageSelector () {
analytics.logEvent('Open image selector', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
osDialog.selectImage().then((imagePath) => {
// Avoid analytics and selection state changes
// if no file was resolved from the dialog.
if (!imagePath) {
analytics.logEvent('Image selector closed', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
return
}
this.selectImageByPath(imagePath)
}).catch(exceptionReporter.report)
}
handleOnDrop (acceptedFiles) {
const [ file ] = acceptedFiles
if (file) {
this.selectImageByPath(file.path)
}
}
showSelectedImageDetails () {
analytics.logEvent('Show selected image tooltip', {
imagePath: selectionState.getImagePath(),
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid
})
this.setState({
showImageDetails: true
})
}
// TODO add a visual change when dragging a file over the selector
render () {
const {
flashing
} = this.props
const {
showImageDetails
} = this.state
const hasImage = selectionState.hasImage()
const imageBasename = hasImage ? path.basename(selectionState.getImagePath()) : ''
const imageName = selectionState.getImageName()
const imageSize = selectionState.getImageSize()
return (
<React.Fragment>
<div className="box text-center relative">
<Dropzone multiple={false} onDrop={this.handleOnDrop}>
{({ getRootProps, getInputProps }) => (
<div className="center-block" {...getRootProps()}>
<input {...getInputProps()} />
<SVGIcon contents={selectionState.getImageLogo()} paths={[ '../../assets/image.svg' ]} />
</div>
)}
</Dropzone>
<div className="space-vertical-large">
{hasImage ? (
<React.Fragment>
<StepNameButton
plain
onClick={this.showSelectedImageDetails}
tooltip={imageBasename}
>
{/* eslint-disable no-magic-numbers */}
{ middleEllipsis(imageName || imageBasename, 20) }
</StepNameButton>
{ !flashing &&
<ChangeButton
plain
mb={14}
onClick={this.reselectImage}
>
Change
</ChangeButton>
}
<DetailsText>
{shared.bytesToClosestUnit(imageSize)}
</DetailsText>
</React.Fragment>
) : (
<StepSelection>
<StepButton
onClick={this.openImageSelector}
>
Select image
</StepButton>
<Footer>
{ mainSupportedExtensions.join(', ') }, and{' '}
<Underline
tooltip={ extraSupportedExtensions.join(', ') }
>
many more
</Underline>
</Footer>
</StepSelection>
)}
</div>
</div>
{Boolean(this.state.warning) && (
<Modal
title={(
<span>
<span style={{ color: '#d9534f' }} className="glyphicon glyphicon-exclamation-sign"></span>
{' '}
<span>{this.state.warning.title}</span>
</span>
)}
action='Continue'
cancel={() => {
this.setState({ warning: null })
this.reselectImage()
}}
done={() => {
this.setState({ warning: null })
}}
primaryButtonProps={{ warning: true, primary: false }}
>
<ModalText dangerouslySetInnerHTML={{ __html: this.state.warning.message }} />
</Modal>
)}
{showImageDetails && (
<Modal
title="Image File Name"
done={() => {
this.setState({ showImageDetails: false })
}}
>
{selectionState.getImagePath()}
</Modal>
)}
</React.Fragment>
)
}
}
ImageSelector.propTypes = {
flashing: propTypes.bool
}
module.exports = ImageSelector

View File

@ -14,19 +14,18 @@
* limitations under the License.
*/
import * as electron from 'electron';
import * as settings from '../../../models/settings';
'use strict'
/**
* @summary Open an external resource
* @module Etcher.Components.Modal
*/
export async function open(url: string) {
// Don't open links if they're disabled by the env var
if (await settings.get('disableExternalLinks')) {
return;
}
if (url) {
electron.shell.openExternal(url);
}
}
const angular = require('angular')
const MODULE_NAME = 'Etcher.Components.Modal'
const Modal = angular.module(MODULE_NAME, [
require('angular-ui-bootstrap')
])
Modal.service('ModalService', require('./services/modal'))
module.exports = MODULE_NAME

View File

@ -0,0 +1,100 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const _ = require('lodash')
const store = require('../../../models/store')
const analytics = require('../../../modules/analytics')
module.exports = function ($uibModal, $q) {
/**
* @summary Open a modal
* @function
* @public
*
* @param {Object} options - options
* @param {String} options.template - template contents
* @param {String} options.controller - controller
* @param {String} [options.size='sm'] - modal size
* @param {Object} options.resolve - modal resolves
* @returns {Object} modal
*
* @example
* ModalService.open({
* name: 'my modal',
* template: require('./path/to/modal.tpl.html'),
* controller: 'DriveSelectorController as modal',
* });
*/
this.open = (options = {}) => {
_.defaults(options, {
size: 'sm'
})
analytics.logEvent('Open modal', {
name: options.name,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
const modal = $uibModal.open({
animation: true,
template: options.template,
controller: options.controller,
size: options.size,
resolve: options.resolve,
backdrop: 'static'
})
return {
close: modal.close,
result: $q((resolve, reject) => {
modal.result.then((value) => {
analytics.logEvent('Modal accepted', {
name: options.name,
value,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
resolve(value)
}).catch((error) => {
// Bootstrap doesn't 'resolve' these but cancels the dialog
if (error === 'escape key press') {
analytics.logEvent('Modal rejected', {
name: options.name,
method: error,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
return resolve()
}
analytics.logEvent('Modal rejected', {
name: options.name,
value: error,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
return reject(error)
})
})
}
}
}

View File

@ -0,0 +1,106 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.modal-content {
background-color: $palette-theme-light-background;
display: flex;
flex-direction: column;
margin: 0 auto;
height: auto;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: baseline;
font-size: 12px;
color: $palette-theme-light-soft-foreground;
padding: 11px 20px;
flex-grow: 0;
}
.modal-title {
font-size: inherit;
flex-grow: 1;
}
.modal-body {
flex-grow: 1;
color: $palette-theme-light-foreground;
padding: 20px;
max-height: 250px;
overflow: auto;
a {
color: $palette-theme-primary-background;
}
> p {
white-space: pre-line;
}
> p:last-child {
margin-bottom: 0;
}
}
.modal-menu {
display: flex;
> * {
flex-basis: auto;
}
}
// UI Bootstrap adds the `.modal-open` class to the <body>
// element and sets its right padding to the width of the
// window, causing the window content to overflow and get
// pushed to the bottom.
// The `!important` flag is needed since UI Bootstrap inlines
// the styles programmatically to the element.
.modal-open {
padding-right: 0 !important;
}
// Disable modal opacity
.modal-backdrop.in {
opacity: 0;
}
.modal-footer {
flex-grow: 0;
border: 0;
text-align: center;
}
.modal {
// Center the modal using Flexbox so we can
// freely use any height.
display: flex !important;
justify-content: center;
align-items: center;
.button[disabled] {
background-color: $palette-theme-light-disabled-background;
color: $palette-theme-light-disabled-foreground;
}
}
.modal-dialog {
margin: 0;
position: initial;
}

View File

@ -0,0 +1,155 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const React = require('react')
const propTypes = require('prop-types')
const Color = require('color')
const {
default: styled,
css,
keyframes
} = require('styled-components')
const { ProgressBar } = require('rendition')
const { colors } = require('./../../theme')
const { StepButton, StepSelection } = require('./../../styled-components')
const darkenForegroundStripes = 0.18
const desaturateForegroundStripes = 0.2
const progressButtonStripesForegroundColor = Color(colors.primary.background)
.darken(darkenForegroundStripes)
.desaturate(desaturateForegroundStripes)
.string()
const desaturateBackgroundStripes = 0.05
const progressButtonStripesBackgroundColor = Color(colors.primary.background)
.desaturate(desaturateBackgroundStripes)
.string()
const ProgressButtonStripes = keyframes `
0% {
background-position: 0 0;
}
100% {
background-position: 20px 20px;
}
`
const ProgressButtonStripesRule = css `
${ProgressButtonStripes} 1s linear infinite;
`
const FlashProgressBar = styled(ProgressBar) `
> div {
width: 200px;
height: 48px;
color: white !important;
text-shadow: none !important;
}
width: 200px;
height: 48px;
font-size: 16px;
line-height: 48px;
background: ${Color(colors.warning.background).darken(darkenForegroundStripes).string()};
`
const FlashProgressBarValidating = styled(FlashProgressBar) `
// Notice that we add 0.01 to certain gradient stop positions.
// That workarounds a Chrome rendering issue where diagonal
// lines look spiky.
// See https://github.com/balena-io/etcher/issues/472
background-image: -webkit-gradient(linear, 0 0, 100% 100%,
color-stop(0.25, ${progressButtonStripesForegroundColor}),
color-stop(0.26, ${progressButtonStripesBackgroundColor}),
color-stop(0.50, ${progressButtonStripesBackgroundColor}),
color-stop(0.51, ${progressButtonStripesForegroundColor}),
color-stop(0.75, ${progressButtonStripesForegroundColor}),
color-stop(0.76 , ${progressButtonStripesBackgroundColor}),
to(${progressButtonStripesBackgroundColor}));
background-color: white;
animation: ${ProgressButtonStripesRule};
overflow: hidden;
background-size: 20px 20px;
`
/**
* Progress Button component
*/
class ProgressButton extends React.Component {
render () {
if (this.props.active) {
if (this.props.striped) {
return (
<StepSelection>
<FlashProgressBarValidating
primary
emphasized
value= { this.props.percentage }
>
{ this.props.label }
</FlashProgressBarValidating>
</StepSelection>
)
}
return (
<StepSelection>
<FlashProgressBar
warning
emphasized
value= { this.props.percentage }
>
{ this.props.label }
</FlashProgressBar>
</StepSelection>
)
}
return (
<StepSelection>
<StepButton
onClick= { this.props.callback }
disabled= { this.props.disabled }
>
{this.props.label}
</StepButton>
</StepSelection>
)
}
}
ProgressButton.propTypes = {
striped: propTypes.bool,
active: propTypes.bool,
percentage: propTypes.number,
label: propTypes.string,
disabled: propTypes.bool,
callback: propTypes.func
}
module.exports = ProgressButton

View File

@ -1,136 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import { Flex, Button, ProgressBar, Txt } from 'rendition';
import { default as styled } from 'styled-components';
import { fromFlashState } from '../../modules/progress-status';
import { StepButton } from '../../styled-components';
import * as i18next from 'i18next';
const FlashProgressBar = styled(ProgressBar)`
> div {
width: 100%;
height: 12px;
color: white !important;
text-shadow: none !important;
transition-duration: 0s;
> div {
transition-duration: 0s;
}
}
width: 100%;
height: 12px;
margin-bottom: 6px;
border-radius: 14px;
font-size: 16px;
line-height: 48px;
background: #2f3033;
`;
interface ProgressButtonProps {
type: 'decompressing' | 'flashing' | 'verifying';
active: boolean;
percentage: number;
position: number;
disabled: boolean;
cancel: (type: string) => void;
callback: () => void;
warning?: boolean;
}
const colors = {
decompressing: '#00aeef',
flashing: '#da60ff',
verifying: '#1ac135',
} as const;
const CancelButton = styled(({ type, onClick, ...props }) => {
const status = type === 'verifying' ? i18next.t('skip') : i18next.t('cancel');
return (
<Button plain onClick={() => onClick(status)} {...props}>
{status}
</Button>
);
})`
font-weight: 600;
&&& {
width: auto;
height: auto;
font-size: 14px;
}
`;
export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
public render() {
const percentage = this.props.percentage;
const warning = this.props.warning;
const { status, position } = fromFlashState({
type: this.props.type,
percentage,
position: this.props.position,
});
const type = this.props.type || 'default';
if (this.props.active) {
return (
<>
<Flex
alignItems="baseline"
justifyContent="space-between"
width="100%"
style={{
marginTop: 42,
marginBottom: '6px',
fontSize: 16,
fontWeight: 600,
}}
>
<Flex>
<Txt color="#fff">{status}&nbsp;</Txt>
<Txt color={colors[type]}>{position}</Txt>
</Flex>
{type && (
<CancelButton
type={type}
onClick={this.props.cancel}
color="#00aeef"
/>
)}
</Flex>
<FlashProgressBar background={colors[type]} value={percentage} />
</>
);
}
return (
<StepButton
primary={!warning}
warning={warning}
onClick={this.props.callback}
disabled={this.props.disabled}
style={{
marginTop: 30,
}}
>
{i18next.t('flash.flashNow')}
</StepButton>
);
}
}

View File

@ -0,0 +1,81 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const React = require('react')
const propTypes = require('prop-types')
const styled = require('styled-components').default
const { color } = require('styled-system')
const SvgIcon = require('../svg-icon/svg-icon.jsx')
const Div = styled.div `
position: absolute;
top: 45px;
left: 545px;
> span.step-name {
justify-content: flex-start;
> span {
margin-left: 10px;
}
> span:nth-child(2) {
font-weight: 500;
}
> span:nth-child(3) {
font-weight: 400;
font-style: italic;
}
}
.svg-icon[disabled] {
opacity: 0.4;
}
`
const Span = styled.span `
${color}
`
const ReducedFlashingInfos = (props) => {
return (props.shouldShow) ? (
<Div>
<Span className="step-name">
<SvgIcon disabled contents={[ props.imageLogo ]} paths={[ '../../assets/image.svg' ]} width='20px'></SvgIcon>
<Span>{ props.imageName }</Span>
<Span color='#7e8085'>{ props.imageSize }</Span>
</Span>
<Span className="step-name">
<SvgIcon disabled paths={[ '../../assets/drive.svg' ]} width='20px'></SvgIcon>
<Span>{ props.driveTitle }</Span>
</Span>
</Div>
) : null
}
ReducedFlashingInfos.propTypes = {
imageLogo: propTypes.string,
imageName: propTypes.string,
imageSize: propTypes.string,
driveTitle: propTypes.string,
shouldShow: propTypes.bool
}
module.exports = ReducedFlashingInfos

View File

@ -1,74 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import { Flex, Txt } from 'rendition';
import DriveSvg from '../../../assets/drive.svg';
import ImageSvg from '../../../assets/image.svg';
import { SVGIcon } from '../svg-icon/svg-icon';
import { middleEllipsis } from '../../utils/middle-ellipsis';
interface ReducedFlashingInfosProps {
imageLogo?: string;
imageName?: string;
imageSize: string;
driveTitle: string;
driveLabel: string;
style?: React.CSSProperties;
}
export class ReducedFlashingInfos extends React.Component<ReducedFlashingInfosProps> {
constructor(props: ReducedFlashingInfosProps) {
super(props);
this.state = {};
}
public render() {
const { imageName = '' } = this.props;
return (
<Flex
flexDirection="column"
style={this.props.style ? this.props.style : undefined}
>
<Flex mb={16}>
<SVGIcon
disabled
width="21px"
height="21px"
contents={this.props.imageLogo}
fallback={ImageSvg}
style={{ marginRight: '9px' }}
/>
<Txt
style={{ marginRight: '9px' }}
tooltip={{ text: imageName, placement: 'right' }}
>
{middleEllipsis(imageName, 16)}
</Txt>
<Txt color="#7e8085">{this.props.imageSize}</Txt>
</Flex>
<Flex>
<DriveSvg width="21px" height="21px" style={{ marginRight: '9px' }} />
<Txt tooltip={{ text: this.props.driveLabel, placement: 'right' }}>
{middleEllipsis(this.props.driveTitle, 16)}
</Txt>
</Flex>
</Flex>
);
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 2018 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
/**
* @module Etcher.Components.SafeWebview
*/
const angular = require('angular')
const { react2angular } = require('react2angular')
const MODULE_NAME = 'Etcher.Components.SafeWebview'
const SafeWebview = angular.module(MODULE_NAME, [])
SafeWebview.component(
'safeWebview',
react2angular(require('./safe-webview.jsx'))
)
module.exports = MODULE_NAME

View File

@ -0,0 +1,245 @@
/*
* Copyright 2017 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
/* eslint-disable jsdoc/require-example */
const _ = require('lodash')
const electron = require('electron')
const react = require('react')
const propTypes = require('prop-types')
const analytics = require('../../modules/analytics')
const store = require('../../models/store')
const settings = require('../../models/settings')
const packageJSON = require('../../../../../package.json')
/**
* @summary Electron session identifier
* @constant
* @private
* @type {String}
*/
const ELECTRON_SESSION = 'persist:success-banner'
/**
* @summary Etcher version search-parameter key
* @constant
* @private
* @type {String}
*/
const ETCHER_VERSION_PARAM = 'etcher-version'
/**
* @summary API version search-parameter key
* @constant
* @private
* @type {String}
*/
const API_VERSION_PARAM = 'api-version'
/**
* @summary Opt-out analytics search-parameter key
* @constant
* @private
* @type {String}
*/
const OPT_OUT_ANALYTICS_PARAM = 'optOutAnalytics'
/**
* @summary Webview API version
* @constant
* @private
* @type {String}
*
* @description
* Changing this number represents a departure from an older API and as such
* should only be changed when truly necessary as it introduces breaking changes.
* This version number is exposed to the banner such that it can determine what
* features are safe to utilize.
*
* See `git blame -L n` where n is the line below for the history of version changes.
*/
const API_VERSION = 2
/**
* @summary Webviews that hide/show depending on the HTTP status returned
* @type {Object}
* @public
*
* @example
* <safe-webview src="https://etcher.io/"></safe-webview>
*/
class SafeWebview extends react.PureComponent {
/**
* @param {Object} props - React element properties
*/
constructor (props) {
super(props)
this.state = {
shouldShow: true
}
const url = new window.URL(props.src)
// We set the version GET parameters here.
url.searchParams.set(ETCHER_VERSION_PARAM, packageJSON.version)
url.searchParams.set(API_VERSION_PARAM, API_VERSION)
url.searchParams.set(OPT_OUT_ANALYTICS_PARAM, !settings.get('errorReporting'))
this.entryHref = url.href
// Events steal 'this'
this.didFailLoad = _.bind(this.didFailLoad, this)
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this)
const logWebViewMessage = (event) => {
console.log('Message from SafeWebview:', event.message)
}
this.eventTuples = [
[ 'did-fail-load', this.didFailLoad ],
[ 'new-window', this.constructor.newWindow ],
[ 'console-message', logWebViewMessage ]
]
// Make a persistent electron session for the webview
this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, {
// Disable the cache for the session such that new content shows up when refreshing
cache: false
})
}
/**
* @returns {react.Element}
*/
render () {
return react.createElement('webview', {
ref: 'webview',
partition: ELECTRON_SESSION,
style: {
flex: this.state.shouldShow ? null : '0 1',
width: this.state.shouldShow ? null : '0',
height: this.state.shouldShow ? null : '0'
}
}, [])
}
/**
* @summary Add the Webview events
*/
componentDidMount () {
// Events React is unaware of have to be handled manually
_.map(this.eventTuples, (tuple) => {
this.refs.webview.addEventListener(...tuple)
})
this.session.webRequest.onCompleted(this.didGetResponseDetails)
// It's important that this comes after the partition setting, otherwise it will
// use another session and we can't change it without destroying the element again
this.refs.webview.src = this.entryHref
}
/**
* @summary Remove the Webview events
*/
componentWillUnmount () {
// Events that React is unaware of have to be handled manually
_.map(this.eventTuples, (tuple) => {
this.refs.webview.removeEventListener(...tuple)
})
this.session.webRequest.onCompleted(null)
}
/**
* @summary Set the element state to hidden
*/
didFailLoad () {
this.setState({
shouldShow: false
})
if (this.props.onWebviewShow) {
this.props.onWebviewShow(false)
}
}
/**
* @summary Set the element state depending on the HTTP response code
* @param {Event} event - Event object
*/
didGetResponseDetails (event) {
// This seems to pick up all requests related to the webview,
// only care about this event if it's a request for the main frame
if (event.resourceType === 'mainFrame') {
const HTTP_OK = 200
analytics.logEvent('SafeWebview loaded', {
event,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
this.setState({
shouldShow: event.statusCode === HTTP_OK
})
if (this.props.onWebviewShow) {
this.props.onWebviewShow(event.statusCode === HTTP_OK)
}
}
}
/**
* @summary Open link in browser if it's opened as a 'foreground-tab'
* @param {Event} event - event object
*/
static newWindow (event) {
const url = new window.URL(event.url)
if (_.every([
url.protocol === 'http:' || url.protocol === 'https:',
event.disposition === 'foreground-tab',
// Don't open links if they're disabled by the env var
!settings.get('disableExternalLinks')
])) {
electron.shell.openExternal(url.href)
}
}
}
SafeWebview.propTypes = {
/**
* @summary The website source URL
*/
src: propTypes.string.isRequired,
/**
* @summary Refresh the webview
*/
refreshNow: propTypes.bool,
/**
* @summary Webview lifecycle event
*/
onWebviewShow: propTypes.func
}
module.exports = SafeWebview

View File

@ -1,206 +0,0 @@
/*
* Copyright 2017 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as electron from 'electron';
import * as remote from '@electron/remote';
import * as _ from 'lodash';
import * as React from 'react';
import * as packageJSON from '../../../../../package.json';
import * as settings from '../../models/settings';
/**
* @summary Electron session identifier
*/
const ELECTRON_SESSION = 'persist:success-banner';
/**
* @summary Etcher version search-parameter key
*/
const ETCHER_VERSION_PARAM = 'etcher-version';
/**
* @summary API version search-parameter key
*/
const API_VERSION_PARAM = 'api-version';
/**
* @summary Opt-out analytics search-parameter key
*/
const OPT_OUT_ANALYTICS_PARAM = 'optOutAnalytics';
/**
* @summary Webview API version
*
* @description
* Changing this number represents a departure from an older API and as such
* should only be changed when truly necessary as it introduces breaking changes.
* This version number is exposed to the banner such that it can determine what
* features are safe to utilize.
*
* See `git blame -L n` where n is the line below for the history of version changes.
*/
const API_VERSION = '2';
interface SafeWebviewProps {
// The website source URL
src: string;
// Webview lifecycle event
onWebviewShow?: (isWebviewShowing: boolean) => void;
style?: React.CSSProperties;
}
interface SafeWebviewState {
shouldShow: boolean;
}
/**
* @summary Webviews that hide/show depending on the HTTP status returned
*/
export class SafeWebview extends React.PureComponent<
SafeWebviewProps,
SafeWebviewState
> {
private entryHref: string;
private session: electron.Session;
private webviewRef: React.RefObject<electron.WebviewTag>;
constructor(props: SafeWebviewProps) {
super(props);
this.webviewRef = React.createRef();
this.state = {
shouldShow: true,
};
const url = new window.URL(this.props.src);
// We set the version GET parameters here.
url.searchParams.set(ETCHER_VERSION_PARAM, packageJSON.version);
url.searchParams.set(API_VERSION_PARAM, API_VERSION);
url.searchParams.set(
OPT_OUT_ANALYTICS_PARAM,
(!settings.getSync('errorReporting')).toString(),
);
this.entryHref = url.href;
// Events steal 'this'
this.handleDomReady = _.bind(this.handleDomReady, this);
this.didFailLoad = _.bind(this.didFailLoad, this);
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this);
// Make a persistent electron session for the webview
this.session = remote.session.fromPartition(ELECTRON_SESSION, {
// Disable the cache for the session such that new content shows up when refreshing
cache: false,
});
}
private static logWebViewMessage(event: electron.ConsoleMessageEvent) {
console.log('Message from SafeWebview:', event.message);
}
public render() {
const {
style = {
flex: this.state.shouldShow ? undefined : '0 1',
width: this.state.shouldShow ? undefined : '0',
height: this.state.shouldShow ? undefined : '0',
},
} = this.props;
return (
<webview
ref={this.webviewRef}
partition={ELECTRON_SESSION}
style={style}
// @ts-ignore
allowpopups="true"
/>
);
}
// Add the Webview events
public componentDidMount() {
// Events React is unaware of have to be handled manually
if (this.webviewRef.current !== null) {
this.webviewRef.current.addEventListener(
'did-fail-load',
this.didFailLoad,
);
this.webviewRef.current.addEventListener(
'dom-ready',
this.handleDomReady,
);
this.webviewRef.current.addEventListener(
'console-message',
SafeWebview.logWebViewMessage,
);
this.session.webRequest.onCompleted(this.didGetResponseDetails);
// It's important that this comes after the partition setting, otherwise it will
// use another session and we can't change it without destroying the element again
this.webviewRef.current.src = this.entryHref;
}
}
// Remove the Webview events
public componentWillUnmount() {
// Events that React is unaware of have to be handled manually
if (this.webviewRef.current !== null) {
this.webviewRef.current.removeEventListener(
'did-fail-load',
this.didFailLoad,
);
this.webviewRef.current.removeEventListener(
'dom-ready',
this.handleDomReady,
);
this.webviewRef.current.removeEventListener(
'console-message',
SafeWebview.logWebViewMessage,
);
}
this.session.webRequest.onCompleted(null);
}
handleDomReady() {
const webview = this.webviewRef.current;
if (webview == null) {
return;
}
const id = webview.getWebContentsId();
electron.ipcRenderer.send('webview-dom-ready', id);
}
// Set the element state to hidden
public didFailLoad() {
this.setState({
shouldShow: false,
});
if (this.props.onWebviewShow) {
this.props.onWebviewShow(false);
}
}
// Set the element state depending on the HTTP response code
public didGetResponseDetails(event: electron.OnCompletedListenerDetails) {
// This seems to pick up all requests related to the webview,
// only care about this event if it's a request for the main frame
if (event.resourceType === 'mainFrame') {
const HTTP_OK = 200;
this.setState({
shouldShow: event.statusCode === HTTP_OK,
});
if (this.props.onWebviewShow) {
this.props.onWebviewShow(event.statusCode === HTTP_OK);
}
}
}
}

View File

@ -14,143 +14,214 @@
* limitations under the License.
*/
import GithubSvg from '@fortawesome/fontawesome-free/svgs/brands/github.svg';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import * as _ from 'lodash';
import * as os from 'os';
import * as React from 'react';
import { Box, Checkbox, Flex, Txt } from 'rendition';
import { Badge, Checkbox, Modal } from 'rendition';
import styled from 'styled-components';
import { version, packageType } from '../../../../../package.json';
import { version } from '../../../../../package.json';
import * as settings from '../../models/settings';
import * as store from '../../models/store';
import * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external';
import { Modal } from '../../styled-components';
import * as i18next from 'i18next';
import { etcherProInfo } from '../../utils/etcher-pro-specific';
const { useState } = React;
const platform = os.platform();
interface WarningModalProps {
message: string;
confirmLabel: string;
cancel: () => void;
done: () => void;
}
const WarningModal = ({
message,
confirmLabel,
cancel,
done,
}: WarningModalProps) => {
return (
<Modal
title={confirmLabel}
action={confirmLabel}
cancel={cancel}
done={done}
style={{
width: 420,
height: 300,
}}
primaryButtonProps={{ warning: true }}
>
{message}
</Modal>
);
};
interface Setting {
name: string;
label: string | JSX.Element;
options?: any;
hide?: boolean;
}
async function getSettingsList(): Promise<Setting[]> {
const list: Setting[] = [
{
name: 'errorReporting',
label: i18next.t('settings.errorReporting'),
const settingsList: Setting[] = [
{
name: 'errorReporting',
label: 'Anonymously report errors and usage statistics to balena.io',
},
{
name: 'unmountOnSuccess',
/**
* On Windows, "Unmounting" basically means "ejecting".
* On top of that, Windows users are usually not even
* familiar with the meaning of "unmount", which comes
* from the UNIX world.
*/
label: `${platform === 'win32' ? 'Eject' : 'Auto-unmount'} on success`,
},
{
name: 'validateWriteOnSuccess',
label: 'Validate write on success',
},
{
name: 'trim',
label: 'Trim ext{2,3,4} partitions before writing (raw images only)',
},
{
name: 'updatesEnabled',
label: 'Auto-updates enabled',
},
{
name: 'unsafeMode',
label: (
<span>
Unsafe mode{' '}
<Badge danger fontSize={12}>
Dangerous
</Badge>
</span>
),
options: {
description: `Are you sure you want to turn this on?
You will be able to overwrite your system drives if you're not careful.`,
confirmLabel: 'Enable unsafe mode',
},
{
name: 'autoBlockmapping',
label: i18next.t('settings.trimExtPartitions'),
},
];
if (['appimage', 'nsis', 'dmg'].includes(packageType)) {
list.push({
name: 'updatesEnabled',
label: i18next.t('settings.autoUpdate'),
});
}
return list;
}
hide: settings.get('disableUnsafeMode'),
},
];
interface SettingsModalProps {
toggleModal: (value: boolean) => void;
}
const EPInfo = etcherProInfo();
const InfoBox = (props: any) => (
<Box fontSize={14}>
<Txt>{props.label}</Txt>
<Txt code copy={props.value}>
{props.value}{' '}
</Txt>
</Box>
);
export function SettingsModal({ toggleModal }: SettingsModalProps) {
const [settingsList, setCurrentSettingsList] = React.useState<Setting[]>([]);
React.useEffect(() => {
(async () => {
if (settingsList.length === 0) {
setCurrentSettingsList(await getSettingsList());
}
})();
});
const [currentSettings, setCurrentSettings] = React.useState<
_.Dictionary<boolean>
>({});
React.useEffect(() => {
(async () => {
if (_.isEmpty(currentSettings)) {
setCurrentSettings(await settings.getAll());
}
})();
});
const toggleSetting = async (setting: string) => {
const value = currentSettings[setting];
await settings.set(setting, !value);
setCurrentSettings({
...currentSettings,
[setting]: !value,
});
};
return (
<Modal
titleElement={
<Txt fontSize={24} mb={24}>
{i18next.t('settings.settings')}
</Txt>
}
done={() => toggleModal(false)}
>
<Flex flexDirection="column">
{settingsList.map((setting: Setting, i: number) => {
return (
<Flex key={setting.name} mb={14}>
<Checkbox
toggle
tabIndex={6 + i}
label={setting.label}
checked={currentSettings[setting.name]}
onChange={() => toggleSetting(setting.name)}
/>
</Flex>
);
})}
{EPInfo !== undefined && (
<Flex flexDirection="column">
<Txt fontSize={24}>{i18next.t('settings.systemInformation')}</Txt>
{EPInfo.get_serial() === undefined ? (
<InfoBox label="UUID" value={EPInfo.uuid} />
) : (
<InfoBox label="Serial" value={EPInfo.get_serial()} />
)}
</Flex>
)}
<Flex
mt={18}
alignItems="center"
color="#00aeef"
style={{
width: 'fit-content',
cursor: 'pointer',
fontSize: 14,
}}
onClick={() =>
openExternal(
'https://github.com/balena-io/etcher/blob/master/CHANGELOG.md',
)
}
>
<GithubSvg
height="1em"
fill="currentColor"
style={{ marginRight: 8 }}
/>
<Txt style={{ borderBottom: '1px solid #00aeef' }}>{version}</Txt>
</Flex>
</Flex>
</Modal>
);
interface Dictionary<T> {
[key: string]: T;
}
export const SettingsModal: any = styled(
({ toggleModal }: SettingsModalProps) => {
const [currentSettings, setCurrentSettings]: [
Dictionary<any>,
React.Dispatch<React.SetStateAction<Dictionary<any>>>,
] = useState(settings.getAll());
const [warning, setWarning]: [
any,
React.Dispatch<React.SetStateAction<any>>,
] = useState({});
const toggleSetting = async (setting: string, options?: any) => {
const value = currentSettings[setting];
const dangerous = !_.isUndefined(options);
analytics.logEvent('Toggle setting', {
setting,
value,
dangerous,
// @ts-ignore
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
});
if (value || !dangerous) {
await settings.set(setting, !value);
setCurrentSettings({
...currentSettings,
[setting]: !value,
});
setWarning({});
return;
}
// Show warning since it's a dangerous setting
setWarning({
setting,
settingValue: value,
...options,
});
};
return (
<Modal
id="settings-modal"
title="Settings"
done={() => toggleModal(false)}
style={{
width: 780,
height: 420,
}}
>
<div>
{_.map(settingsList, (setting: Setting, i: number) => {
return setting.hide ? null : (
<div key={setting.name}>
<Checkbox
toggle
tabIndex={6 + i}
label={setting.label}
checked={currentSettings[setting.name]}
onChange={() => toggleSetting(setting.name, setting.options)}
/>
</div>
);
})}
<div>
<span
onClick={() =>
openExternal(
'https://github.com/balena-io/etcher/blob/master/CHANGELOG.md',
)
}
>
<FontAwesomeIcon icon={faGithub} /> {version}
</span>
</div>
</div>
{_.isEmpty(warning) ? null : (
<WarningModal
message={warning.description}
confirmLabel={warning.confirmLabel}
done={() => {
settings.set(warning.setting, !warning.settingValue);
setCurrentSettings({
...currentSettings,
[warning.setting]: true,
});
setWarning({});
}}
cancel={() => {
setWarning({});
}}
/>
)}
</Modal>
);
},
)`
> div:nth-child(3) {
justify-content: center;
}
`;

View File

@ -1,785 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import CopySvg from '@fortawesome/fontawesome-free/svgs/solid/copy.svg';
import FileSvg from '@fortawesome/fontawesome-free/svgs/solid/file.svg';
import LinkSvg from '@fortawesome/fontawesome-free/svgs/solid/link.svg';
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/triangle-exclamation.svg';
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg';
import ChevronRightSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-right.svg';
import type { IpcRendererEvent } from 'electron';
import { ipcRenderer } from 'electron';
import { uniqBy, isNil } from 'lodash';
import * as path from 'path';
import prettyBytes from 'pretty-bytes';
import * as React from 'react';
import { requestMetadata } from '../../app';
import type { ButtonProps } from 'rendition';
import {
Flex,
Modal as SmallModal,
Txt,
Card as BaseCard,
Input,
Spinner,
Link,
} from 'rendition';
import styled from 'styled-components';
import * as errors from '../../../../shared/errors';
import * as messages from '../../../../shared/messages';
import * as supportedFormats from '../../../../shared/supported-formats';
import * as selectionState from '../../models/selection-state';
import { observe } from '../../models/store';
import * as analytics from '../../modules/analytics';
import * as exceptionReporter from '../../modules/exception-reporter';
import * as osDialog from '../../os/dialog';
import {
ChangeButton,
DetailsText,
Modal,
StepButton,
StepNameButton,
ScrollableFlex,
} from '../../styled-components';
import { colors } from '../../theme';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import { SVGIcon } from '../svg-icon/svg-icon';
import ImageSvg from '../../../assets/image.svg';
import SrcSvg from '../../../assets/src.svg';
import { DriveSelector } from '../drive-selector/drive-selector';
import type { DrivelistDrive } from '../../../../shared/drive-constraints';
import { isJson } from '../../../../shared/utils';
import type {
SourceMetadata,
Authentication,
Source,
} from '../../../../shared/typings/source-selector';
import * as i18next from 'i18next';
const recentUrlImagesKey = 'recentUrlImages';
function normalizeRecentUrlImages(urls: any[]): URL[] {
if (!Array.isArray(urls)) {
urls = [];
}
urls = urls
.map((url) => {
try {
return new URL(url);
} catch (error: any) {
// Invalid URL, skip
}
})
.filter((url) => url !== undefined);
urls = uniqBy(urls, (url) => url.href);
return urls.slice(urls.length - 5);
}
function getRecentUrlImages(): URL[] {
let urls = [];
try {
urls = JSON.parse(localStorage.getItem(recentUrlImagesKey) || '[]');
} catch {
// noop
}
return normalizeRecentUrlImages(urls);
}
function setRecentUrlImages(urls: URL[]) {
const normalized = normalizeRecentUrlImages(urls.map((url: URL) => url.href));
localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized));
}
const isURL = (imagePath: string) =>
imagePath.startsWith('https://') || imagePath.startsWith('http://');
const Card = styled(BaseCard)`
hr {
margin: 5px 0;
}
`;
// TODO move these styles to rendition
const ModalText = styled.p`
a {
color: rgb(0, 174, 239);
&:hover {
color: rgb(0, 139, 191);
}
}
`;
function getState() {
const image = selectionState.getImage();
return {
hasImage: selectionState.hasImage(),
imageName: image?.name,
imageSize: image?.size,
};
}
function isString(value: any): value is string {
return typeof value === 'string';
}
const URLSelector = ({
done,
cancel,
}: {
done: (imageURL: string, auth?: Authentication) => void;
cancel: () => void;
}) => {
const [imageURL, setImageURL] = React.useState('');
const [recentImages, setRecentImages] = React.useState<URL[]>([]);
const [loading, setLoading] = React.useState(false);
const [showBasicAuth, setShowBasicAuth] = React.useState(false);
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
React.useEffect(() => {
const fetchRecentUrlImages = async () => {
const recentUrlImages: URL[] = await getRecentUrlImages();
setRecentImages(recentUrlImages);
};
fetchRecentUrlImages();
}, []);
return (
<Modal
cancel={cancel}
primaryButtonProps={{
disabled: loading || !imageURL,
}}
action={loading ? <Spinner /> : i18next.t('ok')}
done={async () => {
setLoading(true);
const urlStrings = recentImages.map((url: URL) => url.href);
const normalizedRecentUrls = normalizeRecentUrlImages([
...urlStrings,
imageURL,
]);
setRecentUrlImages(normalizedRecentUrls);
const auth = username ? { username, password } : undefined;
await done(imageURL, auth);
}}
>
<Flex flexDirection="column">
<Flex mb={15} style={{ width: '100%' }} flexDirection="column">
<Txt mb="10px" fontSize="24px">
{i18next.t('source.useSourceURL')}
</Txt>
<Input
value={imageURL}
placeholder={i18next.t('source.enterValidURL')}
type="text"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setImageURL(evt.target.value)
}
/>
<Link
mt={15}
mb={15}
fontSize="14px"
onClick={() => {
if (showBasicAuth) {
setUsername('');
setPassword('');
}
setShowBasicAuth(!showBasicAuth);
}}
>
<Flex alignItems="center">
{showBasicAuth && (
<ChevronDownSvg height="1em" fill="currentColor" />
)}
{!showBasicAuth && (
<ChevronRightSvg height="1em" fill="currentColor" />
)}
<Txt ml={8}>{i18next.t('source.auth')}</Txt>
</Flex>
</Link>
{showBasicAuth && (
<React.Fragment>
<Input
mb={15}
value={username}
placeholder={i18next.t('source.username')}
type="text"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setUsername(evt.target.value)
}
/>
<Input
value={password}
placeholder={i18next.t('source.password')}
type="password"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setPassword(evt.target.value)
}
/>
</React.Fragment>
)}
</Flex>
{recentImages.length > 0 && (
<Flex flexDirection="column" height="78.6%">
<Txt fontSize={18}>Recent</Txt>
<ScrollableFlex flexDirection="column">
<Card
p="10px 15px"
rows={recentImages
.map((recent) => (
<Txt
key={recent.href}
onClick={() => {
setImageURL(recent.href);
}}
style={{
overflowWrap: 'break-word',
}}
>
{recent.pathname.split('/').pop()} - {recent.href}
</Txt>
))
.reverse()}
/>
</ScrollableFlex>
</Flex>
)}
</Flex>
</Modal>
);
};
interface Flow {
icon?: JSX.Element;
onClick: (evt: React.MouseEvent) => void;
label: string;
}
const FlowSelector = styled(
({ flow, ...props }: { flow: Flow } & ButtonProps) => (
<StepButton
plain={!props.primary}
primary={props.primary}
onClick={(evt: React.MouseEvent<Element, MouseEvent>) =>
flow.onClick(evt)
}
icon={flow.icon}
{...props}
>
{flow.label}
</StepButton>
),
)`
border-radius: 24px;
color: rgba(255, 255, 255, 0.7);
:enabled:focus,
:enabled:focus svg {
color: ${colors.primary.foreground} !important;
}
:enabled:hover {
background-color: ${colors.primary.background};
color: ${colors.primary.foreground};
font-weight: 600;
svg {
color: ${colors.primary.foreground} !important;
}
}
`;
interface SourceSelectorProps {
flashing: boolean;
hideAnalyticsAlert: () => void;
}
interface SourceSelectorState {
hasImage: boolean;
imageName?: string;
imageSize?: number;
warning: { message: string; title: string | null } | null;
showImageDetails: boolean;
showURLSelector: boolean;
showDriveSelector: boolean;
defaultFlowActive: boolean;
imageSelectorOpen: boolean;
imageLoading: boolean;
}
export class SourceSelector extends React.Component<
SourceSelectorProps,
SourceSelectorState
> {
private unsubscribe: (() => void) | undefined;
constructor(props: SourceSelectorProps) {
super(props);
this.state = {
...getState(),
warning: null,
showImageDetails: false,
showURLSelector: false,
showDriveSelector: false,
defaultFlowActive: true,
imageSelectorOpen: false,
imageLoading: false,
};
// Bind `this` since it's used in an event's callback
this.onSelectImage = this.onSelectImage.bind(this);
}
public componentDidMount() {
this.unsubscribe = observe(() => {
this.setState(getState());
});
ipcRenderer.on('select-image', this.onSelectImage);
ipcRenderer.send('source-selector-ready');
}
public componentWillUnmount() {
this.unsubscribe?.();
ipcRenderer.removeListener('select-image', this.onSelectImage);
}
public componentDidUpdate(
_prevProps: Readonly<SourceSelectorProps>,
prevState: Readonly<SourceSelectorState>,
) {
if (
(!prevState.showDriveSelector && this.state.showDriveSelector) ||
(!prevState.showURLSelector && this.state.showURLSelector) ||
(!prevState.showImageDetails && this.state.showImageDetails) ||
(!prevState.imageSelectorOpen && this.state.imageSelectorOpen)
) {
this.props.hideAnalyticsAlert();
}
}
private async onSelectImage(_event: IpcRendererEvent, imagePath: string) {
this.setState({ imageLoading: true });
await this.selectSource(
imagePath,
isURL(this.normalizeImagePath(imagePath)) ? 'Http' : 'File',
).promise;
this.setState({ imageLoading: false });
}
public normalizeImagePath(imgPath: string) {
const decodedPath = decodeURIComponent(imgPath);
if (isJson(decodedPath)) {
return JSON.parse(decodedPath).url ?? decodedPath;
}
return decodedPath;
}
private reselectSource() {
selectionState.deselectImage();
this.props.hideAnalyticsAlert();
}
private selectSource(
selected: string | DrivelistDrive,
SourceType: Source,
auth?: Authentication,
): { promise: Promise<void>; cancel: () => void } {
return {
cancel: () => {
// noop
},
promise: (async () => {
const sourcePath = isString(selected) ? selected : selected.device;
let metadata: SourceMetadata | undefined;
if (isString(selected)) {
if (
SourceType === 'Http' &&
!isURL(this.normalizeImagePath(selected))
) {
this.handleError(
i18next.t('source.unsupportedProtocol'),
selected,
messages.error.unsupportedProtocol(),
);
return;
}
if (supportedFormats.looksLikeWindowsImage(selected)) {
this.setState({
warning: {
message: messages.warning.looksLikeWindowsImage(),
title: i18next.t('source.windowsImage'),
},
});
}
try {
// this will send an event down the ipcMain asking for metadata
// we'll get the response through an event
// FIXME: This is a poor man wait while loading to prevent a potential race condition without completely blocking the interface
// This should be addressed when refactoring the GUI
let retriesLeft = 10;
while (requestMetadata === undefined && retriesLeft > 0) {
await new Promise((resolve) => setTimeout(resolve, 1050)); // api is trying to connect every 1000, this is offset to make sure we fall between retries
retriesLeft--;
}
metadata = await requestMetadata({ selected, SourceType, auth });
if (!metadata?.hasMBR && this.state.warning === null) {
this.setState({
warning: {
message: messages.warning.missingPartitionTable(),
title: i18next.t('source.partitionTable'),
},
});
}
} catch (error: any) {
this.handleError(
i18next.t('source.errorOpen'),
sourcePath,
messages.error.openSource(sourcePath, error.message),
error,
);
}
} else {
if (selected.partitionTableType === null) {
this.setState({
warning: {
message: messages.warning.driveMissingPartitionTable(),
title: i18next.t('source.partitionTable'),
},
});
}
metadata = {
path: selected.device,
displayName: selected.displayName,
description: selected.displayName,
size: selected.size as SourceMetadata['size'],
SourceType: 'BlockDevice',
drive: selected,
};
}
if (metadata !== undefined) {
metadata.auth = auth;
metadata.SourceType = SourceType;
selectionState.selectSource(metadata);
}
})(),
};
}
private handleError(
title: string,
sourcePath: string,
description: string,
error?: Error,
) {
const imageError = errors.createUserError({
title,
description,
});
osDialog.showError(imageError);
if (error) {
analytics.logException(error);
return;
}
}
private async openImageSelector() {
this.setState({ imageSelectorOpen: true });
try {
const imagePath = await osDialog.selectImage();
// Avoid analytics and selection state changes
// if no file was resolved from the dialog.
if (!imagePath) {
return;
}
await this.selectSource(imagePath, 'File').promise;
} catch (error: any) {
exceptionReporter.report(error);
} finally {
this.setState({ imageSelectorOpen: false });
}
}
private async onDrop(event: React.DragEvent<HTMLDivElement>) {
const file = event.dataTransfer.files.item(0);
if (file != null) {
await this.selectSource(file.path, 'File').promise;
}
}
private openURLSelector() {
this.setState({
showURLSelector: true,
});
}
private openDriveSelector() {
this.setState({
showDriveSelector: true,
});
}
private onDragOver(event: React.DragEvent<HTMLDivElement>) {
// Needed to get onDrop events on div elements
event.preventDefault();
}
private onDragEnter(event: React.DragEvent<HTMLDivElement>) {
// Needed to get onDrop events on div elements
event.preventDefault();
}
private showSelectedImageDetails() {
this.setState({
showImageDetails: true,
});
}
private setDefaultFlowActive(defaultFlowActive: boolean) {
this.setState({ defaultFlowActive });
}
private closeModal() {
this.setState({
showDriveSelector: false,
});
}
// TODO add a visual change when dragging a file over the selector
public render() {
const { flashing } = this.props;
const {
showImageDetails,
showURLSelector,
showDriveSelector,
imageLoading,
} = this.state;
const selectionImage = selectionState.getImage();
let image =
selectionImage !== undefined ? selectionImage : ({} as SourceMetadata);
image = image.drive ?? image;
let cancelURLSelection = () => {
// noop
};
image.name = image.description || image.name;
const imagePath = image.path || image.displayName || '';
const imageBasename = path.basename(imagePath);
const imageName = image.name || '';
const imageSize = image.size;
const imageLogo = image.logo || '';
return (
<>
<Flex
flexDirection="column"
alignItems="center"
onDrop={(evt: React.DragEvent<HTMLDivElement>) => this.onDrop(evt)}
onDragEnter={(evt: React.DragEvent<HTMLDivElement>) =>
this.onDragEnter(evt)
}
onDragOver={(evt: React.DragEvent<HTMLDivElement>) =>
this.onDragOver(evt)
}
>
<SVGIcon
contents={imageLogo}
fallback={ImageSvg}
style={{
marginBottom: 30,
}}
/>
{selectionImage !== undefined || imageLoading ? (
<>
<StepNameButton
plain
onClick={() => this.showSelectedImageDetails()}
tooltip={imageName || imageBasename}
>
<Spinner show={imageLoading}>
{middleEllipsis(imageName || imageBasename, 20)}
</Spinner>
</StepNameButton>
{!flashing && !imageLoading && (
<ChangeButton
plain
mb={14}
onClick={() => this.reselectSource()}
>
{i18next.t('cancel')}
</ChangeButton>
)}
{!isNil(imageSize) && !imageLoading && (
<DetailsText>{prettyBytes(imageSize)}</DetailsText>
)}
</>
) : (
<>
<FlowSelector
disabled={this.state.imageSelectorOpen}
primary={this.state.defaultFlowActive}
key="Flash from file"
flow={{
onClick: () => this.openImageSelector(),
label: i18next.t('source.fromFile'),
icon: <FileSvg height="1em" fill="currentColor" />,
}}
onMouseEnter={() => this.setDefaultFlowActive(false)}
onMouseLeave={() => this.setDefaultFlowActive(true)}
/>
<FlowSelector
key="Flash from URL"
flow={{
onClick: () => this.openURLSelector(),
label: i18next.t('source.fromURL'),
icon: <LinkSvg height="1em" fill="currentColor" />,
}}
onMouseEnter={() => this.setDefaultFlowActive(false)}
onMouseLeave={() => this.setDefaultFlowActive(true)}
/>
<FlowSelector
key="Clone drive"
flow={{
onClick: () => this.openDriveSelector(),
label: i18next.t('source.clone'),
icon: <CopySvg height="1em" fill="currentColor" />,
}}
onMouseEnter={() => this.setDefaultFlowActive(false)}
onMouseLeave={() => this.setDefaultFlowActive(true)}
/>
</>
)}
</Flex>
{this.state.warning != null && (
<SmallModal
style={{
boxShadow: '0 3px 7px rgba(0, 0, 0, 0.3)',
}}
title={
<span>
<ExclamationTriangleSvg fill="#fca321" height="1em" />{' '}
<span>{this.state.warning.title}</span>
</span>
}
action={i18next.t('continue')}
cancel={() => {
this.setState({ warning: null });
this.reselectSource();
}}
done={() => {
this.setState({ warning: null });
}}
primaryButtonProps={{ warning: true, primary: false }}
>
<ModalText
dangerouslySetInnerHTML={{ __html: this.state.warning.message }}
/>
</SmallModal>
)}
{showImageDetails && (
<SmallModal
title={i18next.t('source.image')}
done={() => {
this.setState({ showImageDetails: false });
}}
>
<Txt.p>
<Txt.span bold>{i18next.t('source.name')}</Txt.span>
<Txt.span>{imageName || imageBasename}</Txt.span>
</Txt.p>
<Txt.p>
<Txt.span bold>{i18next.t('source.path')}</Txt.span>
<Txt.span>{imagePath}</Txt.span>
</Txt.p>
</SmallModal>
)}
{showURLSelector && (
<URLSelector
cancel={() => {
cancelURLSelection();
this.setState({
showURLSelector: false,
});
}}
done={async (imageURL: string, auth?: Authentication) => {
// Avoid analytics and selection state changes
// if no file was resolved from the dialog.
if (imageURL) {
let promise;
({ promise, cancel: cancelURLSelection } = this.selectSource(
imageURL,
'Http',
auth,
));
await promise;
}
this.setState({
showURLSelector: false,
});
}}
/>
)}
{showDriveSelector && (
<DriveSelector
write={false}
multipleSelection={false}
titleLabel={i18next.t('source.selectSource')}
emptyListLabel={i18next.t('source.plugSource')}
emptyListIcon={<SrcSvg width="40px" />}
cancel={(originalList) => {
if (originalList.length) {
const originalSource = originalList[0];
if (selectionImage?.drive?.device !== originalSource.device) {
this.selectSource(originalSource, 'BlockDevice');
}
} else {
selectionState.deselectImage();
}
this.closeModal();
}}
done={() => this.closeModal()}
onSelect={(drive) => {
if (drive) {
if (
selectionState.getImage()?.drive?.device === drive?.device
) {
return selectionState.deselectImage();
}
this.selectSource(drive, 'BlockDevice');
}
}}
/>
)}
</>
);
}
}

View File

@ -14,20 +14,19 @@
* limitations under the License.
*/
import type { DrivelistDrive } from '../../../shared/drive-constraints';
import { Actions, store } from './store';
'use strict'
export function hasAvailableDrives() {
return getDrives().length > 0;
}
/* eslint-disable jsdoc/require-example */
export function setDrives(drives: any[]) {
store.dispatch({
type: Actions.SET_AVAILABLE_TARGETS,
data: drives,
});
}
/**
* @module Etcher.Components.SVGIcon
*/
export function getDrives(): DrivelistDrive[] {
return store.getState().toJS().availableDrives;
}
const angular = require('angular')
const react2angular = require('react2angular').react2angular
const MODULE_NAME = 'Etcher.Components.SVGIcon'
const angularSVGIcon = angular.module(MODULE_NAME, [])
angularSVGIcon.component('svgIcon', react2angular(require('./svg-icon.jsx')))
module.exports = MODULE_NAME

View File

@ -0,0 +1,9 @@
svg-icon {
display: inline-block;
img {
width: 100%;
height: 100%;
}
}

View File

@ -0,0 +1,167 @@
/*
* Copyright 2018 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
/**
* @module Etcher.Components.SVGIcon
*/
const _ = require('lodash')
const react = require('react')
const propTypes = require('prop-types')
const path = require('path')
const fs = require('fs')
const analytics = require('../../modules/analytics')
const domParser = new window.DOMParser()
const DEFAULT_SIZE = '40px'
/**
* @summary Try to parse SVG contents and return it data encoded
*
* @param {String} contents - SVG XML contents
* @returns {String|null}
*
* @example
* const encodedSVG = tryParseSVGContents('<svg><path></path></svg>')
*
* img.src = encodedSVG
*/
const tryParseSVGContents = (contents) => {
const doc = domParser.parseFromString(contents, 'image/svg+xml')
const parserError = doc.querySelector('parsererror')
const svg = doc.querySelector('svg')
if (!parserError && svg) {
return `data:image/svg+xml,${encodeURIComponent(svg.outerHTML)}`
}
return null
}
/* eslint-disable jsdoc/require-example */
/**
* @summary SVG element that takes both filepaths and file contents
* @type {Object}
* @public
*/
class SVGIcon extends react.Component {
/**
* @summary Render the SVG
* @returns {react.Element}
*/
render () {
// __dirname behaves strangely inside a Webpack bundle,
// so we need to provide different base directories
// depending on whether __dirname is absolute or not,
// which helps detecting a Webpack bundle.
// We use global.__dirname inside a Webpack bundle since
// that's the only way to get the "real" __dirname.
const baseDirectory = path.isAbsolute(__dirname)
? path.join(__dirname, '..')
// eslint-disable-next-line no-underscore-dangle
: global.__dirname
let svgData = ''
_.find(this.props.contents, (content) => {
const attempt = tryParseSVGContents(content)
if (attempt) {
svgData = attempt
return true
}
return false
})
if (!svgData) {
_.find(this.props.paths, (relativePath) => {
// This means the path to the icon should be
// relative to *this directory*.
// TODO: There might be a way to compute the path
// relatively to the `index.html`.
const imagePath = path.join(baseDirectory, 'assets', relativePath)
const contents = _.attempt(() => {
return fs.readFileSync(imagePath, {
encoding: 'utf8'
})
})
if (_.isError(contents)) {
analytics.logException(contents)
return false
}
const parsed = _.attempt(tryParseSVGContents, contents)
if (parsed) {
svgData = parsed
return true
}
return false
})
}
const width = this.props.width || DEFAULT_SIZE
const height = this.props.height || DEFAULT_SIZE
return react.createElement('img', {
className: 'svg-icon',
style: {
width,
height
},
src: svgData,
disabled: this.props.disabled
})
}
}
SVGIcon.propTypes = {
/**
* @summary Paths to SVG files to be tried in succession if any fails
*/
paths: propTypes.array,
/**
* @summary List of embedded SVG contents to be tried in succession if any fails
*/
contents: propTypes.array,
/**
* @summary SVG image width unit
*/
width: propTypes.string,
/**
* @summary SVG image height unit
*/
height: propTypes.string,
/**
* @summary Should the element visually appear grayed out and disabled?
*/
disabled: propTypes.bool
}
module.exports = SVGIcon

View File

@ -1,74 +0,0 @@
/*
* Copyright 2018 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
const domParser = new window.DOMParser();
const DEFAULT_SIZE = '40px';
/**
* @summary Try to parse SVG contents and return it data encoded
*
*/
function tryParseSVGContents(contents?: string): string | undefined {
if (contents === undefined) {
return;
}
const doc = domParser.parseFromString(contents, 'image/svg+xml');
const parserError = doc.querySelector('parsererror');
const svg = doc.querySelector('svg');
if (!parserError && svg) {
return `data:image/svg+xml,${encodeURIComponent(svg.outerHTML)}`;
}
}
interface SVGIconProps {
// Optional string representing the SVG contents to be tried
contents?: string;
// Fallback SVG element to show if `contents` is invalid/undefined
fallback: React.FunctionComponent<React.SVGProps<HTMLOrSVGElement>>;
// SVG image width unit
width?: string;
// SVG image height unit
height?: string;
// Should the element visually appear grayed out and disabled?
disabled?: boolean;
style?: React.CSSProperties;
}
/**
* @summary SVG element that takes file contents
*/
export class SVGIcon extends React.PureComponent<SVGIconProps> {
public render() {
const svgData = tryParseSVGContents(this.props.contents);
const { width, height, style = {} } = this.props;
style.width = width || DEFAULT_SIZE;
style.height = height || DEFAULT_SIZE;
if (svgData !== undefined) {
return (
<img
className={this.props.disabled ? 'disabled' : ''}
style={style}
src={svgData}
/>
);
}
const { fallback: FallbackSVG } = this.props;
return <FallbackSVG style={style} />;
}
}

View File

@ -1,157 +0,0 @@
/*
* Copyright 2019 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/triangle-exclamation.svg';
import * as React from 'react';
import type { FlexProps } from 'rendition';
import { Flex, Txt } from 'rendition';
import type { DriveStatus } from '../../../../shared/drive-constraints';
import { getDriveImageCompatibilityStatuses } from '../../../../shared/drive-constraints';
import { compatibility, warning } from '../../../../shared/messages';
import prettyBytes from 'pretty-bytes';
import { getImage, getSelectedDrives } from '../../models/selection-state';
import {
ChangeButton,
DetailsText,
StepButton,
StepNameButton,
} from '../../styled-components';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import * as i18next from 'i18next';
interface TargetSelectorProps {
targets: any[];
disabled: boolean;
openDriveSelector: () => void;
reselectDrive: () => void;
flashing: boolean;
show: boolean;
tooltip: string;
}
function getDriveWarning(status: DriveStatus) {
switch (status.message) {
case compatibility.containsImage():
return warning.sourceDrive();
case compatibility.largeDrive():
return warning.largeDriveSize();
case compatibility.system():
return warning.systemDrive();
default:
return '';
}
}
const DriveCompatibilityWarning = ({
warnings,
...props
}: {
warnings: string[];
} & FlexProps) => {
const systemDrive = warnings.find(
(message) => message === warning.systemDrive(),
);
return (
<Flex tooltip={warnings.join(', ')} {...props}>
<ExclamationTriangleSvg
fill={systemDrive ? '#fca321' : '#8f9297'}
height="1em"
/>
</Flex>
);
};
export function TargetSelectorButton(props: TargetSelectorProps) {
const targets = getSelectedDrives();
if (targets.length === 1) {
const target = targets[0];
const warnings = getDriveImageCompatibilityStatuses(
target,
getImage(),
true,
).map(getDriveWarning);
return (
<>
<StepNameButton plain tooltip={props.tooltip}>
{warnings.length > 0 && (
<DriveCompatibilityWarning warnings={warnings} mr={2} />
)}
{middleEllipsis(target.description, 20)}
</StepNameButton>
{!props.flashing && (
<ChangeButton plain mb={14} onClick={props.reselectDrive}>
{i18next.t('target.change')}
</ChangeButton>
)}
{target.size != null && (
<DetailsText>{prettyBytes(target.size)}</DetailsText>
)}
</>
);
}
if (targets.length > 1) {
const targetsTemplate = [];
for (const target of targets) {
const warnings = getDriveImageCompatibilityStatuses(
target,
getImage(),
true,
).map(getDriveWarning);
targetsTemplate.push(
<DetailsText
key={target.device}
tooltip={`${target.description} ${target.displayName} ${
target.size != null ? prettyBytes(target.size) : ''
}`}
px={21}
>
{warnings.length > 0 ? (
<DriveCompatibilityWarning warnings={warnings} mr={2} />
) : null}
<Txt mr={2}>{middleEllipsis(target.description, 14)}</Txt>
{target.size != null && <Txt>{prettyBytes(target.size)}</Txt>}
</DetailsText>,
);
}
return (
<>
<StepNameButton plain tooltip={props.tooltip}>
{targets.length} {i18next.t('target.targets')}
</StepNameButton>
{!props.flashing && (
<ChangeButton plain onClick={props.reselectDrive} mb={14}>
{i18next.t('target.change')}
</ChangeButton>
)}
{targetsTemplate}
</>
);
}
return (
<StepButton
primary
tabIndex={targets.length > 0 ? -1 : 2}
disabled={props.disabled}
onClick={props.openDriveSelector}
>
{i18next.t('target.selectTarget')}
</StepButton>
);
}

View File

@ -1,181 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import { Flex, Txt } from 'rendition';
import type { DriveSelectorProps } from '../drive-selector/drive-selector';
import { DriveSelector } from '../drive-selector/drive-selector';
import {
getImage,
getSelectedDrives,
deselectDrive,
selectDrive,
deselectAllDrives,
} from '../../models/selection-state';
import { observe } from '../../models/store';
import { TargetSelectorButton } from './target-selector-button';
import TgtSvg from '../../../assets/tgt.svg';
import DriveSvg from '../../../assets/drive.svg';
import { warning } from '../../../../shared/messages';
import type { DrivelistDrive } from '../../../../shared/drive-constraints';
import * as i18next from 'i18next';
export const getDriveListLabel = () => {
return getSelectedDrives()
.map((drive: any) => {
return `${drive.description} (${drive.displayName})`;
})
.join('\n');
};
const getDriveSelectionStateSlice = () => ({
driveListLabel: getDriveListLabel(),
targets: getSelectedDrives(),
image: getImage(),
});
export const TargetSelectorModal = (
props: Omit<
DriveSelectorProps,
'titleLabel' | 'emptyListLabel' | 'multipleSelection' | 'emptyListIcon'
>,
) => (
<DriveSelector
multipleSelection={true}
titleLabel={i18next.t('target.selectTarget')}
emptyListLabel={i18next.t('target.plugTarget')}
emptyListIcon={<TgtSvg width="40px" />}
showWarnings={true}
selectedList={getSelectedDrives()}
updateSelectedList={getSelectedDrives}
{...props}
/>
);
export const selectAllTargets = (modalTargets: DrivelistDrive[]) => {
const selectedDrivesFromState = getSelectedDrives();
const deselected = selectedDrivesFromState.filter(
(drive) =>
!modalTargets.find((modalTarget) => modalTarget.device === drive.device),
);
// deselect drives
deselected.forEach((drive) => {
deselectDrive(drive.device);
});
// select drives
modalTargets.forEach((drive) => {
selectDrive(drive.device);
});
};
interface TargetSelectorProps {
disabled: boolean;
hasDrive: boolean;
flashing: boolean;
hideAnalyticsAlert: () => void;
}
export const TargetSelector = ({
disabled,
hasDrive,
flashing,
hideAnalyticsAlert,
}: TargetSelectorProps) => {
// TODO: inject these from redux-connector
const [{ driveListLabel, targets }, setStateSlice] = React.useState(
getDriveSelectionStateSlice(),
);
const [showTargetSelectorModal, setShowTargetSelectorModal] =
React.useState(false);
React.useEffect(() => {
return observe(() => {
setStateSlice(getDriveSelectionStateSlice());
});
}, []);
const hasSystemDrives = targets.some((target) => target.isSystem);
return (
<Flex flexDirection="column" alignItems="center">
<DriveSvg
className={disabled ? 'disabled' : ''}
width="40px"
style={{
marginBottom: 30,
}}
/>
<TargetSelectorButton
disabled={disabled}
show={!hasDrive}
tooltip={driveListLabel}
openDriveSelector={() => {
setShowTargetSelectorModal(true);
hideAnalyticsAlert();
}}
reselectDrive={() => {
setShowTargetSelectorModal(true);
}}
flashing={flashing}
targets={targets}
/>
{hasSystemDrives ? (
<Txt
color="#fca321"
style={{
position: 'absolute',
bottom: '25px',
}}
>
Warning: {warning.systemDrive()}
</Txt>
) : null}
{showTargetSelectorModal && (
<TargetSelectorModal
write={true}
cancel={(originalList) => {
if (originalList.length) {
selectAllTargets(originalList);
} else {
deselectAllDrives();
}
setShowTargetSelectorModal(false);
}}
done={(modalTargets) => {
if (modalTargets.length === 0) {
deselectAllDrives();
}
setShowTargetSelectorModal(false);
}}
onSelect={(drive) => {
if (
getSelectedDrives().find(
(selectedDrive) => selectedDrive.device === drive.device,
)
) {
return deselectDrive(drive.device);
}
selectDrive(drive.device);
}}
/>
)}
</Flex>
);
};

View File

@ -1,66 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@font-face {
font-family: 'SourceSansPro';
src: url('./fonts/SourceSansPro-Regular.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'SourceSansPro';
src: url('./fonts/SourceSansPro-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
html,
body {
margin: 0;
overflow: hidden;
/* Prevent white flash when running application */
background-color: #4d5057;
/* Prevent WebView bounce effect in OS X */
height: 100%;
width: 100%;
}
/* Prevent text selection */
body {
-webkit-user-select: none;
-webkit-overflow-scrolling: touch;
}
/* Prevent blue outline */
a:focus,
input:focus,
button:focus,
[tabindex]:focus,
input[type='checkbox'] + div {
outline: none !important;
box-shadow: none !important;
}
.disabled {
opacity: 0.4;
}
#rendition-tooltip-root > div {
font-family: 'SourceSansPro', sans-serif;
}

View File

@ -1,44 +0,0 @@
import * as i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import zh_CN_translation from './i18n/zh-CN';
import zh_TW_translation from './i18n/zh-TW';
import en_translation from './i18n/en';
export function langParser() {
if (process.env.LANG !== undefined) {
// Bypass mocha, where lang-detect don't works
return 'en';
}
const lang = Intl.DateTimeFormat().resolvedOptions().locale;
switch (lang.substr(0, 2)) {
case 'zh':
if (lang === 'zh-CN' || lang === 'zh-SG') {
return 'zh-CN';
} // Simplified Chinese
else {
return 'zh-TW';
} // Traditional Chinese
default:
return lang.substr(0, 2);
}
}
i18next.use(initReactI18next).init({
lng: langParser(),
fallbackLng: 'en',
nonExplicitSupportedLngs: true,
interpolation: {
escapeValue: false,
},
resources: {
'zh-CN': zh_CN_translation,
'zh-TW': zh_TW_translation,
en: en_translation,
},
});
export const supportedLocales = ['en', 'zh'];
export default i18next;

View File

@ -1,23 +0,0 @@
# i18n
## How it was done
Using the open-source lib [i18next](https://www.i18next.com/).
## How to add your own language
1. Go to `lib/gui/app/i18n` and add a file named `xx.ts` (use the codes mentioned
in [the link](https://www.science.co.il/language/Locale-codes.php), and we support styles as `fr`, `de`, `es-ES`
and `pt-BR`)
.
2. Copy the content from an existing translation and start to translate.
3. Once done, go to `lib/gui/app/i18n.ts` and add a line of `import xx_translation from './i18n/xx'` after the
already-added imports and add `xx: xx_translation` in the `resources` section of `i18next.init()` function.
4. Now go to `lib/shared/catalina-sudo/` and copy the `sudo-askpass.osascript-en.js`, change it to
be `sudo-askpass.osascript-xx.js` and edit
the `'balenaEtcher needs privileged access in order to flash disks.\n\nType your password to allow this.'` line and
those `Ok`s and `Cancel`s to your own language.
5. If, your language has several variations when they are used in several countries/regions, such as `zh-CN` and `zh-TW`
, or `pt-BR` and `pt-PT`, edit
the `langParser()` in the `lib/gui/app/i18n.ts` file to meet your need.
6. Make a commit, and then a pull request on GitHub.

View File

@ -1,161 +0,0 @@
const translation = {
translation: {
continue: 'Continue',
ok: 'OK',
cancel: 'Cancel',
skip: 'Skip',
sure: "Yes, I'm sure",
warning: 'WARNING! ',
attention: 'Attention',
failed: 'Failed',
completed: 'Completed',
yesContinue: 'Yes, continue',
reallyExit: 'Are you sure you want to close Etcher?',
yesExit: 'Yes, quit',
progress: {
starting: 'Starting...',
decompressing: 'Decompressing...',
flashing: 'Flashing...',
finishing: 'Finishing...',
verifying: 'Validating...',
failing: 'Failed',
},
message: {
sizeNotRecommended: 'Not recommended',
tooSmall: 'Too small',
locked: 'Locked',
system: 'System drive',
containsImage: 'Source drive',
largeDrive: 'Large drive',
sourceLarger: 'The selected source is {{byte}} larger than this drive.',
flashSucceed_one: 'Successful target',
flashSucceed_other: 'Successful targets',
flashFail_one: 'Failed target',
flashFail_other: 'Failed targets',
toDrive: 'to {{description}} ({{name}})',
toTarget_one: 'to {{num}} target',
toTarget_other: 'to {{num}} targets',
andFailTarget_one: 'and failed to be flashed to {{num}} target',
andFailTarget_other: 'and failed to be flashed to {{num}} targets',
succeedTo: '{{name}} was successfully flashed {{target}}',
exitWhileFlashing:
'You are currently flashing a drive. Closing Etcher may leave your drive in an unusable state.',
looksLikeWindowsImage:
'It looks like you are trying to burn a Windows image.\n\nUnlike other images, Windows images require special processing to be made bootable. We suggest you use a tool specially designed for this purpose, such as <a href="https://rufus.akeo.ie">Rufus</a> (Windows), <a href="https://github.com/slacka/WoeUSB">WoeUSB</a> (Linux), or Boot Camp Assistant (macOS).',
image: 'image',
drive: 'drive',
missingPartitionTable:
'It looks like this is not a bootable {{type}}.\n\nThe {{type}} does not appear to contain a partition table, and might not be recognized or bootable by your device.',
largeDriveSize:
"This is a large drive! Make sure it doesn't contain files that you want to keep.",
systemDrive:
'Selecting your system drive is dangerous and will erase your drive!',
sourceDrive: 'Contains the image you chose to flash',
noSpace:
'Not enough space on the drive. Please insert larger one and try again.',
genericFlashError:
'Something went wrong. If it is a compressed image, please check that the archive is not corrupted.\n{{error}}',
validation:
'The write has been completed successfully but Etcher detected potential corruption issues when reading the image back from the drive. \n\nPlease consider writing the image to a different drive.',
openError:
'Something went wrong while opening {{source}}.\n\nError: {{error}}',
flashError: 'Something went wrong while writing {{image}} {{targets}}.',
unplug:
"Looks like Etcher lost access to the drive. Did it get unplugged accidentally?\n\nSometimes this error is caused by faulty readers that don't provide stable access to the drive.",
cannotWrite:
'Looks like Etcher is not able to write to this location of the drive. This error is usually caused by a faulty drive, reader, or port. \n\nPlease try again with another drive, reader, or port.',
childWriterDied:
'The writer process ended unexpectedly. Please try again, and contact the Etcher team if the problem persists.',
badProtocol: 'Only http:// and https:// URLs are supported.',
},
target: {
selectTarget: 'Select target',
plugTarget: 'Plug a target drive',
targets: 'Targets',
change: 'Change',
},
source: {
useSourceURL: 'Use Image URL',
auth: 'Authentication',
username: 'Enter username',
password: 'Enter password',
unsupportedProtocol: 'Unsupported protocol',
windowsImage: 'Possible Windows image detected',
partitionTable: 'Missing partition table',
errorOpen: 'Error opening source',
fromFile: 'Flash from file',
fromURL: 'Flash from URL',
clone: 'Clone drive',
image: 'Image',
name: 'Name: ',
path: 'Path: ',
selectSource: 'Select source',
plugSource: 'Plug a source drive',
osImages: 'OS Images',
allFiles: 'All',
enterValidURL: 'Enter a valid URL',
},
drives: {
name: 'Name',
size: 'Size',
location: 'Location',
find: '{{length}} found',
select: 'Select {{select}}',
showHidden: 'Show {{num}} hidden',
systemDriveDanger:
'Selecting your system drive is dangerous and will erase your drive!',
openInBrowser: '`Etcher will open {{link}} in your browser`',
changeTarget: 'Change target',
largeDriveWarning: 'You are about to erase an unusually large drive',
largeDriveWarningMsg:
'Are you sure the selected drive is not a storage drive?',
systemDriveWarning: "You are about to erase your computer's drives",
systemDriveWarningMsg:
'Are you sure you want to flash your system drive?',
},
flash: {
another: 'Flash another',
target: 'Target',
location: 'Location',
error: 'Error',
flash: 'Flash',
flashNow: 'Flash!',
skip: 'Validation has been skipped',
moreInfo: 'more info',
speedTip:
'The speed is calculated by dividing the image size by the flashing time.\nDisk images with ext partitions flash faster as we are able to skip unused parts.',
speed: 'Effective speed: {{speed}} MB/s',
speedShort: '{{speed}} MB/s',
eta: 'ETA: {{eta}}',
failedTarget: 'Failed targets',
failedRetry: 'Retry failed targets',
flashFailed: 'Flash Failed.',
flashCompleted: 'Flash Completed!',
},
settings: {
errorReporting: 'Anonymously report errors to balena.io',
autoUpdate: 'Auto-updates enabled',
settings: 'Settings',
systemInformation: 'System Information',
trimExtPartitions:
'Trim unallocated space on raw images (in ext-type partitions)',
},
menu: {
edit: 'Edit',
view: 'View',
devTool: 'Toggle Developer Tools',
window: 'Window',
help: 'Help',
pro: 'Etcher Pro',
website: 'Etcher Website',
issue: 'Report an issue',
about: 'About Etcher',
hide: 'Hide Etcher',
hideOthers: 'Hide Others',
unhide: 'Unhide All',
quit: 'Quit Etcher',
},
},
};
export default translation;

View File

@ -1,152 +0,0 @@
const translation = {
translation: {
ok: '好',
cancel: '取消',
continue: '继续',
skip: '跳过',
sure: '我确定',
warning: '请注意!',
attention: '请注意',
failed: '失败',
completed: '完毕',
yesExit: '是的,可以退出',
reallyExit: '真的要现在退出 Etcher 吗?',
yesContinue: '是的,继续',
progress: {
starting: '正在启动……',
decompressing: '正在解压……',
flashing: '正在烧录……',
finishing: '正在结束……',
verifying: '正在验证……',
failing: '失败……',
},
message: {
sizeNotRecommended: '大小不推荐',
tooSmall: '空间太小',
locked: '被锁定',
system: '系统盘',
containsImage: '存放源镜像',
largeDrive: '很大的磁盘',
sourceLarger: '所选的镜像比目标盘大了 {{byte}} 比特。',
flashSucceed_one: '烧录成功',
flashSucceed_other: '烧录成功',
flashFail_one: '烧录失败',
flashFail_other: '烧录失败',
toDrive: '到 {{description}} ({{name}})',
toTarget_one: '到 {{num}} 个目标',
toTarget_other: '到 {{num}} 个目标',
andFailTarget_one: '并烧录失败了 {{num}} 个目标',
andFailTarget_other: '并烧录失败了 {{num}} 个目标',
succeedTo: '{{name}} 被成功烧录 {{target}}',
exitWhileFlashing:
'您当前正在刷机。 关闭 Etcher 可能会导致您的磁盘无法使用。',
looksLikeWindowsImage:
'看起来您正在尝试刻录 Windows 镜像。\n\n与其他镜像不同Windows 镜像需要特殊处理才能使其可启动。 我们建议您使用专门为此目的设计的工具,例如 <a href="https://rufus.akeo.ie">Rufus</a> (Windows)、<a href="https://github. com/slacka/WoeUSB">WoeUSB</a> (Linux) 或 Boot Camp 助理 (macOS)。',
image: '镜像',
drive: '磁盘',
missingPartitionTable:
'看起来这不是一个可启动的{{type}}。\n\n这个{{type}}似乎不包含分区表,因此您的设备可能无法识别或无法正确启动。',
largeDriveSize: '这是个很大的磁盘!请检查并确认它不包含对您很重要的信息',
systemDrive: '选择系统盘很危险,因为这将会删除你的系统',
sourceDrive: '源镜像位于这个分区中',
noSpace: '磁盘空间不足。 请插入另一个较大的磁盘并重试。',
genericFlashError:
'出了点问题。如果源镜像曾被压缩过,请检查它是否已损坏。\n{{error}}',
validation:
'写入已成功完成,但 Etcher 在从磁盘读取镜像时检测到潜在的损坏问题。 \n\n请考虑将镜像写入其他磁盘。',
openError: '打开 {{source}} 时出错。\n\n错误信息 {{error}}',
flashError: '烧录 {{image}} {{targets}} 失败。',
unplug:
'看起来 Etcher 失去了对磁盘的连接。 它是不是被意外拔掉了?\n\n有时这个错误是因为读卡器出了故障。',
cannotWrite:
'看起来 Etcher 无法写入磁盘的这个位置。 此错误通常是由故障的磁盘、读取器或端口引起的。 \n\n请使用其他磁盘、读卡器或端口重试。',
childWriterDied:
'写入进程意外崩溃。请再试一次,如果问题仍然存在,请联系 Etcher 团队。',
badProtocol: '仅支持 http:// 和 https:// 开头的网址。',
},
target: {
selectTarget: '选择目标磁盘',
plugTarget: '请插入目标磁盘',
targets: '个目标',
change: '更改',
},
menu: {
edit: '编辑',
view: '视图',
devTool: '打开开发者工具',
window: '窗口',
help: '帮助',
pro: 'Etcher 专业版',
website: 'Etcher 的官网',
issue: '提交一个 issue',
about: '关于 Etcher',
hide: '隐藏 Etcher',
hideOthers: '隐藏其它窗口',
unhide: '取消隐藏',
quit: '退出 Etcher',
},
source: {
useSourceURL: '使用镜像网络地址',
auth: '验证',
username: '输入用户名',
password: '输入密码',
unsupportedProtocol: '不支持的协议',
windowsImage: '这可能是 Windows 系统镜像',
partitionTable: '找不到分区表',
errorOpen: '打开源镜像时出错',
fromFile: '从文件烧录',
fromURL: '从在线地址烧录',
clone: '克隆磁盘',
image: '镜像信息',
name: '名称:',
path: '路径:',
selectSource: '选择源',
plugSource: '请插入源磁盘',
osImages: '系统镜像格式',
allFiles: '任何文件格式',
enterValidURL: '请输入一个正确的地址',
},
drives: {
name: '名称',
size: '大小',
location: '位置',
find: '找到 {{length}} 个',
select: '选定 {{select}}',
showHidden: '显示 {{num}} 个隐藏的磁盘',
systemDriveDanger: '选择系统盘很危险,因为这将会删除你的系统!',
openInBrowser: 'Etcher 会在浏览器中打开 {{link}}',
changeTarget: '改变目标',
largeDriveWarning: '您即将擦除一个非常大的磁盘',
largeDriveWarningMsg: '您确定所选磁盘不是存储磁盘吗?',
systemDriveWarning: '您将要擦除系统盘',
systemDriveWarningMsg: '您确定要烧录到系统盘吗?',
},
flash: {
another: '烧录另一目标',
target: '目标',
location: '位置',
error: '错误',
flash: '烧录',
flashNow: '现在烧录!',
skip: '跳过了验证',
moreInfo: '更多信息',
speedTip:
'通过将镜像大小除以烧录时间来计算速度。\n由于我们能够跳过未使用的部分因此具有EXT分区的磁盘镜像烧录速度更快。',
speed: '速度:{{speed}} MB/秒',
speedShort: '{{speed}} MB/秒',
eta: '预计还需要:{{eta}}',
failedTarget: '失败的烧录目标',
failedRetry: '重试烧录失败目标',
flashFailed: '烧录失败。',
flashCompleted: '烧录成功!',
},
settings: {
errorReporting: '匿名地向 balena.io 报告运行错误和使用统计',
autoUpdate: '自动更新',
settings: '软件设置',
systemInformation: '系统信息',
},
},
};
export default translation;

View File

@ -1,154 +0,0 @@
const translation = {
translation: {
continue: '繼續',
ok: '好',
cancel: '取消',
skip: '跳過',
sure: '我確定',
warning: '請注意!',
attention: '請注意',
failed: '失敗',
completed: '完成',
yesContinue: '是的,繼續',
reallyExit: '真的要現在結束 Etcher 嗎?',
yesExit: '是的,可以結束',
progress: {
starting: '正在啟動……',
decompressing: '正在解壓縮……',
flashing: '正在燒錄……',
finishing: '正在結束……',
verifying: '正在驗證……',
failing: '失敗……',
},
message: {
sizeNotRecommended: '大小不建議',
tooSmall: '空間太小',
locked: '被鎖定',
system: '系統',
containsImage: '存放來源映像檔',
largeDrive: '很大的磁碟',
sourceLarger: '所選的映像檔比目標磁碟大了 {{byte}} 位元組。',
flashSucceed_one: '燒錄成功',
flashSucceed_other: '燒錄成功',
flashFail_one: '燒錄失敗',
flashFail_other: '燒錄失敗',
toDrive: '到 {{description}} ({{name}})',
toTarget_one: '到 {{num}} 個目標',
toTarget_other: '到 {{num}} 個目標',
andFailTarget_one: '並燒錄失敗了 {{num}} 個目標',
andFailTarget_other: '並燒錄失敗了 {{num}} 個目標',
succeedTo: '{{name}} 被成功燒錄 {{target}}',
exitWhileFlashing:
'您目前正在刷寫。關閉 Etcher 可能會導致您的磁碟無法使用。',
looksLikeWindowsImage:
'看起來您正在嘗試燒錄 Windows 映像檔。\n\n與其他映像檔不同Windows 映像檔需要特殊處理才能使其可啟動。我們建議您使用專門為此目的設計的工具,例如 <a href="https://rufus.akeo.ie">Rufus</a> (Windows)、<a href="https://github. com/slacka/WoeUSB">WoeUSB</a> (Linux) 或 Boot Camp 助理 (macOS)。',
image: '映像檔',
drive: '磁碟',
missingPartitionTable:
'看起來這不是一個可啟動的{{type}}。\n\n這個{{type}}似乎不包含分割表,因此您的設備可能無法識別或無法正確啟動。',
largeDriveSize:
'這是個很大容量的磁碟!請檢查並確認它不包含對您來說存放很重要的資料',
systemDrive: '選擇系統分割區很危險,因為這將會刪除你的系統',
sourceDrive: '來源映像檔位於這個分割區中',
noSpace: '磁碟空間不足。請插入另一個較大的磁碟並重試。',
genericFlashError:
'出了點問題。如果來源映像檔曾被壓縮過,請檢查它是否已損壞。\n{{error}}',
validation:
'寫入已成功完成,但 Etcher 在從磁碟讀取映像檔時檢測到潛在的損壞問題。\n\n請考慮將映像檔寫入其他磁碟。',
openError: '打開 {{source}} 時發生錯誤。\n\n錯誤訊息 {{error}}',
flashError: '燒錄 {{image}} {{targets}} 失敗。',
unplug:
'看起來 Etcher 失去了對磁碟的連接。是不是被意外拔掉了?\n\n有時這個錯誤是因為讀卡器出了故障。',
cannotWrite:
'看起來 Etcher 無法寫入磁碟的這個位置。此錯誤通常是由故障的磁碟、讀取器或連接埠引起的。\n\n請使用其他磁碟、讀卡器或連接埠重試。',
childWriterDied:
'寫入處理程序意外崩潰。請再試一次,如果問題仍然存在,請聯絡 Etcher 團隊。',
badProtocol: '僅支援 http:// 和 https:// 開頭的網址。',
},
target: {
selectTarget: '選擇目標磁碟',
plugTarget: '請插入目標磁碟',
targets: '個目標',
change: '更改',
},
source: {
useSourceURL: '使用映像檔網址',
auth: '驗證',
username: '輸入使用者名稱',
password: '輸入密碼',
unsupportedProtocol: '不支持的通訊協定',
windowsImage: '這可能是 Windows 系統映像檔',
partitionTable: '找不到分割表',
errorOpen: '打開來源映像檔時出錯',
fromFile: '從檔案燒錄',
fromURL: '從網址燒錄',
clone: '再製磁碟',
image: '映像檔訊息',
name: '名稱:',
path: '路徑:',
selectSource: '選擇來源',
plugSource: '請插入來源磁碟',
osImages: '系統映像檔格式',
allFiles: '任何檔案格式',
enterValidURL: '請輸入正確的網址',
},
drives: {
name: '名稱',
size: '大小',
location: '位置',
find: '找到 {{length}} 個',
select: '選取 {{select}}',
showHidden: '顯示 {{num}} 個隱藏的磁碟',
systemDriveDanger: '選擇系統分割區很危險,因為這將會刪除你的系統!',
openInBrowser: 'Etcher 會在瀏覽器中打開 {{link}}',
changeTarget: '更改目標',
largeDriveWarning: '您即將格式化一個非常大的磁碟',
largeDriveWarningMsg: '您確定所選磁碟不是儲存資料的磁碟嗎?',
systemDriveWarning: '您將要格式化系統分割區',
systemDriveWarningMsg: '您確定要燒錄到系統分割區嗎?',
},
flash: {
another: '燒錄另一目標',
target: '目標',
location: '位置',
error: '錯誤',
flash: '燒錄',
flashNow: '現在燒錄!',
skip: '跳過了驗證',
moreInfo: '更多資訊',
speedTip:
'透過將映像檔大小除以燒錄時間來計算速度。\n由於我們能夠跳過未使用的部分因此具有 ext 分割區的磁碟映像檔燒錄速度更快。',
speed: '速度:{{speed}} MB/秒',
speedShort: '{{speed}} MB/秒',
eta: '預計還需要:{{eta}}',
failedTarget: '目標燒錄失敗',
failedRetry: '重試燒錄失敗的目標',
flashFailed: '燒錄失敗。',
flashCompleted: '燒錄成功!',
},
settings: {
errorReporting: '匿名向 balena.io 回報程式錯誤和使用統計資料',
autoUpdate: '自動更新',
settings: '軟體設定',
systemInformation: '系統資訊',
trimExtPartitions: '修改原始映像檔上未分配的空間(在 ext 類型分割區中)',
},
menu: {
edit: '編輯',
view: '預覽',
devTool: '打開開發者工具',
window: '視窗',
help: '協助',
pro: 'Etcher 專業版',
website: 'Etcher 的官網',
issue: '提交 issue',
about: '關於 Etcher',
hide: '隱藏 Etcher',
hideOthers: '隱藏其它視窗',
unhide: '取消隱藏',
quit: '結束 Etcher',
},
},
};
export default translation;

View File

@ -2,9 +2,25 @@
<html>
<head>
<meta charset="UTF-8">
<title>balenaEtcher</title>
<title>Etcher</title>
<link rel="stylesheet" type="text/css" href="../../../node_modules/flexboxgrid/dist/flexboxgrid.css">
<link rel="stylesheet" type="text/css" href="../css/main.css">
<link rel="stylesheet" type="text/css" href="../css/desktop.css">
<link rel="stylesheet" type="text/css" href="../css/angular.css">
<script src="../../../generated/gui.js"></script>
</head>
<body>
<main id="main"></main>
<main ui-view></main>
<div class="section-loader"
ng-controller="StateController as state"
ng-class="{
isFinish: state.currentName === 'success'
}">
<safe-webview src="'https://www.balena.io/etcher/success-banner/'">
</safe-webview>
</div>
</body>
</html>

View File

@ -0,0 +1,70 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const _ = require('lodash')
const store = require('./store')
/**
* @summary Check if there are available drives
* @function
* @public
*
* @returns {Boolean} whether there are available drives
*
* @example
* if (availableDrives.hasAvailableDrives()) {
* console.log('There are available drives!');
* }
*/
exports.hasAvailableDrives = () => {
return !_.isEmpty(exports.getDrives())
}
/**
* @summary Set a list of drives
* @function
* @private
*
* @param {Object[]} drives - drives
*
* @throws Will throw if no drives
* @throws Will throw if drives is not an array of objects
*
* @example
* availableDrives.setDrives([ ... ]);
*/
exports.setDrives = (drives) => {
store.dispatch({
type: store.Actions.SET_AVAILABLE_DRIVES,
data: drives
})
}
/**
* @summary Get detected drives
* @function
* @private
*
* @returns {Object[]} drives
*
* @example
* const drives = availableDrives.getDrives();
*/
exports.getDrives = () => {
return store.getState().toJS().availableDrives
}

156
lib/gui/app/models/files.js Normal file
View File

@ -0,0 +1,156 @@
/*
* Copyright 2018 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const Bluebird = require('bluebird')
const fs = Bluebird.promisifyAll(require('fs'))
const path = require('path')
const driveScanner = require('../modules/drive-scanner')
/* eslint-disable lodash/prefer-lodash-method */
/* eslint-disable no-undefined */
const CONCURRENCY = 10
const collator = new Intl.Collator(undefined, {
sensitivity: 'case'
})
/**
* @summary Sort files by their names / stats
* @param {FileEntry} fileA - first file
* @param {FileEntry} fileB - second file
* @returns {Number}
*
* @example
* files.readdirAsync(dirname).then((files) => {
* return files.sort(sortFiles)
* })
*/
const sortFiles = (fileA, fileB) => {
return (fileB.isDirectory - fileA.isDirectory) ||
collator.compare(fileA.basename, fileB.basename)
}
/**
* @summary FileEntry struct
* @class
* @type {FileEntry}
*/
class FileEntry {
/**
* @summary FileEntry
* @param {String} filename - filename
* @param {fs.Stats} stats - stats
*
* @example
* new FileEntry(filename, stats)
*/
constructor (filename, stats) {
const components = path.parse(filename)
this.path = filename
this.dirname = components.dir
this.basename = components.base
this.name = components.name
this.ext = components.ext
this.isHidden = components.name.startsWith('.')
this.isFile = stats.isFile()
this.isDirectory = stats.isDirectory()
this.size = stats.size
}
}
/**
* @summary Read a directory & stat all contents
* @param {String} dirpath - Directory path
* @returns {Array<FileEntry>}
*
* @example
* files.readdirAsync('/').then((files) => {
* // ...
* })
*/
exports.readdirAsync = (dirpath) => {
console.time('readdirAsync')
const dirname = path.resolve(dirpath)
return fs.readdirAsync(dirname).then((ls) => {
return ls.filter((filename) => {
return !filename.startsWith('.')
}).map((filename) => {
return path.join(dirname, filename)
})
}).map((filename, index, length) => {
return fs.statAsync(filename).then((stats) => {
return new FileEntry(filename, stats)
})
}, { concurrency: CONCURRENCY }).then((files) => {
console.timeEnd('readdirAsync')
return files.sort(sortFiles)
})
}
/**
* @summary Split a path on it's separator(s)
* @function
* @public
*
* @param {String} fullpath - full path to split
* @param {Array<String>} [subpaths] - this param shouldn't normally be used
* @returns {Array<String>}
*
* @example
* console.log(splitPath(path.join(os.homedir(), 'Downloads'))
* // Linux
* > [ '/', 'home', 'user', 'Downloads' ]
* // Windows
* > [ 'C:', 'Users', 'user', 'Downloads' ]
*/
exports.splitPath = (fullpath, subpaths = []) => {
const {
base,
dir,
root
} = path.parse(fullpath)
const isAbsolute = path.isAbsolute(fullpath)
// Takes care of 'relative/path'
if (!isAbsolute && dir === '') {
return [ base ].concat(subpaths)
// Takes care of '/'
} else if (isAbsolute && base === '') {
return [ root ].concat(subpaths)
}
return exports.splitPath(dir, [ base ].concat(subpaths))
}
/**
* @summary Get constraint path device
* @param {String} pathname - device path
* @returns {Drive} drive - drive object
* @example
* const device = files.getConstraintDevice('/dev/disk2')
*/
exports.getConstraintDevice = (pathname) => {
// This supposes the drive scanner is ready
return driveScanner.getBy('device', pathname) || driveScanner.getBy('devicePath', pathname)
}
exports.FileEntry = FileEntry

View File

@ -0,0 +1,244 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const _ = require('lodash')
const store = require('./store')
const units = require('../../../shared/units')
/**
* @summary Reset flash state
* @function
* @public
*
* @example
* flashState.resetState();
*/
exports.resetState = () => {
store.dispatch({
type: store.Actions.RESET_FLASH_STATE
})
}
/**
* @summary Check if currently flashing
* @function
* @private
*
* @returns {Boolean} whether is flashing or not
*
* @example
* if (flashState.isFlashing()) {
* console.log('We\'re currently flashing');
* }
*/
exports.isFlashing = () => {
return store.getState().toJS().isFlashing
}
/**
* @summary Set the flashing flag
* @function
* @private
*
* @description
* This function is extracted for testing purposes.
*
* The flag is used to signify that we're going to
* start a flash process.
*
* @example
* flashState.setFlashingFlag();
*/
exports.setFlashingFlag = () => {
store.dispatch({
type: store.Actions.SET_FLASHING_FLAG
})
}
/**
* @summary Unset the flashing flag
* @function
* @private
*
* @description
* This function is extracted for testing purposes.
*
* The flag is used to signify that the write process ended.
*
* @param {Object} results - flash results
*
* @example
* flashState.unsetFlashingFlag({
* cancelled: false,
* sourceChecksum: 'a1b45d'
* });
*/
exports.unsetFlashingFlag = (results) => {
store.dispatch({
type: store.Actions.UNSET_FLASHING_FLAG,
data: results
})
}
/**
* @summary Set the flashing state
* @function
* @private
*
* @description
* This function is extracted for testing purposes.
*
* @param {Object} state - flashing state
*
* @example
* flashState.setProgressState({
* type: 'write',
* percentage: 50,
* eta: 15,
* speed: 100000000000
* });
*/
exports.setProgressState = (state) => {
// Preserve only one decimal place
const PRECISION = 1
const data = _.assign({}, state, {
percentage: _.isFinite(state.percentage)
? Math.floor(state.percentage)
// eslint-disable-next-line no-undefined
: undefined,
speed: _.attempt(() => {
if (_.isFinite(state.speed)) {
return _.round(units.bytesToMegabytes(state.speed), PRECISION)
}
return null
}),
totalSpeed: _.attempt(() => {
if (_.isFinite(state.totalSpeed)) {
return _.round(units.bytesToMegabytes(state.totalSpeed), PRECISION)
}
return null
})
})
store.dispatch({
type: store.Actions.SET_FLASH_STATE,
data
})
}
/**
* @summary Get the flash results
* @function
* @private
*
* @returns {Object} flash results
*
* @example
* const results = flashState.getFlashResults();
*/
exports.getFlashResults = () => {
return store.getState().toJS().flashResults
}
/**
* @summary Get the current flash state
* @function
* @public
*
* @returns {Object} flash state
*
* @example
* const flashState = flashState.getFlashState();
*/
exports.getFlashState = () => {
return store.getState().get('flashState').toJS()
}
/**
* @summary Determine if the last flash was cancelled
* @function
* @public
*
* @description
* This function returns false if there was no last flash.
*
* @returns {Boolean} whether the last flash was cancelled
*
* @example
* if (flashState.wasLastFlashCancelled()) {
* console.log('The last flash was cancelled');
* }
*/
exports.wasLastFlashCancelled = () => {
return _.get(exports.getFlashResults(), [ 'cancelled' ], false)
}
/**
* @summary Get last flash source checksum
* @function
* @public
*
* @description
* This function returns undefined if there was no last flash.
*
* @returns {(String|Undefined)} the last flash source checksum
*
* @example
* const checksum = flashState.getLastFlashSourceChecksum();
*/
exports.getLastFlashSourceChecksum = () => {
return exports.getFlashResults().sourceChecksum
}
/**
* @summary Get last flash error code
* @function
* @public
*
* @description
* This function returns undefined if there was no last flash.
*
* @returns {(String|Undefined)} the last flash error code
*
* @example
* const errorCode = flashState.getLastFlashErrorCode();
*/
exports.getLastFlashErrorCode = () => {
return exports.getFlashResults().errorCode
}
/**
* @summary Get current (or last) flash uuid
* @function
* @public
*
* @description
* This function returns undefined if no flash has been started yet.
*
* @returns {String} the last flash uuid
*
* @example
* const uuid = flashState.getFlashUuid();
*/
exports.getFlashUuid = () => {
return store.getState().toJS().flashUuid
}

View File

@ -1,162 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as electron from 'electron';
import type * as sdk from 'etcher-sdk';
import * as _ from 'lodash';
import type { DrivelistDrive } from '../../../shared/drive-constraints';
import { bytesToMegabytes } from '../../../shared/units';
import { Actions, store } from './store';
/**
* @summary Reset flash state
*/
export function resetState() {
store.dispatch({
type: Actions.RESET_FLASH_STATE,
data: {},
});
}
/**
* @summary Check if currently flashing
*/
export function isFlashing(): boolean {
return store.getState().toJS().isFlashing;
}
/**
* @summary Set the flashing flag
*
* @description
* The flag is used to signify that we're going to
* start a flash process.
*/
export function setFlashingFlag() {
// see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods
electron.ipcRenderer.send('disable-screensaver');
store.dispatch({
type: Actions.SET_FLASHING_FLAG,
data: {},
});
}
/**
* @summary Unset the flashing flag
*
* @description
* The flag is used to signify that the write process ended.
*/
export function unsetFlashingFlag(results: {
cancelled?: boolean;
sourceChecksum?: string;
errorCode?: string | number;
}) {
store.dispatch({
type: Actions.UNSET_FLASHING_FLAG,
data: results,
});
// see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods
electron.ipcRenderer.send('enable-screensaver');
}
export function setDevicePaths(devicePaths: string[]) {
store.dispatch({
type: Actions.SET_DEVICE_PATHS,
data: devicePaths,
});
}
export function addFailedDeviceError({
device,
error,
}: {
device: DrivelistDrive;
error: Error;
}) {
const failedDeviceErrorsMap = new Map(
store.getState().toJS().failedDeviceErrors,
);
if (failedDeviceErrorsMap.has(device.device)) {
// Only store the first error
return;
}
failedDeviceErrorsMap.set(device.device, {
description: device.description,
device: device.device,
devicePath: device.devicePath,
...error,
});
store.dispatch({
type: Actions.SET_FAILED_DEVICE_ERRORS,
data: Array.from(failedDeviceErrorsMap),
});
}
/**
* @summary Set the flashing state
*/
export function setProgressState(
state: sdk.multiWrite.MultiDestinationProgress,
) {
// Preserve only one decimal place
const PRECISION = 1;
const data = {
...state,
percentage:
state.percentage !== undefined && _.isFinite(state.percentage)
? Math.floor(state.percentage)
: undefined,
speed: _.attempt(() => {
if (_.isFinite(state.speed)) {
return _.round(bytesToMegabytes(state.speed), PRECISION);
}
return null;
}),
};
store.dispatch({
type: Actions.SET_FLASH_STATE,
data,
});
}
export function getFlashResults() {
return store.getState().toJS().flashResults;
}
export function getFlashState() {
return store.getState().get('flashState').toJS();
}
export function wasLastFlashCancelled() {
return _.get(getFlashResults(), ['cancelled'], false);
}
export function getLastFlashSourceChecksum(): string {
return getFlashResults().sourceChecksum;
}
export function getLastFlashErrorCode() {
return getFlashResults().errorCode;
}
export function getFlashUuid() {
return store.getState().toJS().flashUuid;
}

View File

@ -1,259 +0,0 @@
/*
* Copyright 2020 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as _ from 'lodash';
import type { AnimationFunction, Color } from 'sys-class-rgb-led';
import { Animator, RGBLed } from 'sys-class-rgb-led';
import type { DrivelistDrive } from '../../../shared/drive-constraints';
import { isSourceDrive } from '../../../shared/drive-constraints';
import { getDrives } from './available-drives';
import { getSelectedDrives } from './selection-state';
import * as settings from './settings';
import { observe, store } from './store';
const leds: Map<string, RGBLed> = new Map();
const animator = new Animator([], 10);
function createAnimationFunction(
intensityFunction: (t: number) => number,
color: Color,
): AnimationFunction {
return (t: number): Color => {
const intensity = intensityFunction(t);
return color.map((v: number) => v * intensity) as Color;
};
}
function blink(t: number) {
return Math.floor(t) % 2;
}
function one() {
return 1;
}
type LEDColors = {
green: Color;
purple: Color;
red: Color;
blue: Color;
white: Color;
black: Color;
};
type LEDAnimationFunctions = {
blinkGreen: AnimationFunction;
blinkPurple: AnimationFunction;
staticRed: AnimationFunction;
staticGreen: AnimationFunction;
staticBlue: AnimationFunction;
staticWhite: AnimationFunction;
staticBlack: AnimationFunction;
};
let ledColors: LEDColors;
let ledAnimationFunctions: LEDAnimationFunctions;
interface LedsState {
step: 'main' | 'flashing' | 'verifying' | 'finish';
sourceDrive: string | undefined;
availableDrives: string[];
selectedDrives: string[];
failedDrives: string[];
}
function setLeds(animation: AnimationFunction, drivesPaths: Set<string>) {
const rgbLeds: RGBLed[] = [];
for (const path of drivesPaths) {
const led = leds.get(path);
if (led) {
rgbLeds.push(led);
}
}
return { animation, rgbLeds };
}
// Source slot (1st slot): behaves as a target unless it is chosen as source
// No drive: black
// Drive plugged: blue - on
//
// Other slots (2 - 16):
//
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
// | | main screen | flashing | validating | results screen |
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
// | no drive | black | black | black | black |
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
// | drive plugged | black | black | black | black |
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
// | drive selected | white | blink purple, red if failed | blink green, red if failed | green if success, red if failed |
// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+
export function updateLeds({
step,
sourceDrive,
availableDrives,
selectedDrives,
failedDrives,
}: LedsState) {
const unplugged = new Set(leds.keys());
const plugged = new Set(availableDrives);
const selectedOk = new Set(selectedDrives);
const selectedFailed = new Set(failedDrives);
// Remove selected devices from plugged set
for (const d of selectedOk) {
plugged.delete(d);
unplugged.delete(d);
}
// Remove plugged devices from unplugged set
for (const d of plugged) {
unplugged.delete(d);
}
// Remove failed devices from selected set
for (const d of selectedFailed) {
selectedOk.delete(d);
}
const mapping: Array<{
animation: AnimationFunction;
rgbLeds: RGBLed[];
}> = [];
// Handle source slot
if (sourceDrive !== undefined) {
if (plugged.has(sourceDrive)) {
plugged.delete(sourceDrive);
mapping.push(
setLeds(ledAnimationFunctions.staticBlue, new Set([sourceDrive])),
);
}
}
if (step === 'main') {
mapping.push(
setLeds(
ledAnimationFunctions.staticBlack,
new Set([...unplugged, ...plugged]),
),
setLeds(
ledAnimationFunctions.staticWhite,
new Set([...selectedOk, ...selectedFailed]),
),
);
} else if (step === 'flashing') {
mapping.push(
setLeds(
ledAnimationFunctions.staticBlack,
new Set([...unplugged, ...plugged]),
),
setLeds(ledAnimationFunctions.blinkPurple, selectedOk),
setLeds(ledAnimationFunctions.staticRed, selectedFailed),
);
} else if (step === 'verifying') {
mapping.push(
setLeds(
ledAnimationFunctions.staticBlack,
new Set([...unplugged, ...plugged]),
),
setLeds(ledAnimationFunctions.blinkGreen, selectedOk),
setLeds(ledAnimationFunctions.staticRed, selectedFailed),
);
} else if (step === 'finish') {
mapping.push(
setLeds(
ledAnimationFunctions.staticBlack,
new Set([...unplugged, ...plugged]),
),
setLeds(ledAnimationFunctions.staticGreen, selectedOk),
setLeds(ledAnimationFunctions.staticRed, selectedFailed),
);
}
animator.mapping = mapping;
}
let ledsState: LedsState | undefined;
function stateObserver() {
const s = store.getState().toJS();
let step: 'main' | 'flashing' | 'verifying' | 'finish';
if (s.isFlashing) {
step = s.flashState.type;
} else {
step = s.lastAverageFlashingSpeed == null ? 'main' : 'finish';
}
const availableDrives = getDrives().filter(
(d: DrivelistDrive) => d.devicePath,
);
const sourceDrivePath = availableDrives.filter((d: DrivelistDrive) =>
isSourceDrive(d, s.selection.image),
)[0]?.devicePath;
const availableDrivesPaths = availableDrives.map(
(d: DrivelistDrive) => d.devicePath,
);
let selectedDrivesPaths: string[];
if (step === 'main') {
selectedDrivesPaths = getSelectedDrives()
.filter((drive) => drive.devicePath !== null)
.map((drive) => drive.devicePath) as string[];
} else {
selectedDrivesPaths = s.devicePaths;
}
const failedDevicePaths = s.failedDeviceErrors.map(
([, { devicePath }]: [string, { devicePath: string }]) => devicePath,
);
const newLedsState = {
step,
sourceDrive: sourceDrivePath,
availableDrives: availableDrivesPaths,
selectedDrives: selectedDrivesPaths,
failedDrives: failedDevicePaths,
} as LedsState;
if (!_.isEqual(newLedsState, ledsState)) {
updateLeds(newLedsState);
ledsState = newLedsState;
}
}
export async function init(): Promise<void> {
// ledsMapping is something like:
// {
// 'platform-xhci-hcd.0.auto-usb-0:1.1.1:1.0-scsi-0:0:0:0': [
// 'led1_r',
// 'led1_g',
// 'led1_b',
// ],
// ...
// }
const ledsMapping: _.Dictionary<[string, string, string]> =
(await settings.get('ledsMapping')) || {};
if (!_.isEmpty(ledsMapping)) {
for (const [drivePath, ledsNames] of Object.entries(ledsMapping)) {
leds.set('/dev/disk/by-path/' + drivePath, new RGBLed(ledsNames));
}
ledColors = (await settings.get('ledColors')) || {};
ledAnimationFunctions = {
blinkGreen: createAnimationFunction(blink, ledColors['green']),
blinkPurple: createAnimationFunction(blink, ledColors['purple']),
staticRed: createAnimationFunction(one, ledColors['red']),
staticGreen: createAnimationFunction(one, ledColors['green']),
staticBlue: createAnimationFunction(one, ledColors['blue']),
staticWhite: createAnimationFunction(one, ledColors['white']),
staticBlack: createAnimationFunction(one, ledColors['black']),
};
observe(_.debounce(stateObserver, 1000, { maxWait: 1000 }));
}
}

View File

@ -0,0 +1,184 @@
/*
* Copyright 2017 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const Bluebird = require('bluebird')
const fs = require('fs')
const path = require('path')
/**
* @summary Number of spaces to indent JSON output with
* @type {Number}
* @constant
*/
const JSON_INDENT = 2
/**
* @summary Userdata directory path
* @description
* Defaults to the following:
* - `%APPDATA%/etcher` on Windows
* - `$XDG_CONFIG_HOME/etcher` or `~/.config/etcher` on Linux
* - `~/Library/Application Support/etcher` on macOS
* See https://electronjs.org/docs/api/app#appgetpathname
* @constant
* @type {String}
*/
const USER_DATA_DIR = (() => {
// NOTE: The ternary is due to this module being loaded both,
// Electron's main process and renderer process
const electron = require('electron')
return electron.app
? electron.app.getPath('userData')
: electron.remote.app.getPath('userData')
})()
/**
* @summary Configuration file path
* @type {String}
* @constant
*/
const CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json')
/**
* @summary Read a local config.json file
* @function
* @private
*
* @param {String} filename - file path
* @fulfil {Object} - settings
* @returns {Promise}
*
* @example
* readConfigFile('config.json').then((settings) => {
* console.log(settings)
* })
*/
const readConfigFile = (filename) => {
return new Bluebird((resolve, reject) => {
fs.readFile(filename, { encoding: 'utf8' }, (error, contents) => {
let data = {}
if (error) {
if (error.code === 'ENOENT') {
resolve(data)
} else {
reject(error)
}
} else {
try {
data = JSON.parse(contents)
} catch (parseError) {
console.error(parseError)
}
resolve(data)
}
})
})
}
/**
* @summary Write to the local configuration file
* @function
* @private
*
* @param {String} filename - file path
* @param {Object} data - data
* @fulfil {Object} data - data
* @returns {Promise}
*
* @example
* writeConfigFile('config.json', { something: 'good' })
* .then(() => {
* console.log('data written')
* })
*/
const writeConfigFile = (filename, data) => {
return new Bluebird((resolve, reject) => {
const contents = JSON.stringify(data, null, JSON_INDENT)
fs.writeFile(filename, contents, (error) => {
if (error) {
reject(error)
} else {
resolve(data)
}
})
})
}
/**
* @summary Read all local settings
* @function
* @public
*
* @fulfil {Object} - local settings
* @returns {Promise}
*
* @example
* localSettings.readAll().then((settings) => {
* console.log(settings);
* });
*/
exports.readAll = () => {
return readConfigFile(CONFIG_PATH)
}
/**
* @summary Write local settings
* @function
* @public
*
* @param {Object} settings - settings
* @fulfil {Object} settings - settings
* @returns {Promise}
*
* @example
* localSettings.writeAll({
* foo: 'bar'
* }).then(() => {
* console.log('Done!');
* });
*/
exports.writeAll = (settings) => {
return writeConfigFile(CONFIG_PATH, settings)
}
/**
* @summary Clear the local settings
* @function
* @private
*
* @description
* Exported for testing purposes
*
* @returns {Promise}
*
* @example
* localSettings.clear().then(() => {
* console.log('Done!');
* });
*/
exports.clear = () => {
return new Bluebird((resolve, reject) => {
fs.unlink(CONFIG_PATH, (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}

View File

@ -0,0 +1,438 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const _ = require('lodash')
const store = require('./store')
const availableDrives = require('./available-drives')
/**
* @summary Select a drive by its device path
* @function
* @public
*
* @param {String} driveDevice - drive device
*
* @example
* selectionState.selectDrive('/dev/disk2');
*/
exports.selectDrive = (driveDevice) => {
store.dispatch({
type: store.Actions.SELECT_DRIVE,
data: driveDevice
})
}
/**
* @summary Toggle drive selection
* @function
* @public
*
* @param {String} driveDevice - drive device
*
* @example
* selectionState.toggleDrive('/dev/disk2');
*/
exports.toggleDrive = (driveDevice) => {
if (exports.isDriveSelected(driveDevice)) {
exports.deselectDrive(driveDevice)
} else {
exports.selectDrive(driveDevice)
}
}
/**
* @summary Deselect all other drives and keep the current drive's status
* @function
* @public
* @deprecated
*
* @description
* This is a temporary function during the transition to multi-writes,
* remove this and its uses when multi-selection should become user-facing.
*
* @param {String} driveDevice - drive device identifier
*
* @example
* console.log(selectionState.getSelectedDevices())
* > [ '/dev/disk1', '/dev/disk2', '/dev/disk3' ]
* selectionState.deselectOtherDrives('/dev/disk2')
* console.log(selectionState.getSelectedDevices())
* > [ '/dev/disk2' ]
*/
exports.deselectOtherDrives = (driveDevice) => {
if (exports.isDriveSelected(driveDevice)) {
const otherDevices = _.reject(exports.getSelectedDevices(), _.partial(_.isEqual, driveDevice))
_.each(otherDevices, exports.deselectDrive)
} else {
exports.deselectAllDrives()
}
}
/**
* @summary Select an image
* @function
* @public
*
* @param {Object} image - image
*
* @example
* selectionState.selectImage({
* path: 'foo.img',
* size: 1000000000,
* compressedSize: 1000000000,
* isSizeEstimated: false,
* });
*/
exports.selectImage = (image) => {
store.dispatch({
type: store.Actions.SELECT_IMAGE,
data: image
})
}
/**
* @summary Get all selected drives' devices
* @function
* @public
*
* @returns {String[]} selected drives' devices
*
* @example
* for (driveDevice of selectionState.getSelectedDevices()) {
* console.log(driveDevice)
* }
* > '/dev/disk1'
* > '/dev/disk2'
*/
exports.getSelectedDevices = () => {
return store.getState().getIn([ 'selection', 'devices' ]).toJS()
}
/**
* @summary Get all selected drive objects
* @function
* @public
*
* @returns {Object[]} selected drive objects
*
* @example
* for (drive of selectionState.getSelectedDrives()) {
* console.log(drive)
* }
* > '{ device: '/dev/disk1', size: 123456789, ... }'
* > '{ device: '/dev/disk2', size: 987654321, ... }'
*/
exports.getSelectedDrives = () => {
const drives = availableDrives.getDrives()
return _.map(exports.getSelectedDevices(), (device) => {
return _.find(drives, { device })
})
}
/**
* @summary Get the head of the list of selected drives
* @function
* @public
*
* @returns {Object} drive
*
* @example
* const drive = selectionState.getCurrentDrive();
* console.log(drive)
* > { device: '/dev/disk1', name: 'Flash drive', ... }
*/
exports.getCurrentDrive = () => {
const device = _.head(exports.getSelectedDevices())
return _.find(availableDrives.getDrives(), { device })
}
/**
* @summary Get the selected image
* @function
* @public
*
* @returns {Object} image
*
* @example
* const image = selectionState.getImage();
*/
exports.getImage = () => {
return _.get(store.getState().toJS(), [ 'selection', 'image' ])
}
/**
* @summary Get image path
* @function
* @public
*
* @returns {String} image path
*
* @example
* const imagePath = selectionState.getImagePath();
*/
exports.getImagePath = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'path'
])
}
/**
* @summary Get image size
* @function
* @public
*
* @returns {Number} image size
*
* @example
* const imageSize = selectionState.getImageSize();
*/
exports.getImageSize = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'size'
])
}
/**
* @summary Get image url
* @function
* @public
*
* @returns {String} image url
*
* @example
* const imageUrl = selectionState.getImageUrl();
*/
exports.getImageUrl = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'url'
])
}
/**
* @summary Get image name
* @function
* @public
*
* @returns {String} image name
*
* @example
* const imageName = selectionState.getImageName();
*/
exports.getImageName = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'name'
])
}
/**
* @summary Get image logo
* @function
* @public
*
* @returns {String} image logo
*
* @example
* const imageLogo = selectionState.getImageLogo();
*/
exports.getImageLogo = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'logo'
])
}
/**
* @summary Get image support url
* @function
* @public
*
* @returns {String} image support url
*
* @example
* const imageSupportUrl = selectionState.getImageSupportUrl();
*/
exports.getImageSupportUrl = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'supportUrl'
])
}
/**
* @summary Get image recommended drive size
* @function
* @public
*
* @returns {String} image recommended drive size
*
* @example
* const imageRecommendedDriveSize = selectionState.getImageRecommendedDriveSize();
*/
exports.getImageRecommendedDriveSize = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'recommendedDriveSize'
])
}
/**
* @summary Check if there is a selected drive
* @function
* @public
*
* @returns {Boolean} whether there is a selected drive
*
* @example
* if (selectionState.hasDrive()) {
* console.log('There is a drive!');
* }
*/
exports.hasDrive = () => {
return Boolean(exports.getSelectedDevices().length)
}
/**
* @summary Check if there is a selected image
* @function
* @public
*
* @returns {Boolean} whether there is a selected image
*
* @example
* if (selectionState.hasImage()) {
* console.log('There is an image!');
* }
*/
exports.hasImage = () => {
return Boolean(exports.getImage())
}
/**
* @summary Remove drive from selection
* @function
* @public
*
* @param {String} driveDevice - drive device identifier
*
* @example
* selectionState.deselectDrive('/dev/sdc');
*
* @example
* selectionState.deselectDrive('\\\\.\\PHYSICALDRIVE3');
*/
exports.deselectDrive = (driveDevice) => {
store.dispatch({
type: store.Actions.DESELECT_DRIVE,
data: driveDevice
})
}
/**
* @summary Deselect image
* @function
* @public
*
* @example
* selectionState.deselectImage();
*/
exports.deselectImage = () => {
store.dispatch({
type: store.Actions.DESELECT_IMAGE
})
}
/**
* @summary Deselect all drives
* @function
* @public
*
* @example
* selectionState.deselectAllDrives()
*/
exports.deselectAllDrives = () => {
_.each(exports.getSelectedDevices(), exports.deselectDrive)
}
/**
* @summary Clear selections
* @function
* @public
*
* @example
* selectionState.clear();
*/
exports.clear = () => {
exports.deselectImage()
exports.deselectAllDrives()
}
/**
* @summary Check if a drive is the current drive
* @function
* @public
*
* @param {String} driveDevice - drive device
* @returns {Boolean} whether the drive is the current drive
*
* @example
* if (selectionState.isCurrentDrive('/dev/sdb')) {
* console.log('This is the current drive!');
* }
*/
exports.isCurrentDrive = (driveDevice) => {
if (!driveDevice) {
return false
}
return driveDevice === _.get(exports.getCurrentDrive(), [ 'device' ])
}
/**
* @summary Check whether a given device is selected.
* @function
* @public
*
* @param {String} driveDevice - drive device identifier
* @returns {Boolean}
*
* @example
* const isSelected = selectionState.isDriveSelected('/dev/sdb')
*
* if (isSelected) {
* selectionState.deselectDrive(driveDevice)
* }
*/
exports.isDriveSelected = (driveDevice) => {
if (!driveDevice) {
return false
}
const selectedDriveDevices = exports.getSelectedDevices()
return _.includes(selectedDriveDevices, driveDevice)
}

View File

@ -1,128 +0,0 @@
import type { DrivelistDrive } from '../../../shared/drive-constraints';
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { SourceMetadata } from '../../../shared/typings/source-selector';
import * as availableDrives from './available-drives';
import { Actions, store } from './store';
/**
* @summary Select a drive by its device path
*/
export function selectDrive(driveDevice: string) {
store.dispatch({
type: Actions.SELECT_TARGET,
data: driveDevice,
});
}
/**
* @summary Toggle drive selection
*/
export function toggleDrive(driveDevice: string) {
if (isDriveSelected(driveDevice)) {
deselectDrive(driveDevice);
} else {
selectDrive(driveDevice);
}
}
export function selectSource(source: SourceMetadata) {
store.dispatch({
type: Actions.SELECT_SOURCE,
data: source,
});
}
/**
* @summary Get all selected drives' devices
*/
export function getSelectedDevices(): string[] {
return store.getState().getIn(['selection', 'devices']).toJS();
}
/**
* @summary Get all selected drive objects
*/
export function getSelectedDrives(): DrivelistDrive[] {
const selectedDevices = getSelectedDevices();
return availableDrives
.getDrives()
.filter((drive) => selectedDevices.includes(drive.device));
}
/**
* @summary Get the selected image
*/
export function getImage(): SourceMetadata | undefined {
return store.getState().toJS().selection.image;
}
/**
* @summary Check if there is a selected drive
*/
export function hasDrive(): boolean {
return Boolean(getSelectedDevices().length);
}
/**
* @summary Check if there is a selected image
*/
export function hasImage(): boolean {
return getImage() !== undefined;
}
/**
* @summary Remove drive from selection
*/
export function deselectDrive(driveDevice: string) {
store.dispatch({
type: Actions.DESELECT_TARGET,
data: driveDevice,
});
}
export function deselectImage() {
store.dispatch({
type: Actions.DESELECT_SOURCE,
data: {},
});
}
export function deselectAllDrives() {
getSelectedDevices().forEach(deselectDrive);
}
/**
* @summary Clear selections
*/
export function clear() {
deselectImage();
deselectAllDrives();
}
/**
* @summary Check whether a given device is selected.
*/
export function isDriveSelected(driveDevice: string) {
if (!driveDevice) {
return false;
}
const selectedDriveDevices = getSelectedDevices();
return selectedDriveDevices.includes(driveDevice);
}

View File

@ -0,0 +1,232 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
/**
* @module Etcher.Models.Settings
*/
const _ = require('lodash')
const Bluebird = require('bluebird')
const localSettings = require('./local-settings')
const errors = require('../../../shared/errors')
const packageJSON = require('../../../../package.json')
const debug = require('debug')('etcher:models:settings')
/**
* @summary Default settings
* @constant
* @type {Object}
*/
const DEFAULT_SETTINGS = {
unsafeMode: false,
errorReporting: true,
unmountOnSuccess: true,
validateWriteOnSuccess: true,
trim: false,
updatesEnabled: packageJSON.updates.enabled && !_.includes([ 'rpm', 'deb' ], packageJSON.packageType),
lastSleptUpdateNotifier: null,
lastSleptUpdateNotifierVersion: null,
desktopNotifications: true
}
/**
* @summary Settings state
* @type {Object}
* @private
*/
let settings = _.cloneDeep(DEFAULT_SETTINGS)
/**
* @summary Reset settings to their default values
* @function
* @public
*
* @returns {Promise}
*
* @example
* settings.reset().then(() => {
* console.log('Done!');
* });
*/
exports.reset = () => {
debug('reset')
// TODO: Remove default settings from config file (?)
settings = _.cloneDeep(DEFAULT_SETTINGS)
return localSettings.writeAll(settings)
}
/**
* @summary Extend the current settings
* @function
* @public
*
* @param {Object} value - value
* @returns {Promise}
*
* @example
* settings.assign({
* foo: 'bar'
* }).then(() => {
* console.log('Done!');
* });
*/
exports.assign = (value) => {
debug('assign', value)
if (_.isNil(value)) {
return Bluebird.reject(errors.createError({
title: 'Missing settings'
}))
}
if (!_.isPlainObject(value)) {
return Bluebird.reject(errors.createError({
title: 'Settings must be an object'
}))
}
const newSettings = _.assign({}, settings, value)
return localSettings.writeAll(newSettings)
.then((updatedSettings) => {
// NOTE: Only update in memory settings when successfully written
settings = updatedSettings
})
}
/**
* @summary Extend the application state with the local settings
* @function
* @public
*
* @returns {Promise}
*
* @example
* settings.load().then(() => {
* console.log('Done!');
* });
*/
exports.load = () => {
debug('load')
return localSettings.readAll().then((loadedSettings) => {
return _.assign(settings, loadedSettings)
})
}
/**
* @summary Set a setting value
* @function
* @public
*
* @param {String} key - setting key
* @param {*} value - setting value
* @returns {Promise}
*
* @example
* settings.set('unmountOnSuccess', true).then(() => {
* console.log('Done!');
* });
*/
exports.set = (key, value) => {
debug('set', key, value)
if (_.isNil(key)) {
return Bluebird.reject(errors.createError({
title: 'Missing setting key'
}))
}
if (!_.isString(key)) {
return Bluebird.reject(errors.createError({
title: `Invalid setting key: ${key}`
}))
}
const previousValue = settings[key]
settings[key] = value
return localSettings.writeAll(settings)
.catch((error) => {
// Revert to previous value if persisting settings failed
settings[key] = previousValue
throw error
})
}
/**
* @summary Get a setting value
* @function
* @public
*
* @param {String} key - setting key
* @returns {*} setting value
*
* @example
* const value = settings.get('unmountOnSuccess');
*/
exports.get = (key) => {
return _.cloneDeep(_.get(settings, [ key ]))
}
/**
* @summary Check if setting value exists
* @function
* @public
*
* @param {String} key - setting key
* @returns {Boolean} exists
*
* @example
* const hasValue = settings.has('unmountOnSuccess');
*/
exports.has = (key) => {
/* eslint-disable no-eq-null */
return settings[key] != null
}
/**
* @summary Get all setting values
* @function
* @public
*
* @returns {Object} all setting values
*
* @example
* const allSettings = settings.getAll();
* console.log(allSettings.unmountOnSuccess);
*/
exports.getAll = () => {
debug('getAll')
return _.cloneDeep(settings)
}
/**
* @summary Get the default setting values
* @function
* @public
*
* @returns {Object} all setting values
*
* @example
* const defaults = settings.getDefaults();
* console.log(defaults.unmountOnSuccess);
*/
exports.getDefaults = () => {
debug('getDefaults')
return _.cloneDeep(DEFAULT_SETTINGS)
}

View File

@ -1,126 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as _debug from 'debug';
import * as electron from 'electron';
import * as _ from 'lodash';
import { promises as fs } from 'fs';
import { join } from 'path';
import * as packageJSON from '../../../../package.json';
const debug = _debug('etcher:models:settings');
const JSON_INDENT = 2;
export const DEFAULT_WIDTH = 800;
export const DEFAULT_HEIGHT = 480;
/**
* @summary Userdata directory path
* @description
* Defaults to the following:
* - `%APPDATA%/etcher` on Windows
* - `$XDG_CONFIG_HOME/etcher` or `~/.config/etcher` on Linux
* - `~/Library/Application Support/etcher` on macOS
* See https://electronjs.org/docs/api/app#appgetpathname
*
* NOTE: We use the remote property when this module
* is loaded in the Electron's renderer process
*/
function getConfigPath() {
const app = electron.app || require('@electron/remote').app;
return join(app.getPath('userData'), 'config.json');
}
async function readConfigFile(filename: string): Promise<_.Dictionary<any>> {
let contents = '{}';
try {
contents = await fs.readFile(filename, { encoding: 'utf8' });
} catch (error: any) {
// noop
}
try {
return JSON.parse(contents);
} catch (parseError) {
console.error(parseError);
return {};
}
}
// exported for tests
export async function readAll() {
return await readConfigFile(getConfigPath());
}
// exported for tests
export async function writeConfigFile(
filename: string,
data: _.Dictionary<any>,
): Promise<void> {
await fs.writeFile(filename, JSON.stringify(data, null, JSON_INDENT));
}
const DEFAULT_SETTINGS: _.Dictionary<any> = {
errorReporting: true,
updatesEnabled: ['appimage', 'nsis', 'dmg'].includes(packageJSON.packageType),
desktopNotifications: true,
autoBlockmapping: true,
decompressFirst: true,
};
const settings = _.cloneDeep(DEFAULT_SETTINGS);
async function load(): Promise<void> {
debug('load');
const loadedSettings = await readAll();
_.assign(settings, loadedSettings);
}
const loaded = load();
export async function set(
key: string,
value: any,
writeConfigFileFn = writeConfigFile,
): Promise<void> {
debug('set', key, value);
await loaded;
const previousValue = settings[key];
settings[key] = value;
try {
await writeConfigFileFn(getConfigPath(), settings);
} catch (error: any) {
// Revert to previous value if persisting settings failed
settings[key] = previousValue;
throw error;
}
}
export async function get(key: string): Promise<any> {
await loaded;
return getSync(key);
}
export function getSync(key: string): any {
return _.cloneDeep(settings[key]);
}
export async function getAll() {
debug('getAll');
await loaded;
return _.cloneDeep(settings);
}

View File

@ -0,0 +1,164 @@
/*
* Copyright 2018 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const INDENTATION_SPACES = 2
/**
* @summary Localstorage class and helper functions
* @class
* @public
*/
class Storage {
/**
* @function
* @public
*
* @param {String} superkey - superkey
*
* @example
* const potatoStorage = new Storage('potato')
*/
constructor (superkey) {
this.superkey = superkey
}
/**
* @summary Get the whole object under the superkey
* @function
* @public
*
* @returns {Object}
*
* @example
* for (const key in potatoStorage.getAll()) {
* console.log(key)
* }
*/
getAll () {
try {
// JSON.parse(null) === null, so we fallback to {}
return JSON.parse(window.localStorage.getItem(this.superkey)) || {}
} catch (err) {
this.setAll({})
throw err
}
}
/**
* @summary Set the whole object under the superkey
* @function
* @public
*
* @param {Any} value - any valid JSON value
*
* @example
* potatoStorage.setAll({
* location: 'somewhere',
* freshness: 100,
* edible: true
* })
*/
setAll (value) {
window.localStorage.setItem(this.superkey, JSON.stringify(value, null, INDENTATION_SPACES))
}
/**
* @summary Clear the whole object under the superkey
* @function
* @public
*
* @example
* potatoStorage.clearAll()
*/
clearAll () {
window.localStorage.removeItem(this.superkey)
}
/**
* @summary Get a stored value
* @function
* @public
*
* @param {String} key - object field key
* @param {Any} defaultValue - any valid JSON value
* @returns {Any} - the JSON parsed value
*
* @example
* potatoStorage.get('location', 'my farm')
*/
get (key, defaultValue) {
const value = this.getAll()[key]
// eslint-disable-next-line no-undefined
if (value === undefined) {
return defaultValue
}
return value
}
/**
* @summary Modify a stored value
* @function
* @public
*
* @param {String} key - object field key
* @param {Function} func - function to apply to the value
* @param {Any} defaultValue - fallback value
* @returns {Any} - the value returned by the function applied above
*
* @example
* potatoStorage.modify('freshness', (freshness) => {
* return freshness + 1
* })
*/
modify (key, func, defaultValue) {
const obj = this.getAll()
let result = null
// eslint-disable-next-line no-undefined
if (obj[key] === undefined) {
result = func(defaultValue)
} else {
result = func(obj[key])
}
// eslint-disable-next-line lodash/prefer-lodash-method
this.setAll(Object.assign(obj, { [key]: result }))
return result
}
/**
* @summary Set a stored value
* @function
* @public
*
* @param {String} key - object field key
* @param {Any} value - value to set
*
* @example
* potatoStorage.set('edible', true)
*/
set (key, value) {
this.modify(key, () => {
return value
})
}
}
module.exports = Storage

554
lib/gui/app/models/store.js Normal file
View File

@ -0,0 +1,554 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const Immutable = require('immutable')
const _ = require('lodash')
const redux = require('redux')
const uuidV4 = require('uuid/v4')
const constraints = require('../../../shared/drive-constraints')
const supportedFormats = require('../../../shared/supported-formats')
const errors = require('../../../shared/errors')
const fileExtensions = require('../../../shared/file-extensions')
const utils = require('../../../shared/utils')
const settings = require('./settings')
/**
* @summary Verify and throw if any state fields are nil
* @function
* @public
*
* @param {Object} object - state object
* @param {Array<Array<String>> | Array<String>} fields - array of object field paths
* @param {String} name - name of the state we're dealing with
* @throws
*
* @example
* const fields = [ 'type', 'percentage' ]
* verifyNoNilFields(action.data, fields, 'flash')
*/
const verifyNoNilFields = (object, fields, name) => {
const nilFields = _.filter(fields, (field) => {
return _.isNil(_.get(object, field))
})
if (nilFields.length) {
throw new Error(`Missing ${name} fields: ${nilFields.join(', ')}`)
}
}
/**
* @summary FLASH_STATE fields that can't be nil
* @constant
* @private
*/
const flashStateNoNilFields = [
'speed',
'totalSpeed'
]
/**
* @summary SELECT_IMAGE fields that can't be nil
* @constant
* @private
*/
const selectImageNoNilFields = [
'path',
'extension'
]
/**
* @summary Application default state
* @type {Object}
* @constant
* @private
*/
const DEFAULT_STATE = Immutable.fromJS({
applicationSessionUuid: '',
flashingWorkflowUuid: '',
availableDrives: [],
selection: {
devices: new Immutable.OrderedSet()
},
isFlashing: false,
flashResults: {},
flashState: {
flashing: 0,
verifying: 0,
successful: 0,
failed: 0,
percentage: 0,
speed: null,
totalSpeed: null
}
})
/**
* @summary Application supported action messages
* @type {Object}
* @constant
*/
const ACTIONS = _.fromPairs(_.map([
'SET_AVAILABLE_DRIVES',
'SET_FLASH_STATE',
'RESET_FLASH_STATE',
'SET_FLASHING_FLAG',
'UNSET_FLASHING_FLAG',
'SELECT_DRIVE',
'SELECT_IMAGE',
'DESELECT_DRIVE',
'DESELECT_IMAGE',
'SET_APPLICATION_SESSION_UUID',
'SET_FLASHING_WORKFLOW_UUID',
'SET_WEBVIEW_SHOWING_STATUS'
], (message) => {
return [ message, message ]
}))
/**
* @summary Get available drives from the state
* @function
* @public
*
* @param {Object} state - state object
* @returns {Object} new state
*
* @example
* const drives = getAvailableDrives(state)
* _.find(drives, { device: '/dev/sda' })
*/
const getAvailableDrives = (state) => {
// eslint-disable-next-line lodash/prefer-lodash-method
return state.get('availableDrives').toJS()
}
/**
* @summary The redux store reducer
* @function
* @private
*
* @param {Object} state - application state
* @param {Object} action - dispatched action
* @returns {Object} new application state
*
* @example
* const newState = storeReducer(DEFAULT_STATE, {
* type: ACTIONS.DESELECT_DRIVE
* });
*/
const storeReducer = (state = DEFAULT_STATE, action) => {
switch (action.type) {
case ACTIONS.SET_AVAILABLE_DRIVES: {
// Type: action.data : Array<DriveObject>
if (!action.data) {
throw errors.createError({
title: 'Missing drives'
})
}
const drives = action.data
if (!_.isArray(drives) || !_.every(drives, _.isObject)) {
throw errors.createError({
title: `Invalid drives: ${drives}`
})
}
const newState = state.set('availableDrives', Immutable.fromJS(drives))
const selectedDevices = newState.getIn([ 'selection', 'devices' ]).toJS()
// Remove selected drives that are stale, i.e. missing from availableDrives
const nonStaleNewState = _.reduce(selectedDevices, (accState, device) => {
// Check whether the drive still exists in availableDrives
if (device && !_.find(drives, {
device
})) {
// Deselect this drive gone from availableDrives
return storeReducer(accState, {
type: ACTIONS.DESELECT_DRIVE,
data: device
})
}
return accState
}, newState)
const shouldAutoselectAll = Boolean(settings.get('disableExplicitDriveSelection'))
const AUTOSELECT_DRIVE_COUNT = 1
const nonStaleSelectedDevices = nonStaleNewState.getIn([ 'selection', 'devices' ]).toJS()
const hasSelectedDevices = nonStaleSelectedDevices.length >= AUTOSELECT_DRIVE_COUNT
const shouldAutoselectOne = drives.length === AUTOSELECT_DRIVE_COUNT && !hasSelectedDevices
if (shouldAutoselectOne || shouldAutoselectAll) {
// Even if there's no image selected, we need to call several
// drive/image related checks, and `{}` works fine with them
const image = state.getIn([ 'selection', 'image' ], Immutable.fromJS({})).toJS()
return _.reduce(drives, (accState, drive) => {
if (_.every([
constraints.isDriveValid(drive, image),
constraints.isDriveSizeRecommended(drive, image),
// We don't want to auto-select large drives
!constraints.isDriveSizeLarge(drive),
// We don't want to auto-select system drives,
// even when "unsafe mode" is enabled
!constraints.isSystemDrive(drive)
]) || (shouldAutoselectAll && constraints.isDriveValid(drive, image))) {
// Auto-select this drive
return storeReducer(accState, {
type: ACTIONS.SELECT_DRIVE,
data: drive.device
})
}
// Deselect this drive in case it still is selected
return storeReducer(accState, {
type: ACTIONS.DESELECT_DRIVE,
data: drive.device
})
}, nonStaleNewState)
}
return nonStaleNewState
}
case ACTIONS.SET_FLASH_STATE: {
// Type: action.data : FlashStateObject
if (!state.get('isFlashing')) {
throw errors.createError({
title: 'Can\'t set the flashing state when not flashing'
})
}
verifyNoNilFields(action.data, flashStateNoNilFields, 'flash')
if (!_.every(_.pick(action.data, [
'flashing',
'verifying',
'successful',
'failed'
]), _.isFinite)) {
throw errors.createError({
title: 'State quantity field(s) not finite number'
})
}
if (!_.isUndefined(action.data.percentage) && !utils.isValidPercentage(action.data.percentage)) {
throw errors.createError({
title: `Invalid state percentage: ${action.data.percentage}`
})
}
if (!_.isUndefined(action.data.eta) && !_.isNumber(action.data.eta)) {
throw errors.createError({
title: `Invalid state eta: ${action.data.eta}`
})
}
return state.set('flashState', Immutable.fromJS(action.data))
}
case ACTIONS.RESET_FLASH_STATE: {
return state
.set('isFlashing', false)
.set('flashState', DEFAULT_STATE.get('flashState'))
.set('flashResults', DEFAULT_STATE.get('flashResults'))
.delete('flashUuid')
}
case ACTIONS.SET_FLASHING_FLAG: {
return state
.set('isFlashing', true)
.set('flashUuid', uuidV4())
.set('flashResults', DEFAULT_STATE.get('flashResults'))
}
case ACTIONS.UNSET_FLASHING_FLAG: {
// Type: action.data : FlashResultsObject
if (!action.data) {
throw errors.createError({
title: 'Missing results'
})
}
_.defaults(action.data, {
cancelled: false
})
if (!_.isBoolean(action.data.cancelled)) {
throw errors.createError({
title: `Invalid results cancelled: ${action.data.cancelled}`
})
}
if (action.data.cancelled && action.data.sourceChecksum) {
throw errors.createError({
title: 'The sourceChecksum value can\'t exist if the flashing was cancelled'
})
}
if (action.data.sourceChecksum && !_.isString(action.data.sourceChecksum)) {
throw errors.createError({
title: `Invalid results sourceChecksum: ${action.data.sourceChecksum}`
})
}
if (action.data.errorCode && !_.isString(action.data.errorCode) && !_.isNumber(action.data.errorCode)) {
throw errors.createError({
title: `Invalid results errorCode: ${action.data.errorCode}`
})
}
return state
.set('isFlashing', false)
.set('flashResults', Immutable.fromJS(action.data))
.set('flashState', DEFAULT_STATE.get('flashState'))
}
case ACTIONS.SELECT_DRIVE: {
// Type: action.data : String
const device = action.data
if (!device) {
throw errors.createError({
title: 'Missing drive'
})
}
if (!_.isString(device)) {
throw errors.createError({
title: `Invalid drive: ${device}`
})
}
const selectedDrive = _.find(getAvailableDrives(state), { device })
if (!selectedDrive) {
throw errors.createError({
title: `The drive is not available: ${device}`
})
}
if (selectedDrive.isReadOnly) {
throw errors.createError({
title: 'The drive is write-protected'
})
}
const image = state.getIn([ 'selection', 'image' ])
if (image && !constraints.isDriveLargeEnough(selectedDrive, image.toJS())) {
throw errors.createError({
title: 'The drive is not large enough'
})
}
const selectedDevices = state.getIn([ 'selection', 'devices' ])
return state.setIn([ 'selection', 'devices' ], selectedDevices.add(device))
}
// TODO(jhermsmeier): Consolidate these assertions
// with image-stream / supported-formats, and have *one*
// place where all the image extension / format handling
// takes place, to avoid having to check 2+ locations with different logic
case ACTIONS.SELECT_IMAGE: {
// Type: action.data : ImageObject
verifyNoNilFields(action.data, selectImageNoNilFields, 'image')
if (!_.isString(action.data.path)) {
throw errors.createError({
title: `Invalid image path: ${action.data.path}`
})
}
if (!_.isString(action.data.extension)) {
throw errors.createError({
title: `Invalid image extension: ${action.data.extension}`
})
}
const extension = _.toLower(action.data.extension)
if (!_.includes(supportedFormats.getAllExtensions(), extension)) {
throw errors.createError({
title: `Invalid image extension: ${action.data.extension}`
})
}
let lastImageExtension = fileExtensions.getLastFileExtension(action.data.path)
lastImageExtension = _.isString(lastImageExtension) ? _.toLower(lastImageExtension) : lastImageExtension
if (lastImageExtension !== extension) {
if (!_.isString(action.data.archiveExtension)) {
throw errors.createError({
title: 'Missing image archive extension'
})
}
const archiveExtension = _.toLower(action.data.archiveExtension)
if (!_.includes(supportedFormats.getAllExtensions(), archiveExtension)) {
throw errors.createError({
title: `Invalid image archive extension: ${action.data.archiveExtension}`
})
}
if (lastImageExtension !== archiveExtension) {
throw errors.createError({
title: `Image archive extension mismatch: ${action.data.archiveExtension} and ${lastImageExtension}`
})
}
}
const MINIMUM_IMAGE_SIZE = 0
// eslint-disable-next-line no-undefined
if (action.data.size !== undefined) {
if ((action.data.size < MINIMUM_IMAGE_SIZE) || !_.isInteger(action.data.size)) {
throw errors.createError({
title: `Invalid image size: ${action.data.size}`
})
}
}
if (!_.isUndefined(action.data.compressedSize)) {
if ((action.data.compressedSize < MINIMUM_IMAGE_SIZE) || !_.isInteger(action.data.compressedSize)) {
throw errors.createError({
title: `Invalid image compressed size: ${action.data.compressedSize}`
})
}
}
if (action.data.url && !_.isString(action.data.url)) {
throw errors.createError({
title: `Invalid image url: ${action.data.url}`
})
}
if (action.data.name && !_.isString(action.data.name)) {
throw errors.createError({
title: `Invalid image name: ${action.data.name}`
})
}
if (action.data.logo && !_.isString(action.data.logo)) {
throw errors.createError({
title: `Invalid image logo: ${action.data.logo}`
})
}
const selectedDevices = state.getIn([ 'selection', 'devices' ])
// Remove image-incompatible drives from selection with `constraints.isDriveValid`
return _.reduce(selectedDevices.toJS(), (accState, device) => {
const drive = _.find(getAvailableDrives(state), { device })
if (!constraints.isDriveValid(drive, action.data) || !constraints.isDriveSizeRecommended(drive, action.data)) {
return storeReducer(accState, {
type: ACTIONS.DESELECT_DRIVE,
data: device
})
}
return accState
}, state).setIn([ 'selection', 'image' ], Immutable.fromJS(action.data))
}
case ACTIONS.DESELECT_DRIVE: {
// Type: action.data : String
if (!action.data) {
throw errors.createError({
title: 'Missing drive'
})
}
if (!_.isString(action.data)) {
throw errors.createError({
title: `Invalid drive: ${action.data}`
})
}
const selectedDevices = state.getIn([ 'selection', 'devices' ])
// Remove drive from set in state
return state.setIn([ 'selection', 'devices' ], selectedDevices.delete(action.data))
}
case ACTIONS.DESELECT_IMAGE: {
return state.deleteIn([ 'selection', 'image' ])
}
case ACTIONS.SET_APPLICATION_SESSION_UUID: {
return state.set('applicationSessionUuid', action.data)
}
case ACTIONS.SET_FLASHING_WORKFLOW_UUID: {
return state.set('flashingWorkflowUuid', action.data)
}
case ACTIONS.SET_WEBVIEW_SHOWING_STATUS: {
return state.set('isWebviewShowing', action.data)
}
default: {
return state
}
}
}
module.exports = _.merge(redux.createStore(storeReducer, DEFAULT_STATE), {
Actions: ACTIONS,
Defaults: DEFAULT_STATE
})
/**
* @summary Observe the store for changes
* @param {Function} onChange - change handler
* @returns {Function} unsubscribe
* @example
* store.observe((newState) => {
* // ...
* })
*/
module.exports.observe = (onChange) => {
let currentState = null
/**
* @summary Internal change detection handler
* @private
* @example
* store.subscribe(changeHandler)
*/
const changeHandler = () => {
const nextState = module.exports.getState()
if (!_.isEqual(nextState, currentState)) {
currentState = nextState
onChange(currentState)
}
}
changeHandler()
return module.exports.subscribe(changeHandler)
}

View File

@ -1,554 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Immutable from 'immutable';
import * as _ from 'lodash';
import { basename } from 'path';
import * as redux from 'redux';
import { v4 as uuidV4 } from 'uuid';
import * as constraints from '../../../shared/drive-constraints';
import * as errors from '../../../shared/errors';
import * as utils from '../../../shared/utils';
import * as settings from './settings';
/**
* @summary Verify and throw if any state fields are nil
*/
function verifyNoNilFields(
object: _.Dictionary<any>,
fields: string[],
name: string,
) {
const nilFields = _.filter(fields, (field) => {
return _.isNil(_.get(object, field));
});
if (nilFields.length) {
throw new Error(`Missing ${name} fields: ${nilFields.join(', ')}`);
}
}
/**
* @summary FLASH_STATE fields that can't be nil
*/
const flashStateNoNilFields = ['speed'];
/**
* @summary SELECT_IMAGE fields that can't be nil
*/
const selectImageNoNilFields = ['path', 'extension'];
/**
* @summary Application default state
*/
export const DEFAULT_STATE = Immutable.fromJS({
applicationSessionUuid: '',
flashingWorkflowUuid: '',
availableDrives: [],
selection: {
devices: Immutable.OrderedSet(),
},
isFlashing: false,
devicePaths: [],
failedDeviceErrors: [],
flashResults: {},
flashState: {
active: 0,
failed: 0,
percentage: 0,
speed: null,
averageSpeed: null,
},
lastAverageFlashingSpeed: null,
});
/**
* @summary Application supported action messages
*/
export enum Actions {
SET_DEVICE_PATHS,
SET_FAILED_DEVICE_ERRORS,
SET_AVAILABLE_TARGETS,
SET_FLASH_STATE,
RESET_FLASH_STATE,
SET_FLASHING_FLAG,
UNSET_FLASHING_FLAG,
SELECT_TARGET,
SELECT_SOURCE,
DESELECT_TARGET,
DESELECT_SOURCE,
SET_APPLICATION_SESSION_UUID,
SET_FLASHING_WORKFLOW_UUID,
}
interface Action {
type: Actions;
data: any;
}
/**
* @summary Get available drives from the state
*
* @param {Object} state - state object
* @returns {Object} new state
*/
function getAvailableDrives(state: typeof DEFAULT_STATE) {
return state.get('availableDrives').toJS();
}
/**
* @summary The redux store reducer
*/
function storeReducer(
state = DEFAULT_STATE,
action: Action,
): typeof DEFAULT_STATE {
switch (action.type) {
case Actions.SET_AVAILABLE_TARGETS: {
// Type: action.data : Array<DriveObject>
if (!action.data) {
throw errors.createError({
title: 'Missing drives',
});
}
let drives = action.data;
if (!_.isArray(drives) || !_.every(drives, _.isObject)) {
throw errors.createError({
title: `Invalid drives: ${drives}`,
});
}
// Drives order is a list of devicePaths
const drivesOrder = settings.getSync('drivesOrder') ?? [];
drives = _.sortBy(drives, [
// System drives last
(d) => !!d.isSystem,
// Devices with no devicePath first (usbboot)
(d) => !!d.devicePath,
// Sort as defined in the drivesOrder setting if there is one (only for Linux with udev)
(d) => drivesOrder.indexOf(basename(d.devicePath || '')),
// Then sort by devicePath (only available on Linux with udev) or device
(d) => d.devicePath || d.device,
]);
const newState = state.set('availableDrives', Immutable.fromJS(drives));
const selectedDevices = newState.getIn(['selection', 'devices']).toJS();
// Remove selected drives that are stale, i.e. missing from availableDrives
const nonStaleNewState = _.reduce(
selectedDevices,
(accState, device) => {
// Check whether the drive still exists in availableDrives
if (
device &&
!_.find(drives, {
device,
})
) {
// Deselect this drive gone from availableDrives
return storeReducer(accState, {
type: Actions.DESELECT_TARGET,
data: device,
});
}
return accState;
},
newState,
);
const shouldAutoselectAll = Boolean(
settings.getSync('autoSelectAllDrives'),
);
const AUTOSELECT_DRIVE_COUNT = 1;
const nonStaleSelectedDevices = nonStaleNewState
.getIn(['selection', 'devices'])
.toJS();
const hasSelectedDevices =
nonStaleSelectedDevices.length >= AUTOSELECT_DRIVE_COUNT;
const shouldAutoselectOne =
drives.length === AUTOSELECT_DRIVE_COUNT && !hasSelectedDevices;
if (shouldAutoselectOne || shouldAutoselectAll) {
// Even if there's no image selected, we need to call several
// drive/image related checks, and `{}` works fine with them
const image = state
.getIn(['selection', 'image'], Immutable.fromJS({}))
.toJS();
return _.reduce(
drives,
(accState, drive) => {
if (
constraints.isDriveValid(drive, image) &&
!drive.isReadOnly &&
constraints.isDriveSizeRecommended(drive, image) &&
// We don't want to auto-select large drives except if autoSelectAllDrives is true
(!constraints.isDriveSizeLarge(drive) || shouldAutoselectAll) &&
// We don't want to auto-select system drives
!constraints.isSystemDrive(drive)
) {
// Auto-select this drive
return storeReducer(accState, {
type: Actions.SELECT_TARGET,
data: drive.device,
});
}
// Deselect this drive in case it still is selected
return storeReducer(accState, {
type: Actions.DESELECT_TARGET,
data: drive.device,
});
},
nonStaleNewState,
);
}
return nonStaleNewState;
}
case Actions.SET_FLASH_STATE: {
// Type: action.data : FlashStateObject
if (!state.get('isFlashing')) {
throw errors.createError({
title: "Can't set the flashing state when not flashing",
});
}
verifyNoNilFields(action.data, flashStateNoNilFields, 'flash');
if (!_.every(_.pick(action.data, ['active', 'failed']), _.isFinite)) {
throw errors.createError({
title: 'State quantity field(s) not finite number',
});
}
if (
!_.isUndefined(action.data.percentage) &&
!utils.isValidPercentage(action.data.percentage)
) {
throw errors.createError({
title: `Invalid state percentage: ${action.data.percentage}`,
});
}
if (!_.isUndefined(action.data.eta) && !_.isNumber(action.data.eta)) {
throw errors.createError({
title: `Invalid state eta: ${action.data.eta}`,
});
}
let ret = state.set('flashState', Immutable.fromJS(action.data));
if (action.data.type === 'flashing') {
ret = ret.set('lastAverageFlashingSpeed', action.data.averageSpeed);
}
return ret;
}
case Actions.RESET_FLASH_STATE: {
return state
.set('isFlashing', false)
.set('flashState', DEFAULT_STATE.get('flashState'))
.set('flashResults', DEFAULT_STATE.get('flashResults'))
.set('devicePaths', DEFAULT_STATE.get('devicePaths'))
.set('failedDeviceErrors', DEFAULT_STATE.get('failedDeviceErrors'))
.set(
'lastAverageFlashingSpeed',
DEFAULT_STATE.get('lastAverageFlashingSpeed'),
)
.delete('flashUuid');
}
case Actions.SET_FLASHING_FLAG: {
return state
.set('isFlashing', true)
.set('flashUuid', uuidV4())
.set('flashResults', DEFAULT_STATE.get('flashResults'));
}
case Actions.UNSET_FLASHING_FLAG: {
// Type: action.data : FlashResultsObject
if (!action.data) {
throw errors.createError({
title: 'Missing results',
});
}
_.defaults(action.data, {
cancelled: false,
skip: false,
});
if (!_.isBoolean(action.data.cancelled)) {
throw errors.createError({
title: `Invalid results cancelled: ${action.data.cancelled}`,
});
}
if (action.data.cancelled && action.data.sourceChecksum) {
throw errors.createError({
title:
"The sourceChecksum value can't exist if the flashing was cancelled",
});
}
if (
action.data.sourceChecksum &&
!_.isString(action.data.sourceChecksum)
) {
throw errors.createError({
title: `Invalid results sourceChecksum: ${action.data.sourceChecksum}`,
});
}
if (
action.data.errorCode &&
!_.isString(action.data.errorCode) &&
!_.isNumber(action.data.errorCode)
) {
throw errors.createError({
title: `Invalid results errorCode: ${action.data.errorCode}`,
});
}
if (action.data.results) {
action.data.results.averageFlashingSpeed = state.get(
'lastAverageFlashingSpeed',
);
}
if (action.data.skip) {
return state
.set('isFlashing', false)
.set('flashResults', Immutable.fromJS(action.data));
}
return state
.set('isFlashing', false)
.set('flashResults', Immutable.fromJS(action.data))
.set('flashState', DEFAULT_STATE.get('flashState'));
}
case Actions.SELECT_TARGET: {
// Type: action.data : String
const device = action.data;
if (!device) {
throw errors.createError({
title: 'Missing drive',
});
}
if (!_.isString(device)) {
throw errors.createError({
title: `Invalid drive: ${device}`,
});
}
const selectedDrive = _.find(getAvailableDrives(state), { device });
if (!selectedDrive) {
throw errors.createError({
title: `The drive is not available: ${device}`,
});
}
if (selectedDrive.isReadOnly) {
throw errors.createError({
title: 'The drive is write-protected',
});
}
const image = state.getIn(['selection', 'image']);
if (
image &&
!constraints.isDriveLargeEnough(selectedDrive, image.toJS())
) {
throw errors.createError({
title: 'The drive is not large enough',
});
}
const selectedDevices = state.getIn(['selection', 'devices']);
return state.setIn(['selection', 'devices'], selectedDevices.add(device));
}
// TODO(jhermsmeier): Consolidate these assertions
// with image-stream / supported-formats, and have *one*
// place where all the image extension / format handling
// takes place, to avoid having to check 2+ locations with different logic
case Actions.SELECT_SOURCE: {
// Type: action.data : ImageObject
if (!action.data.drive) {
verifyNoNilFields(action.data, selectImageNoNilFields, 'image');
}
if (!_.isString(action.data.path)) {
throw errors.createError({
title: `Invalid image path: ${action.data.path}`,
});
}
const MINIMUM_IMAGE_SIZE = 0;
if (action.data.size !== undefined) {
if (
action.data.size < MINIMUM_IMAGE_SIZE ||
!_.isInteger(action.data.size)
) {
throw errors.createError({
title: `Invalid image size: ${action.data.size}`,
});
}
}
if (!_.isUndefined(action.data.compressedSize)) {
if (
action.data.compressedSize < MINIMUM_IMAGE_SIZE ||
!_.isInteger(action.data.compressedSize)
) {
throw errors.createError({
title: `Invalid image compressed size: ${action.data.compressedSize}`,
});
}
}
if (action.data.url && !_.isString(action.data.url)) {
throw errors.createError({
title: `Invalid image url: ${action.data.url}`,
});
}
if (action.data.name && !_.isString(action.data.name)) {
throw errors.createError({
title: `Invalid image name: ${action.data.name}`,
});
}
if (action.data.logo && !_.isString(action.data.logo)) {
throw errors.createError({
title: `Invalid image logo: ${action.data.logo}`,
});
}
const selectedDevices = state.getIn(['selection', 'devices']);
// Remove image-incompatible drives from selection with `constraints.isDriveValid`
return _.reduce(
selectedDevices.toJS(),
(accState, device) => {
const drive = _.find(getAvailableDrives(state), { device });
if (
!constraints.isDriveValid(drive, action.data) ||
!constraints.isDriveSizeRecommended(drive, action.data)
) {
return storeReducer(accState, {
type: Actions.DESELECT_TARGET,
data: device,
});
}
return accState;
},
state,
).setIn(['selection', 'image'], Immutable.fromJS(action.data));
}
case Actions.DESELECT_TARGET: {
// Type: action.data : String
if (!action.data) {
throw errors.createError({
title: 'Missing drive',
});
}
if (!_.isString(action.data)) {
throw errors.createError({
title: `Invalid drive: ${action.data}`,
});
}
const selectedDevices = state.getIn(['selection', 'devices']);
// Remove drive from set in state
return state.setIn(
['selection', 'devices'],
selectedDevices.delete(action.data),
);
}
case Actions.DESELECT_SOURCE: {
return state.deleteIn(['selection', 'image']);
}
case Actions.SET_APPLICATION_SESSION_UUID: {
return state.set('applicationSessionUuid', action.data);
}
case Actions.SET_FLASHING_WORKFLOW_UUID: {
return state.set('flashingWorkflowUuid', action.data);
}
case Actions.SET_DEVICE_PATHS: {
return state.set('devicePaths', action.data);
}
case Actions.SET_FAILED_DEVICE_ERRORS: {
return state.set('failedDeviceErrors', action.data);
}
default: {
return state;
}
}
}
export const store = redux.createStore(storeReducer, DEFAULT_STATE);
/**
* @summary Observe the store for changes
* @param {Function} onChange - change handler
* @returns {Function} unsubscribe
*/
export function observe(onChange: (state: typeof DEFAULT_STATE) => void) {
let currentState: typeof DEFAULT_STATE | null = null;
/**
* @summary Internal change detection handler
*/
const changeHandler = () => {
const nextState = store.getState();
if (!_.isEqual(nextState, currentState)) {
currentState = nextState;
onChange(currentState);
}
};
changeHandler();
return store.subscribe(changeHandler);
}

View File

@ -0,0 +1,148 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const _ = require('lodash')
const resinCorvus = require('resin-corvus/browser')
const packageJSON = require('../../../../package.json')
const settings = require('../models/settings')
const { getConfig, hasProps } = require('../../../shared/utils')
const sentryToken = settings.get('analyticsSentryToken') ||
_.get(packageJSON, [ 'analytics', 'sentry', 'token' ])
const mixpanelToken = settings.get('analyticsMixpanelToken') ||
_.get(packageJSON, [ 'analytics', 'mixpanel', 'token' ])
const configUrl = settings.get('configUrl') || 'https://balena.io/etcher/static/config.json'
const DEFAULT_PROBABILITY = 0.1
const services = {
sentry: sentryToken,
mixpanel: mixpanelToken
}
resinCorvus.install({
services,
options: {
release: packageJSON.version,
shouldReport: () => {
return settings.get('errorReporting')
},
mixpanelDeferred: true
}
})
let mixpanelSample = DEFAULT_PROBABILITY
/**
* @summary Init analytics configurations
* @example initConfig()
*/
const initConfig = async () => {
let validatedConfig = null
try {
const config = await getConfig(configUrl)
const mixpanel = _.get(config, [ 'analytics', 'mixpanel' ], {})
mixpanelSample = mixpanel.probability || DEFAULT_PROBABILITY
if (isClientEligible(mixpanelSample)) {
validatedConfig = validateMixpanelConfig(mixpanel)
}
} catch (err) {
resinCorvus.logException(err)
}
resinCorvus.setConfigs({
mixpanel: validatedConfig
})
}
initConfig()
/**
* @summary Check that the client is eligible for analytics
* @param {Object} - config
*/
// eslint-disable-next-line
function isClientEligible(probability) {
return Math.random() < probability
}
/**
* @summary Check that config has at least HTTP_PROTOCOL and api_host
* @param {Object} - config
*/
// eslint-disable-next-line
function validateMixpanelConfig (config) {
/* eslint-disable camelcase */
const mixpanelConfig = {
api_host: 'https://api.mixpanel.com'
}
if (hasProps(config, [ 'HTTP_PROTOCOL', 'api_host' ])) {
mixpanelConfig.api_host = `${config.HTTP_PROTOCOL}://${config.api_host}`
}
return mixpanelConfig
/* eslint-enable camelcase */
}
/**
* @summary Log a debug message
* @function
* @public
*
* @description
* This function sends the debug message to error reporting services.
*
* @param {String} message - message
*
* @example
* analytics.log('Hello World');
*/
exports.logDebug = resinCorvus.logDebug
/**
* @summary Log an event
* @function
* @public
*
* @description
* This function sends the debug message to product analytics services.
*
* @param {String} message - message
* @param {Object} [data] - event data
*
* @example
* analytics.logEvent('Select image', {
* image: '/dev/disk2'
* });
*/
exports.logEvent = (message, data) => {
resinCorvus.logEvent(message, { ...data, sample: mixpanelSample })
}
/**
* @summary Log an exception
* @function
* @public
*
* @description
* This function logs an exception to error reporting services.
*
* @param {Error} exception - exception
*
* @example
* analytics.logException(new Error('Something happened'));
*/
exports.logException = resinCorvus.logException

View File

@ -1,140 +0,0 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { findLastIndex, once } from 'lodash';
import * as SentryRenderer from '@sentry/electron/renderer';
import * as settings from '../models/settings';
type AnalyticsPayload = _.Dictionary<any>;
const clearUserPath = (filename: string): string => {
const generatedFile = filename.split('generated').reverse()[0];
return generatedFile !== filename ? `generated${generatedFile}` : filename;
};
export const anonymizeSentryData = (
event: SentryRenderer.Event,
): SentryRenderer.Event => {
event.exception?.values?.forEach((exception) => {
exception.stacktrace?.frames?.forEach((frame) => {
if (frame.filename) {
frame.filename = clearUserPath(frame.filename);
}
});
});
event.breadcrumbs?.forEach((breadcrumb) => {
if (breadcrumb.data?.url) {
breadcrumb.data.url = clearUserPath(breadcrumb.data.url);
}
});
if (event.request?.url) {
event.request.url = clearUserPath(event.request.url);
}
return event;
};
const extractPathRegex = /(.*)(^|\s)(file:\/\/)?(\w:)?([\\/].+)/;
const etcherSegmentMarkers = ['app.asar', 'Resources'];
export const anonymizePath = (input: string) => {
// First, extract a part of the value that matches a path pattern.
const match = extractPathRegex.exec(input);
if (match === null) {
return input;
}
const mainPart = match[5];
const space = match[2];
const beginning = match[1];
const uriPrefix = match[3] || '';
// We have to deal with both Windows and POSIX here.
// The path starts with its separator (we work with absolute paths).
const sep = mainPart[0];
const segments = mainPart.split(sep);
// Moving from the end, find the first marker and cut the path from there.
const startCutIndex = findLastIndex(segments, (segment) =>
etcherSegmentMarkers.includes(segment),
);
return (
beginning +
space +
uriPrefix +
'[PERSONAL PATH]' +
sep +
segments.splice(startCutIndex).join(sep)
);
};
const safeAnonymizePath = (input: string) => {
try {
return anonymizePath(input);
} catch (e) {
return '[ANONYMIZE PATH FAILED]';
}
};
const sensitiveEtcherProperties = [
'error.description',
'error.message',
'error.stack',
'image',
'image.path',
'path',
];
export const anonymizeAnalyticsPayload = (
data: AnalyticsPayload,
): AnalyticsPayload => {
for (const prop of sensitiveEtcherProperties) {
const value = data[prop];
if (value != null) {
data[prop] = safeAnonymizePath(value.toString());
}
}
return data;
};
/**
* @summary Init analytics configurations
*/
export const initAnalytics = once(() => {
const dsn =
settings.getSync('analyticsSentryToken') || process.env.SENTRY_TOKEN;
SentryRenderer.init({
dsn,
beforeSend: anonymizeSentryData,
debug: process.env.ETCHER_SENTRY_DEBUG === 'true',
});
});
/**
* @summary Log an exception
*
* @description
* This function logs an exception to error reporting services.
*/
export function logException(error: any) {
const shouldReportErrors = settings.getSync('errorReporting');
console.error(error);
if (shouldReportErrors) {
initAnalytics();
SentryRenderer.captureException(error);
}
}

View File

@ -1,253 +0,0 @@
/** This function will :
* - start the ipc server (api)
* - spawn the child process (privileged or not)
* - wait for the child process to connect to the api
* - return a promise that will resolve with the emit function for the api
*
* //TODO:
* - this should be refactored to reverse the control flow:
* - the child process should be the server
* - this should be the client
* - replace the current node-ipc api with a websocket api
* - centralise the api for both the writer and the scanner instead of having two instances running
*/
import WebSocket from 'ws'; // (no types for wrapper, this is expected)
import { spawn, exec } from 'child_process';
import * as os from 'os';
import * as packageJSON from '../../../../package.json';
import * as permissions from '../../../shared/permissions';
import * as errors from '../../../shared/errors';
const THREADS_PER_CPU = 16;
const connectionRetryDelay = 1000;
const connectionRetryAttempts = 10;
async function writerArgv(): Promise<string[]> {
let entryPoint = await window.etcher.getEtcherUtilPath();
// AppImages run over FUSE, so the files inside the mount point
// can only be accessed by the user that mounted the AppImage.
// This means we can't re-spawn Etcher as root from the same
// mount-point, and as a workaround, we re-mount the original
// AppImage as root.
if (os.platform() === 'linux' && process.env.APPIMAGE && process.env.APPDIR) {
entryPoint = entryPoint.replace(process.env.APPDIR, '');
return [
process.env.APPIMAGE,
'-e',
`require(\`\${process.env.APPDIR}${entryPoint}\`)`,
];
} else {
return [entryPoint];
}
}
async function spawnChild(
withPrivileges: boolean,
etcherServerId: string,
etcherServerAddress: string,
etcherServerPort: string,
) {
const argv = await writerArgv();
const env: any = {
ETCHER_SERVER_ADDRESS: etcherServerAddress,
ETCHER_SERVER_ID: etcherServerId,
ETCHER_SERVER_PORT: etcherServerPort,
UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(),
// This environment variable prevents the AppImages
// desktop integration script from presenting the
// "installation" dialog
SKIP: '1',
...(process.platform === 'win32' ? {} : process.env),
};
if (withPrivileges) {
console.log('... with privileges ...');
return permissions.elevateCommand(argv, {
applicationName: packageJSON.displayName,
env,
});
} else {
if (process.platform === 'win32') {
// we need to ensure we reset the env as a previous elevation process might have kept them in a wrong state
const envCommand = [];
for (const key in env) {
if (Object.prototype.hasOwnProperty.call(env, key)) {
envCommand.push(`set ${key}=${env[key]}`);
}
}
await exec(envCommand.join(' && '));
}
const spawned = await spawn(argv[0], argv.slice(1), {
env,
});
return { cancelled: false, spawned };
}
}
type ChildApi = {
emit: (type: string, payload: any) => void;
registerHandler: (event: string, handler: any) => void;
failed: boolean;
};
async function connectToChildProcess(
etcherServerAddress: string,
etcherServerPort: string,
etcherServerId: string,
): Promise<ChildApi | { failed: boolean }> {
return new Promise((resolve, reject) => {
// TODO: default to IPC connections https://github.com/websockets/ws/blob/master/doc/ws.md#ipc-connections
// TODO: use the path as cheap authentication
console.log(etcherServerId);
const url = `ws://${etcherServerAddress}:${etcherServerPort}`;
const ws = new WebSocket(url);
let heartbeat: any;
const startHeartbeat = (emit: any) => {
console.log('start heartbeat');
heartbeat = setInterval(() => {
emit('heartbeat', {});
}, 1000);
};
const stopHeartbeat = () => {
console.log('stop heartbeat');
clearInterval(heartbeat);
};
ws.on('error', (error: any) => {
if (error.code === 'ECONNREFUSED') {
resolve({
failed: true,
});
} else {
stopHeartbeat();
reject({
failed: true,
});
}
});
ws.on('open', () => {
const emit = (type: string, payload: any) => {
ws.send(JSON.stringify({ type, payload }));
};
emit('ready', {});
// parse and route messages
const messagesHandler: any = {
log: (message: any) => {
console.log(`CHILD LOG: ${message}`);
},
error: (error: any) => {
const errorObject = errors.fromJSON(error);
console.error('CHILD ERROR', errorObject);
stopHeartbeat();
},
// once api is ready (means child process is connected) we pass the emit function to the caller
ready: () => {
console.log('CHILD READY');
startHeartbeat(emit);
resolve({
failed: false,
emit,
registerHandler,
});
},
};
ws.on('message', (jsonData: any) => {
const data = JSON.parse(jsonData);
const message = messagesHandler[data.type];
if (message) {
message(data.payload);
} else {
throw new Error(`Unknown message type: ${data.type}`);
}
});
// api to register more handlers with callbacks
const registerHandler = (event: string, handler: any) => {
messagesHandler[event] = handler;
};
});
});
}
async function spawnChildAndConnect({
withPrivileges,
}: {
withPrivileges: boolean;
}): Promise<ChildApi> {
const etcherServerAddress = process.env.ETCHER_SERVER_ADDRESS ?? '127.0.0.1'; // localhost
const etcherServerPort =
process.env.ETCHER_SERVER_PORT ?? withPrivileges ? '3435' : '3434';
const etcherServerId =
process.env.ETCHER_SERVER_ID ??
`etcher-${Math.random().toString(36).substring(7)}`;
console.log(
`Starting ${
withPrivileges ? 'priviledged' : 'unpriviledged'
} flasher sidecar on port ${etcherServerPort}`,
);
// spawn the child process, which will act as the ws server
// ETCHER_NO_SPAWN_UTIL can be set to launch a GUI only version of etcher, in that case you'll probably want to set other ENV to match your setup
if (!process.env.ETCHER_NO_SPAWN_UTIL) {
try {
const result = await spawnChild(
withPrivileges,
etcherServerId,
etcherServerAddress,
etcherServerPort,
);
if (result.cancelled) {
throw new Error('Starting flasher sidecar process was cancelled');
}
} catch (error) {
console.error('Error starting flasher sidecar process', error);
throw new Error('Error starting flasher sidecar process');
}
}
// try to connect to the ws server, retrying if necessary, until the connection is established
try {
let retry = 0;
while (retry < connectionRetryAttempts) {
const { emit, registerHandler, failed } = await connectToChildProcess(
etcherServerAddress,
etcherServerPort,
etcherServerId,
);
if (failed) {
retry++;
console.log(
`Connection to sidecar flasher process attempt ${retry} / ${connectionRetryAttempts} failed; retrying in ${connectionRetryDelay}ms...`,
);
await new Promise((resolve) =>
setTimeout(resolve, connectionRetryDelay),
);
continue;
}
return { failed, emit, registerHandler };
}
// TODO: raised an error to the user if we reach this point
throw new Error('Connection to sidecar flasher process timed out');
} catch (error) {
console.error('Error connecting to sidecar flasher process process', error);
throw new Error('Connection to sidecar flasher process failed');
}
}
export { spawnChildAndConnect };

View File

@ -0,0 +1,54 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const sdk = require('etcher-sdk')
const process = require('process')
const settings = require('../models/settings')
/**
* @summary returns true if system drives should be shown
* @function
*
* @returns {Boolean}
*
* @example
* const shouldInclude = includeSystemDrives()
*/
const includeSystemDrives = () => {
return settings.get('unsafeMode') && !settings.get('disableUnsafeMode')
}
const adapters = [
new sdk.scanner.adapters.BlockDeviceAdapter(includeSystemDrives)
]
// Can't use permissions.isElevated() here as it returns a promise and we need to set
// module.exports = scanner right now.
// eslint-disable-next-line no-magic-numbers
if ((process.platform !== 'linux') || (process.geteuid() === 0)) {
adapters.push(new sdk.scanner.adapters.UsbbootDeviceAdapter())
}
if (process.platform === 'win32') {
adapters.push(new sdk.scanner.adapters.DriverlessDeviceAdapter())
}
const scanner = new sdk.scanner.Scanner(adapters)
module.exports = scanner

View File

@ -0,0 +1,40 @@
/*
* Copyright 2016 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const _ = require('lodash')
const analytics = require('../modules/analytics')
const osDialog = require('../os/dialog')
/**
* @summary Report an exception
* @function
* @public
*
* @param {Error} exception - exception
*
* @example
* exceptionReporter.report(new Error('Something happened'));
*/
exports.report = (exception) => {
if (_.isUndefined(exception)) {
return
}
osDialog.showError(exception)
analytics.logException(exception)
}

Some files were not shown because too many files have changed in this diff Show More