Compare commits
No commits in common. "master" and "v1.0.0-rc.1" have entirely different histories.
master
...
v1.0.0-rc.
@ -1,2 +1 @@
|
||||
*
|
||||
!requirements.txt
|
||||
|
@ -7,15 +7,7 @@ indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.ts]
|
||||
indent_style = tab
|
||||
|
||||
[*.tsx]
|
||||
indent_style = tab
|
||||
|
10
.eslintrc.js
@ -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",
|
||||
},
|
||||
};
|
647
.eslintrc.yml
Normal file
@ -0,0 +1,647 @@
|
||||
env:
|
||||
browser: true
|
||||
commonjs: true
|
||||
es6: true
|
||||
node: true
|
||||
mocha: true
|
||||
plugins:
|
||||
- lodash
|
||||
extends: 'eslint:recommended'
|
||||
rules:
|
||||
|
||||
# Possible Errors
|
||||
|
||||
no-cond-assign:
|
||||
- error
|
||||
no-console:
|
||||
- off
|
||||
no-constant-condition:
|
||||
- error
|
||||
no-control-regex:
|
||||
- error
|
||||
no-debugger:
|
||||
- error
|
||||
no-dupe-args:
|
||||
- error
|
||||
no-dupe-keys:
|
||||
- error
|
||||
no-duplicate-case:
|
||||
- error
|
||||
no-empty:
|
||||
- error
|
||||
no-empty-character-class:
|
||||
- error
|
||||
no-ex-assign:
|
||||
- error
|
||||
no-extra-boolean-cast:
|
||||
- error
|
||||
no-extra-parens:
|
||||
- error
|
||||
no-extra-semi:
|
||||
- error
|
||||
no-func-assign:
|
||||
- error
|
||||
no-inner-declarations:
|
||||
- error
|
||||
- both
|
||||
no-invalid-regexp:
|
||||
- error
|
||||
no-irregular-whitespace:
|
||||
- error
|
||||
no-negated-in-lhs:
|
||||
- error
|
||||
no-obj-calls:
|
||||
- error
|
||||
no-prototype-builtins:
|
||||
- error
|
||||
no-regex-spaces:
|
||||
- error
|
||||
no-sparse-arrays:
|
||||
- error
|
||||
no-template-curly-in-string:
|
||||
- error
|
||||
no-unexpected-multiline:
|
||||
- error
|
||||
no-unreachable:
|
||||
- error
|
||||
no-unsafe-finally:
|
||||
- error
|
||||
use-isnan:
|
||||
- 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"
|
||||
valid-typeof:
|
||||
- error
|
||||
|
||||
# Best Practices
|
||||
|
||||
accessor-pairs:
|
||||
- error
|
||||
array-callback-return:
|
||||
- error
|
||||
block-scoped-var:
|
||||
- error
|
||||
class-methods-use-this:
|
||||
- error
|
||||
complexity:
|
||||
- off
|
||||
consistent-return:
|
||||
- error
|
||||
curly:
|
||||
- error
|
||||
default-case:
|
||||
- error
|
||||
dot-location:
|
||||
- error
|
||||
- property
|
||||
dot-notation:
|
||||
- error
|
||||
eqeqeq:
|
||||
- error
|
||||
guard-for-in:
|
||||
- error
|
||||
no-alert:
|
||||
- error
|
||||
no-caller:
|
||||
- error
|
||||
no-case-declarations:
|
||||
- error
|
||||
no-div-regex:
|
||||
- error
|
||||
no-else-return:
|
||||
- error
|
||||
no-empty-function:
|
||||
- error
|
||||
no-empty-pattern:
|
||||
- error
|
||||
no-eq-null:
|
||||
- error
|
||||
no-eval:
|
||||
- error
|
||||
no-extend-native:
|
||||
- error
|
||||
no-extra-bind:
|
||||
- error
|
||||
no-extra-label:
|
||||
- error
|
||||
no-fallthrough:
|
||||
- error
|
||||
no-floating-decimal:
|
||||
- error
|
||||
no-global-assign:
|
||||
- error
|
||||
no-implicit-coercion:
|
||||
- error
|
||||
no-implicit-globals:
|
||||
- error
|
||||
no-implied-eval:
|
||||
- error
|
||||
no-iterator:
|
||||
- error
|
||||
no-labels:
|
||||
- error
|
||||
no-lone-blocks:
|
||||
- error
|
||||
no-loop-func:
|
||||
- error
|
||||
no-magic-numbers:
|
||||
- error
|
||||
no-multi-spaces:
|
||||
- error
|
||||
no-multi-str:
|
||||
- error
|
||||
no-native-reassign:
|
||||
- error
|
||||
no-new:
|
||||
- error
|
||||
no-new-func:
|
||||
- error
|
||||
no-new-wrappers:
|
||||
- error
|
||||
no-octal:
|
||||
- error
|
||||
no-octal-escape:
|
||||
- error
|
||||
no-param-reassign:
|
||||
- error
|
||||
no-proto:
|
||||
- error
|
||||
no-redeclare:
|
||||
- error
|
||||
no-restricted-properties:
|
||||
- error
|
||||
- property: __proto__
|
||||
no-return-assign:
|
||||
- error
|
||||
no-return-await:
|
||||
- error
|
||||
no-script-url:
|
||||
- error
|
||||
no-self-assign:
|
||||
- error
|
||||
no-self-compare:
|
||||
- error
|
||||
no-sequences:
|
||||
- error
|
||||
no-throw-literal:
|
||||
- error
|
||||
no-unmodified-loop-condition:
|
||||
- error
|
||||
no-unused-expressions:
|
||||
- error
|
||||
no-unused-labels:
|
||||
- error
|
||||
no-useless-call:
|
||||
- error
|
||||
no-useless-concat:
|
||||
- error
|
||||
no-useless-escape:
|
||||
- error
|
||||
no-void:
|
||||
- error
|
||||
no-warning-comments:
|
||||
- off
|
||||
no-with:
|
||||
- error
|
||||
radix:
|
||||
- error
|
||||
vars-on-top:
|
||||
- off
|
||||
wrap-iife:
|
||||
- error
|
||||
- outside
|
||||
yoda:
|
||||
- error
|
||||
|
||||
# Strict mode
|
||||
|
||||
strict:
|
||||
- error
|
||||
- global
|
||||
|
||||
# Variables
|
||||
|
||||
init-declarations:
|
||||
- error
|
||||
- always
|
||||
no-catch-shadow:
|
||||
- error
|
||||
no-delete-var:
|
||||
- error
|
||||
no-label-var:
|
||||
- error
|
||||
no-restricted-globals:
|
||||
- error
|
||||
- event
|
||||
no-shadow:
|
||||
- error
|
||||
no-shadow-restricted-names:
|
||||
- error
|
||||
no-undef:
|
||||
- error
|
||||
no-undef-init:
|
||||
- error
|
||||
no-undefined:
|
||||
- error
|
||||
no-unused-vars:
|
||||
- error
|
||||
no-use-before-define:
|
||||
- error
|
||||
|
||||
# NodeJS and CommonJS
|
||||
|
||||
callback-return:
|
||||
- error
|
||||
global-require:
|
||||
- off
|
||||
handle-callback-err:
|
||||
- error
|
||||
no-mixed-requires:
|
||||
- error
|
||||
no-new-require:
|
||||
- error
|
||||
no-path-concat:
|
||||
- error
|
||||
no-process-env:
|
||||
- off
|
||||
no-process-exit:
|
||||
- off
|
||||
no-sync:
|
||||
- off
|
||||
|
||||
# Stylistic Issues
|
||||
|
||||
array-bracket-spacing:
|
||||
- error
|
||||
- always
|
||||
block-spacing:
|
||||
- error
|
||||
brace-style:
|
||||
- error
|
||||
- 1tbs
|
||||
camelcase:
|
||||
- error
|
||||
capitalized-comments:
|
||||
- error
|
||||
- always
|
||||
- ignoreConsecutiveComments: true
|
||||
comma-dangle:
|
||||
- error
|
||||
- never
|
||||
comma-spacing:
|
||||
- error
|
||||
- before: false
|
||||
after: true
|
||||
comma-style:
|
||||
- error
|
||||
- last
|
||||
computed-property-spacing:
|
||||
- error
|
||||
- never
|
||||
consistent-this:
|
||||
- error
|
||||
- self
|
||||
eol-last:
|
||||
- error
|
||||
func-call-spacing:
|
||||
- error
|
||||
- never
|
||||
func-name-matching:
|
||||
- error
|
||||
- always
|
||||
func-names:
|
||||
- error
|
||||
- never
|
||||
func-style:
|
||||
- error
|
||||
- expression
|
||||
id-blacklist:
|
||||
- error
|
||||
id-length:
|
||||
- error
|
||||
- min: 2
|
||||
exceptions:
|
||||
- "_"
|
||||
indent:
|
||||
- error
|
||||
- 2
|
||||
- SwitchCase: 1
|
||||
key-spacing:
|
||||
- error
|
||||
- beforeColon: false
|
||||
afterColon: true
|
||||
mode: strict
|
||||
keyword-spacing:
|
||||
- error
|
||||
- before: true
|
||||
after: true
|
||||
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:
|
||||
- error
|
||||
- never
|
||||
new-cap:
|
||||
- error
|
||||
new-parens:
|
||||
- error
|
||||
newline-per-chained-call:
|
||||
- off
|
||||
no-array-constructor:
|
||||
- error
|
||||
no-bitwise:
|
||||
- error
|
||||
no-continue:
|
||||
- error
|
||||
no-inline-comments:
|
||||
- error
|
||||
no-lonely-if:
|
||||
- error
|
||||
no-mixed-operators:
|
||||
- error
|
||||
no-mixed-spaces-and-tabs:
|
||||
- error
|
||||
no-multi-assign:
|
||||
- error
|
||||
no-multiple-empty-lines:
|
||||
- error
|
||||
- max: 1
|
||||
maxEOF: 1
|
||||
maxBOF: 0
|
||||
no-negated-condition:
|
||||
- error
|
||||
no-nested-ternary:
|
||||
- error
|
||||
no-new-object:
|
||||
- error
|
||||
no-plusplus:
|
||||
- error
|
||||
no-restricted-syntax:
|
||||
- error
|
||||
- WithStatement
|
||||
- ForInStatement
|
||||
no-spaced-func:
|
||||
- error
|
||||
no-tabs:
|
||||
- error
|
||||
no-trailing-spaces:
|
||||
- error
|
||||
no-underscore-dangle:
|
||||
- error
|
||||
- allowAfterThis: false
|
||||
no-unneeded-ternary:
|
||||
- error
|
||||
no-whitespace-before-property:
|
||||
- error
|
||||
object-curly-newline:
|
||||
- error
|
||||
- minProperties: 1
|
||||
object-curly-spacing:
|
||||
- error
|
||||
- always
|
||||
object-property-newline:
|
||||
- error
|
||||
one-var-declaration-per-line:
|
||||
- error
|
||||
- always
|
||||
one-var:
|
||||
- error
|
||||
- never
|
||||
operator-assignment:
|
||||
- error
|
||||
- always
|
||||
operator-linebreak:
|
||||
- error
|
||||
- before
|
||||
padded-blocks:
|
||||
- error
|
||||
- classes: always
|
||||
quote-props:
|
||||
- error
|
||||
- as-needed
|
||||
quotes:
|
||||
- error
|
||||
- single
|
||||
require-jsdoc:
|
||||
- error
|
||||
- require:
|
||||
FunctionDeclaration: true
|
||||
ClassDeclaration: true
|
||||
MethodDefinition: true
|
||||
ArrowFunctionExpression: true
|
||||
semi:
|
||||
- error
|
||||
- always
|
||||
semi-spacing:
|
||||
- error
|
||||
- before: false
|
||||
after: true
|
||||
space-before-blocks:
|
||||
- error
|
||||
space-before-function-paren:
|
||||
- error
|
||||
- never
|
||||
space-in-parens:
|
||||
- error
|
||||
- never
|
||||
space-infix-ops:
|
||||
- error
|
||||
space-unary-ops:
|
||||
- error
|
||||
- words: true
|
||||
nonwords: false
|
||||
spaced-comment:
|
||||
- error
|
||||
- always
|
||||
template-tag-spacing:
|
||||
- error
|
||||
- always
|
||||
unicode-bom:
|
||||
- error
|
||||
|
||||
# ECMAScript 6
|
||||
|
||||
arrow-body-style:
|
||||
- error
|
||||
- always
|
||||
arrow-parens:
|
||||
- error
|
||||
- always
|
||||
arrow-spacing:
|
||||
- error
|
||||
- before: true
|
||||
after: true
|
||||
constructor-super:
|
||||
- error
|
||||
generator-star-spacing:
|
||||
- error
|
||||
- before: true
|
||||
after: false
|
||||
no-class-assign:
|
||||
- error
|
||||
no-confusing-arrow:
|
||||
- error
|
||||
no-const-assign:
|
||||
- error
|
||||
no-dupe-class-members:
|
||||
- error
|
||||
no-duplicate-imports:
|
||||
- error
|
||||
no-new-symbol:
|
||||
- error
|
||||
no-this-before-super:
|
||||
- error
|
||||
no-useless-computed-key:
|
||||
- error
|
||||
no-useless-constructor:
|
||||
- error
|
||||
no-useless-rename:
|
||||
- error
|
||||
no-var:
|
||||
- error
|
||||
object-shorthand:
|
||||
- error
|
||||
- always
|
||||
prefer-const:
|
||||
- error
|
||||
prefer-reflect:
|
||||
- error
|
||||
prefer-spread:
|
||||
- error
|
||||
prefer-numeric-literals:
|
||||
- error
|
||||
prefer-rest-params:
|
||||
- error
|
||||
prefer-template:
|
||||
- error
|
||||
prefer-arrow-callback:
|
||||
- error
|
||||
- allowNamedFunctions: false
|
||||
require-yield:
|
||||
- error
|
||||
rest-spread-spacing:
|
||||
- error
|
||||
template-curly-spacing:
|
||||
- error
|
||||
- never
|
||||
symbol-description:
|
||||
- error
|
||||
yield-star-spacing:
|
||||
- error
|
||||
- before: true
|
||||
after: false
|
||||
|
||||
# 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
|
57
.gitattributes
vendored
@ -1,26 +1,18 @@
|
||||
# default
|
||||
* text
|
||||
|
||||
# Javascript files must retain LF line-endings (to keep eslint happy)
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
# CSS and SCSS files must retain LF line-endings (to keep ensure-staged-sass.sh happy)
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
|
||||
# Text files
|
||||
dictionary text
|
||||
Dockerfile* text
|
||||
.dockerignore text
|
||||
.editorconfig text
|
||||
etcher text
|
||||
.git* text
|
||||
*.html text
|
||||
*.json text eol=lf
|
||||
*.cpp text
|
||||
*.h text
|
||||
*.gyp text
|
||||
*.json text
|
||||
LICENSE text
|
||||
Makefile text
|
||||
*.md text
|
||||
@ -28,41 +20,14 @@ Makefile text
|
||||
*.bat text
|
||||
*.svg text
|
||||
*.yml text
|
||||
*.patch text
|
||||
*.txt text
|
||||
*.tpl text
|
||||
CODEOWNERS text
|
||||
*.plist text
|
||||
|
||||
# Binary files (no line-ending conversions)
|
||||
*.bz2 binary diff=hex
|
||||
*.gz binary diff=hex
|
||||
*.icns binary diff=hex
|
||||
*.ico binary diff=hex
|
||||
*.tiff binary diff=hex
|
||||
*.img binary diff=hex
|
||||
*.iso binary diff=hex
|
||||
*.png binary diff=hex
|
||||
*.bin binary diff=hex
|
||||
*.elf binary diff=hex
|
||||
*.xz binary diff=hex
|
||||
*.zip binary diff=hex
|
||||
*.dtb binary diff=hex
|
||||
*.dtbo binary diff=hex
|
||||
*.dat binary diff=hex
|
||||
*.bin binary diff=hex
|
||||
*.dmg binary diff=hex
|
||||
*.rpi-sdcard binary diff=hex
|
||||
*.wic binary diff=hex
|
||||
*.foo binary diff=hex
|
||||
*.eot binary diff=hex
|
||||
*.otf binary diff=hex
|
||||
*.woff binary diff=hex
|
||||
*.woff2 binary diff=hex
|
||||
*.ttf binary diff=hex
|
||||
xz-without-extension binary diff=hex
|
||||
wmic-output.txt binary diff=hex
|
||||
|
||||
# gitsecret
|
||||
*.secret binary
|
||||
.gitsecret/** binary
|
||||
*.bz2 binary
|
||||
*.gz binary
|
||||
*.icns binary
|
||||
*.ico binary
|
||||
*.img binary
|
||||
*.png binary
|
||||
*.xz binary
|
||||
*.zip binary
|
||||
*.dmg binary
|
||||
|
14
.github/ISSUE_TEMPLATE.md
vendored
@ -1,11 +1,5 @@
|
||||
- **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. -->
|
||||
- **Etcher version:**
|
||||
- **Operating system and architecture:**
|
||||
- **Do you see any meaningful error information on DevTools?**
|
||||
|
||||
<!-- 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+Alt+I`, or `Cmd+Alt+I` if you're running OS X. -->
|
||||
|
205
.github/actions/publish/action.yml
vendored
@ -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.x"
|
||||
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
|
||||
/c/Windows/System32/certutil.exe -csp "DigiCert Signing Manager KSP" -key -user
|
||||
smksp_cert_sync.exe
|
||||
|
||||
# (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
|
87
.github/actions/test/action.yml
vendored
@ -1,87 +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.10'
|
||||
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
|
||||
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
|
41
.github/workflows/flowzone.yml
vendored
@ -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-2019"],
|
||||
["macos-13"],
|
||||
["macos-latest-xlarge"]
|
||||
]
|
||||
}
|
||||
custom_publish_matrix: >
|
||||
{
|
||||
"os": [
|
||||
["ubuntu-22.04"],
|
||||
["windows-2019"],
|
||||
["macos-13"],
|
||||
["macos-latest-xlarge"]
|
||||
]
|
||||
}
|
||||
restrict_custom_actions: false
|
||||
github_prerelease: true
|
||||
cloudflare_website: "etcher"
|
14
.github/workflows/winget.yml
vendored
@ -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 }}
|
102
.gitignore
vendored
@ -1,103 +1,36 @@
|
||||
|
||||
# -- ADD NEW ENTRIES AT THE END OF THE FILE ---
|
||||
|
||||
# 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
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# 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)
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
# Dependency directory
|
||||
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
|
||||
node_modules
|
||||
bower_components
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# 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 Electron releases
|
||||
release/
|
||||
etcher-release/
|
||||
|
||||
# Certificates
|
||||
*.spc
|
||||
@ -106,18 +39,3 @@ dist/
|
||||
*.cer
|
||||
*.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
|
||||
|
||||
# Image stream output directory
|
||||
/tests/image-stream/output
|
||||
|
||||
#local development
|
||||
.yalc
|
||||
yalc.lock
|
@ -1,5 +0,0 @@
|
||||
secrets/APPLE_SIGNING_PASSWORD.txt:5c9cfeb1ea5142b547bc842cc6e0b4a932641ae9811ee47abe2c3953f2a4de5d
|
||||
secrets/WINDOWS_SIGNING_PASSWORD.txt:852e431628494f2559793c39cf09c34e9406dd79bb15b90c9f88194020470568
|
||||
secrets/XCODE_APP_LOADER_PASSWORD.txt:005eb9a3c7035c77232973c9355468fc396b94e62783fb8e6dce16bce95b94a1
|
||||
secrets/WINDOWS_SIGNING.pfx:929f401db38733ffc41572539de7c0d938023af51ed06c205a72a71c1f815714
|
||||
secrets/APPLE_SIGNING.p12:61abf7b4ff2eec76ce889d71bcdd568b99a6a719b4947ac20f03966265b0946a
|
@ -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"),
|
||||
);
|
17
.sass-lint.yml
Normal 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
|
||||
|
74
.travis.yml
Normal file
@ -0,0 +1,74 @@
|
||||
language: node_js
|
||||
sudo: false
|
||||
node_js:
|
||||
- "6.1.0"
|
||||
|
||||
git:
|
||||
depth: 3
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- ubuntu-toolchain-r-test
|
||||
packages:
|
||||
- libstdc++-6-dev
|
||||
|
||||
env:
|
||||
global:
|
||||
- CCACHE_TEMPDIR=/tmp/.ccache-temp
|
||||
- CCACHE_COMPRESS=1
|
||||
- CC="clang"
|
||||
- CXX="clang++"
|
||||
matrix:
|
||||
- TARGET_ARCH=x64
|
||||
- TARGET_ARCH=x86
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
exclude:
|
||||
- os: osx
|
||||
env: TARGET_ARCH=x86
|
||||
|
||||
os:
|
||||
- linux
|
||||
- osx
|
||||
|
||||
before_install:
|
||||
- npm config set spin=false
|
||||
|
||||
install:
|
||||
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then
|
||||
./scripts/build/docker/run-command.sh -r "${TARGET_ARCH}" -s "${PWD}" -c "make info && make electron-develop";
|
||||
else
|
||||
pip install codespell==1.9.2 awscli;
|
||||
npm install -g asar;
|
||||
brew install afsctool jq;
|
||||
make info;
|
||||
travis_wait make electron-develop;
|
||||
fi
|
||||
|
||||
script:
|
||||
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then
|
||||
./scripts/build/docker/run-command.sh -r "${TARGET_ARCH}" -s "${PWD}" -c "make sanity-checks && xvfb-run --server-args=$XVFB_ARGS npm test";
|
||||
else
|
||||
make sanity-checks && npm test;
|
||||
fi
|
||||
|
||||
deploy:
|
||||
provider: script
|
||||
skip_cleanup: true
|
||||
script: scripts/ci/deploy-travis.sh
|
||||
on:
|
||||
branch: master
|
||||
|
||||
notifications:
|
||||
email: false
|
||||
webhooks:
|
||||
urls:
|
||||
- https://webhooks.gitter.im/e/0a019c8b9828eb9f6a72
|
||||
on_success: change
|
||||
on_failure: always
|
||||
on_start: never
|
2650
CHANGELOG.md
536
Makefile
Normal file
@ -0,0 +1,536 @@
|
||||
# ---------------------------------------------------------------------
|
||||
# Build configuration
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
# This directory will be completely deleted by the `clean` rule
|
||||
BUILD_DIRECTORY ?= release
|
||||
|
||||
# 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_OUTPUT_DIRECTORY = $(BUILD_DIRECTORY)/out
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Application configuration
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
ELECTRON_VERSION = $(shell jq -r '.devDependencies["electron-prebuilt"]' package.json)
|
||||
NODE_VERSION = 6.1.0
|
||||
COMPANY_NAME = $(shell jq -r '.companyName' package.json)
|
||||
APPLICATION_NAME = $(shell jq -r '.displayName' package.json)
|
||||
APPLICATION_DESCRIPTION = $(shell jq -r '.description' package.json)
|
||||
APPLICATION_COPYRIGHT = $(shell jq -r '.copyright' package.json)
|
||||
APPLICATION_CATEGORY = public.app-category.developer-tools
|
||||
APPLICATION_BUNDLE_ID = io.resin.etcher
|
||||
APPLICATION_FILES = lib,assets
|
||||
|
||||
# Add the current commit to the version if release type is "snapshot"
|
||||
RELEASE_TYPE ?= snapshot
|
||||
PACKAGE_JSON_VERSION = $(shell jq -r '.version' package.json)
|
||||
ifeq ($(RELEASE_TYPE),production)
|
||||
APPLICATION_VERSION = $(PACKAGE_JSON_VERSION)
|
||||
S3_BUCKET = resin-production-downloads
|
||||
endif
|
||||
ifeq ($(RELEASE_TYPE),snapshot)
|
||||
CURRENT_COMMIT_HASH = $(shell git log -1 --format="%h")
|
||||
APPLICATION_VERSION = $(PACKAGE_JSON_VERSION)+$(CURRENT_COMMIT_HASH)
|
||||
S3_BUCKET = resin-nightly-downloads
|
||||
endif
|
||||
ifndef APPLICATION_VERSION
|
||||
$(error Invalid release type: $(RELEASE_TYPE))
|
||||
endif
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Operating system and architecture detection
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
# http://stackoverflow.com/a/12099167
|
||||
ifeq ($(OS),Windows_NT)
|
||||
HOST_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)
|
||||
HOST_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 = armv7l
|
||||
endif
|
||||
endif
|
||||
ifeq ($(shell uname -s),Darwin)
|
||||
HOST_PLATFORM = darwin
|
||||
|
||||
ifeq ($(shell uname -m),x86_64)
|
||||
HOST_ARCH = x64
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
ifndef HOST_PLATFORM
|
||||
$(error We couldn't detect your host platform)
|
||||
endif
|
||||
ifndef HOST_ARCH
|
||||
$(error We couldn't detect your host architecture)
|
||||
endif
|
||||
|
||||
TARGET_PLATFORM = $(HOST_PLATFORM)
|
||||
|
||||
ifneq ($(TARGET_PLATFORM),$(HOST_PLATFORM))
|
||||
$(error We don't support cross-platform builds yet)
|
||||
endif
|
||||
|
||||
# Default to host architecture. You can override by doing:
|
||||
#
|
||||
# make <target> TARGET_ARCH=<arch>
|
||||
#
|
||||
TARGET_ARCH ?= $(HOST_ARCH)
|
||||
|
||||
# Support x86 builds from x64 in GNU/Linux
|
||||
# See https://github.com/addaleax/lzma-native/issues/27
|
||||
ifeq ($(TARGET_PLATFORM),linux)
|
||||
ifneq ($(HOST_ARCH),$(TARGET_ARCH))
|
||||
ifeq ($(TARGET_ARCH),x86)
|
||||
export CFLAGS += -m32
|
||||
else
|
||||
$(error Can't build $(TARGET_ARCH) binaries on a $(HOST_ARCH) host)
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Code signing
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),darwin)
|
||||
ifndef CODE_SIGN_IDENTITY
|
||||
$(warning No code-sign identity found (CODE_SIGN_IDENTITY is not set))
|
||||
endif
|
||||
endif
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),win32)
|
||||
ifndef CODE_SIGN_CERTIFICATE
|
||||
$(warning No code-sign certificate found (CODE_SIGN_CERTIFICATE is not set))
|
||||
ifndef CODE_SIGN_CERTIFICATE_PASSWORD
|
||||
$(warning No code-sign certificate password found (CODE_SIGN_CERTIFICATE_PASSWORD is not set))
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Analytics
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
ifndef ANALYTICS_SENTRY_TOKEN
|
||||
$(warning No Sentry token found (ANALYTICS_SENTRY_TOKEN is not set))
|
||||
endif
|
||||
|
||||
ifndef ANALYTICS_MIXPANEL_TOKEN
|
||||
$(warning No Mixpanel token found (ANALYTICS_MIXPANEL_TOKEN is not set))
|
||||
endif
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Extra variables
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
TARGET_ARCH_DEBIAN = $(shell ./scripts/build/architecture-convert.sh -r $(TARGET_ARCH) -t debian)
|
||||
|
||||
PRODUCT_NAME = etcher
|
||||
APPLICATION_NAME_LOWERCASE = $(shell echo $(APPLICATION_NAME) | tr A-Z a-z)
|
||||
APPLICATION_VERSION_DEBIAN = $(shell echo $(APPLICATION_VERSION) | tr "-" "~")
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Rules
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
# See http://stackoverflow.com/a/12528721
|
||||
# Note that the blank line before 'endef' is actually important - don't delete it
|
||||
define execute-command
|
||||
$(1)
|
||||
|
||||
endef
|
||||
|
||||
$(BUILD_DIRECTORY):
|
||||
mkdir $@
|
||||
|
||||
$(BUILD_TEMPORARY_DIRECTORY): | $(BUILD_DIRECTORY)
|
||||
mkdir $@
|
||||
|
||||
$(BUILD_DIRECTORY)/electron-$(TARGET_PLATFORM)-$(TARGET_ARCH)-dependencies: | $(BUILD_DIRECTORY)
|
||||
mkdir $@
|
||||
|
||||
$(BUILD_DIRECTORY)/node-$(TARGET_PLATFORM)-$(TARGET_ARCH)-dependencies: | $(BUILD_DIRECTORY)
|
||||
mkdir $@
|
||||
|
||||
$(BUILD_OUTPUT_DIRECTORY): | $(BUILD_DIRECTORY)
|
||||
mkdir $@
|
||||
|
||||
$(BUILD_DIRECTORY)/electron-$(TARGET_PLATFORM)-$(TARGET_ARCH)-dependencies/node_modules: package.json npm-shrinkwrap.json \
|
||||
| $(BUILD_DIRECTORY)/electron-$(TARGET_PLATFORM)-$(TARGET_ARCH)-dependencies
|
||||
./scripts/build/dependencies-npm.sh -p \
|
||||
-r "$(TARGET_ARCH)" \
|
||||
-v "$(ELECTRON_VERSION)" \
|
||||
-x $| \
|
||||
-t electron \
|
||||
-s "$(TARGET_PLATFORM)"
|
||||
|
||||
$(BUILD_DIRECTORY)/node-$(TARGET_PLATFORM)-$(TARGET_ARCH)-dependencies/node_modules: package.json npm-shrinkwrap.json \
|
||||
| $(BUILD_DIRECTORY)/node-$(TARGET_PLATFORM)-$(TARGET_ARCH)-dependencies
|
||||
./scripts/build/dependencies-npm.sh -p -f \
|
||||
-r "$(TARGET_ARCH)" \
|
||||
-v "$(NODE_VERSION)" \
|
||||
-x $| \
|
||||
-t node \
|
||||
-s "$(TARGET_PLATFORM)"
|
||||
|
||||
$(BUILD_DIRECTORY)/electron-$(TARGET_PLATFORM)-$(APPLICATION_VERSION)-$(TARGET_ARCH)-app: \
|
||||
$(BUILD_DIRECTORY)/electron-$(TARGET_PLATFORM)-$(TARGET_ARCH)-dependencies/node_modules \
|
||||
| $(BUILD_DIRECTORY) $(BUILD_TEMPORARY_DIRECTORY)
|
||||
./scripts/build/electron-create-resources-app.sh -s . -o $@ \
|
||||
-v $(APPLICATION_VERSION) \
|
||||
-f "$(APPLICATION_FILES)"
|
||||
cp -RLf $< $@
|
||||
|
||||
ifdef ANALYTICS_SENTRY_TOKEN
|
||||
./scripts/build/jq-insert.sh \
|
||||
-p ".analytics.sentry.token" \
|
||||
-v "$(ANALYTICS_SENTRY_TOKEN)" \
|
||||
-f $@/package.json \
|
||||
-t $(BUILD_TEMPORARY_DIRECTORY)
|
||||
endif
|
||||
|
||||
ifdef ANALYTICS_MIXPANEL_TOKEN
|
||||
./scripts/build/jq-insert.sh \
|
||||
-p ".analytics.mixpanel.token" \
|
||||
-v "$(ANALYTICS_MIXPANEL_TOKEN)" \
|
||||
-f $@/package.json \
|
||||
-t $(BUILD_TEMPORARY_DIRECTORY)
|
||||
endif
|
||||
|
||||
$(BUILD_DIRECTORY)/electron-$(TARGET_PLATFORM)-$(APPLICATION_VERSION)-$(TARGET_ARCH)-app.asar: \
|
||||
$(BUILD_DIRECTORY)/electron-$(TARGET_PLATFORM)-$(APPLICATION_VERSION)-$(TARGET_ARCH)-app \
|
||||
| $(BUILD_DIRECTORY)
|
||||
./scripts/build/electron-create-asar.sh -d $< -o $@
|
||||
|
||||
$(BUILD_DIRECTORY)/electron-$(ELECTRON_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).zip: \
|
||||
| $(BUILD_DIRECTORY)
|
||||
./scripts/build/electron-download-package.sh \
|
||||
-r "$(TARGET_ARCH)" \
|
||||
-v "$(ELECTRON_VERSION)" \
|
||||
-s "$(TARGET_PLATFORM)" \
|
||||
-o $@
|
||||
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-cli-$(TARGET_PLATFORM)-$(APPLICATION_VERSION)-$(TARGET_ARCH)-app: \
|
||||
package.json lib \
|
||||
$(BUILD_DIRECTORY)/node-$(TARGET_PLATFORM)-$(TARGET_ARCH)-dependencies/node_modules \
|
||||
| $(BUILD_DIRECTORY)
|
||||
mkdir $@
|
||||
$(foreach prerequisite,$^,$(call execute-command,cp -rf $(prerequisite) $@))
|
||||
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-cli-$(TARGET_PLATFORM)-$(APPLICATION_VERSION)-$(TARGET_ARCH).js: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-cli-$(TARGET_PLATFORM)-$(APPLICATION_VERSION)-$(TARGET_ARCH)-app \
|
||||
| $(BUILD_DIRECTORY)
|
||||
./scripts/build/concatenate-javascript.sh -e $</lib/cli/etcher.js -o $@
|
||||
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-cli-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH): \
|
||||
$(BUILD_DIRECTORY)/node-$(TARGET_PLATFORM)-$(TARGET_ARCH)-dependencies/node_modules \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-cli-$(TARGET_PLATFORM)-$(APPLICATION_VERSION)-$(TARGET_ARCH).js \
|
||||
| $(BUILD_DIRECTORY) $(BUILD_TEMPORARY_DIRECTORY)
|
||||
./scripts/build/node-package-cli.sh -o $@ -l $< \
|
||||
-n $(APPLICATION_NAME) \
|
||||
-e $(word 2,$^) \
|
||||
-r $(TARGET_ARCH) \
|
||||
-s $(TARGET_PLATFORM)
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),win32)
|
||||
./scripts/build/electron-brand-exe.sh \
|
||||
-f $@/etcher.exe \
|
||||
-n $(APPLICATION_NAME) \
|
||||
-d "$(APPLICATION_DESCRIPTION)" \
|
||||
-v "$(APPLICATION_VERSION)" \
|
||||
-c "$(APPLICATION_COPYRIGHT)" \
|
||||
-m "$(COMPANY_NAME)" \
|
||||
-i assets/icon.ico \
|
||||
-w $(BUILD_TEMPORARY_DIRECTORY)
|
||||
endif
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),darwin)
|
||||
ifdef CODE_SIGN_IDENTITY
|
||||
./scripts/build/electron-sign-file-darwin.sh -f $@/etcher -i "$(CODE_SIGN_IDENTITY)"
|
||||
endif
|
||||
endif
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),win32)
|
||||
ifdef CODE_SIGN_CERTIFICATE
|
||||
ifdef CODE_SIGN_CERTIFICATE_PASSWORD
|
||||
./scripts/build/electron-sign-exe-win32.sh -f $@/etcher.exe \
|
||||
-d "$(APPLICATION_NAME) - $(APPLICATION_VERSION)" \
|
||||
-c $(CODE_SIGN_CERTIFICATE) \
|
||||
-p $(CODE_SIGN_CERTIFICATE_PASSWORD)
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH): \
|
||||
$(BUILD_DIRECTORY)/electron-$(TARGET_PLATFORM)-$(APPLICATION_VERSION)-$(TARGET_ARCH)-app.asar \
|
||||
$(BUILD_DIRECTORY)/electron-$(ELECTRON_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).zip \
|
||||
| $(BUILD_DIRECTORY) $(BUILD_TEMPORARY_DIRECTORY)
|
||||
ifeq ($(TARGET_PLATFORM),darwin)
|
||||
./scripts/build/electron-configure-package-darwin.sh -p $(word 2,$^) -a $< \
|
||||
-n "$(APPLICATION_NAME)" \
|
||||
-v "$(APPLICATION_VERSION)" \
|
||||
-b "$(APPLICATION_BUNDLE_ID)" \
|
||||
-c "$(APPLICATION_COPYRIGHT)" \
|
||||
-t "$(APPLICATION_CATEGORY)" \
|
||||
-i assets/icon.icns \
|
||||
-o $@
|
||||
endif
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),linux)
|
||||
./scripts/build/electron-configure-package-linux.sh -p $(word 2,$^) -a $< \
|
||||
-n "$(APPLICATION_NAME)" \
|
||||
-v "$(APPLICATION_VERSION)" \
|
||||
-l LICENSE \
|
||||
-o $@
|
||||
endif
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),win32)
|
||||
./scripts/build/electron-configure-package-win32.sh -p $(word 2,$^) -a $< \
|
||||
-n "$(APPLICATION_NAME)" \
|
||||
-d "$(APPLICATION_DESCRIPTION)" \
|
||||
-v "$(APPLICATION_VERSION)" \
|
||||
-l LICENSE \
|
||||
-c "$(APPLICATION_COPYRIGHT)" \
|
||||
-m "$(COMPANY_NAME)" \
|
||||
-i assets/icon.ico \
|
||||
-w $(BUILD_TEMPORARY_DIRECTORY) \
|
||||
-o $@
|
||||
ifdef CODE_SIGN_CERTIFICATE
|
||||
ifdef CODE_SIGN_CERTIFICATE_PASSWORD
|
||||
./scripts/build/electron-sign-exe-win32.sh -f $@/$(APPLICATION_NAME).exe \
|
||||
-d "$(APPLICATION_NAME) - $(APPLICATION_VERSION)" \
|
||||
-c $(CODE_SIGN_CERTIFICATE) \
|
||||
-p $(CODE_SIGN_CERTIFICATE_PASSWORD)
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH)-rw.dmg: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-darwin-$(TARGET_ARCH) \
|
||||
| $(BUILD_DIRECTORY)
|
||||
./scripts/build/electron-create-readwrite-dmg-darwin.sh -p $< -o $@ \
|
||||
-n "$(APPLICATION_NAME)" \
|
||||
-i assets/icon.icns \
|
||||
-b assets/osx/installer.png
|
||||
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-darwin-$(TARGET_ARCH).zip: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-darwin-$(TARGET_ARCH) \
|
||||
| $(BUILD_OUTPUT_DIRECTORY)
|
||||
ifdef CODE_SIGN_IDENTITY
|
||||
./scripts/build/electron-sign-app-darwin.sh -a $</$(APPLICATION_NAME).app -i "$(CODE_SIGN_IDENTITY)"
|
||||
endif
|
||||
./scripts/build/electron-installer-app-zip-darwin.sh -a $</$(APPLICATION_NAME).app -o $@
|
||||
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-darwin-$(TARGET_ARCH).dmg: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH)-rw.dmg \
|
||||
| $(BUILD_OUTPUT_DIRECTORY)
|
||||
ifdef CODE_SIGN_IDENTITY
|
||||
./scripts/build/electron-sign-dmg-darwin.sh \
|
||||
-n "$(APPLICATION_NAME)" \
|
||||
-d $< \
|
||||
-i "$(CODE_SIGN_IDENTITY)"
|
||||
endif
|
||||
./scripts/build/electron-create-readonly-dmg-darwin.sh -d $< -o $@
|
||||
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-linux-$(TARGET_ARCH).AppDir: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-linux-$(TARGET_ARCH) \
|
||||
| $(BUILD_DIRECTORY)
|
||||
./scripts/build/electron-create-appdir.sh -p $< -o $@ \
|
||||
-n "$(APPLICATION_NAME)" \
|
||||
-d "$(APPLICATION_DESCRIPTION)" \
|
||||
-r "$(TARGET_ARCH)" \
|
||||
-b "$(APPLICATION_NAME_LOWERCASE)" \
|
||||
-i assets/icon.png
|
||||
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-linux-$(TARGET_ARCH).AppImage: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-linux-$(TARGET_ARCH).AppDir \
|
||||
| $(BUILD_DIRECTORY) $(BUILD_TEMPORARY_DIRECTORY)
|
||||
./scripts/build/electron-create-appimage-linux.sh -d $< -o $@ \
|
||||
-r "$(TARGET_ARCH)" \
|
||||
-w "$(BUILD_TEMPORARY_DIRECTORY)"
|
||||
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-linux-$(TARGET_ARCH).zip: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-linux-$(TARGET_ARCH).AppImage \
|
||||
| $(BUILD_OUTPUT_DIRECTORY)
|
||||
./scripts/build/electron-installer-appimage-zip.sh -i $< -o $@
|
||||
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME_LOWERCASE)-electron_$(APPLICATION_VERSION_DEBIAN)_$(TARGET_ARCH_DEBIAN).deb: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-linux-$(TARGET_ARCH) \
|
||||
| $(BUILD_OUTPUT_DIRECTORY)
|
||||
./scripts/build/electron-installer-debian-linux.sh -p $< -r "$(TARGET_ARCH)" -o $| \
|
||||
-c scripts/build/debian/config.json
|
||||
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-win32-$(TARGET_ARCH).zip: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-win32-$(TARGET_ARCH) \
|
||||
| $(BUILD_OUTPUT_DIRECTORY)
|
||||
./scripts/build/electron-installer-zip-win32.sh -a $< -o $@
|
||||
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-win32-$(TARGET_ARCH).exe: \
|
||||
$(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-win32-$(TARGET_ARCH) \
|
||||
| $(BUILD_OUTPUT_DIRECTORY) $(BUILD_TEMPORARY_DIRECTORY)
|
||||
./scripts/build/electron-installer-nsis-win32.sh -n $(APPLICATION_NAME) -a $< -t $(BUILD_TEMPORARY_DIRECTORY) -o $@
|
||||
|
||||
ifdef CODE_SIGN_CERTIFICATE
|
||||
ifdef CODE_SIGN_CERTIFICATE_PASSWORD
|
||||
./scripts/build/electron-sign-exe-win32.sh -f $@ \
|
||||
-d "$(APPLICATION_NAME) - $(APPLICATION_VERSION)" \
|
||||
-c $(CODE_SIGN_CERTIFICATE) \
|
||||
-p $(CODE_SIGN_CERTIFICATE_PASSWORD)
|
||||
endif
|
||||
endif
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Phony targets
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
TARGETS = \
|
||||
help \
|
||||
info \
|
||||
sanity-checks \
|
||||
clean \
|
||||
distclean \
|
||||
package-electron \
|
||||
package-cli \
|
||||
cli-develop \
|
||||
electron-develop
|
||||
|
||||
package-electron: $(BUILD_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH)
|
||||
package-cli: $(BUILD_DIRECTORY)/$(APPLICATION_NAME)-cli-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH)
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),darwin)
|
||||
electron-installer-app-zip: $(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).zip
|
||||
electron-installer-dmg: $(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).dmg
|
||||
TARGETS += \
|
||||
electron-installer-dmg \
|
||||
electron-installer-app-zip
|
||||
PUBLISH_AWS_S3 += \
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).zip \
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).dmg
|
||||
endif
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),linux)
|
||||
electron-installer-appimage: $(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).zip
|
||||
electron-installer-debian: $(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME_LOWERCASE)-electron_$(APPLICATION_VERSION_DEBIAN)_$(TARGET_ARCH_DEBIAN).deb
|
||||
TARGETS += \
|
||||
electron-installer-appimage \
|
||||
electron-installer-debian
|
||||
PUBLISH_AWS_S3 += \
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).zip
|
||||
PUBLISH_BINTRAY_DEBIAN += \
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME_LOWERCASE)-electron_$(APPLICATION_VERSION_DEBIAN)_$(TARGET_ARCH_DEBIAN).deb
|
||||
endif
|
||||
|
||||
ifeq ($(TARGET_PLATFORM),win32)
|
||||
electron-installer-zip: $(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).zip
|
||||
electron-installer-nsis: $(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-win32-$(TARGET_ARCH).exe
|
||||
TARGETS += \
|
||||
electron-installer-zip \
|
||||
electron-installer-nsis
|
||||
PUBLISH_AWS_S3 += \
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-$(TARGET_PLATFORM)-$(TARGET_ARCH).zip \
|
||||
$(BUILD_OUTPUT_DIRECTORY)/$(APPLICATION_NAME)-$(APPLICATION_VERSION)-win32-$(TARGET_ARCH).exe
|
||||
endif
|
||||
|
||||
ifdef PUBLISH_AWS_S3
|
||||
publish-aws-s3: $(PUBLISH_AWS_S3)
|
||||
ifeq ($(RELEASE_TYPE),production)
|
||||
$(foreach publishable,$^,$(call execute-command,./scripts/publish/aws-s3.sh \
|
||||
-f $(publishable) \
|
||||
-b $(S3_BUCKET) \
|
||||
-v $(APPLICATION_VERSION) \
|
||||
-p $(PRODUCT_NAME)))
|
||||
endif
|
||||
ifeq ($(RELEASE_TYPE),snapshot)
|
||||
$(foreach publishable,$^,$(call execute-command,./scripts/publish/aws-s3.sh \
|
||||
-f $(publishable) \
|
||||
-b $(S3_BUCKET) \
|
||||
-v $(APPLICATION_VERSION) \
|
||||
-p $(PRODUCT_NAME) \
|
||||
-k $(shell date +"%Y-%m-%d")))
|
||||
endif
|
||||
|
||||
TARGETS += publish-aws-s3
|
||||
endif
|
||||
|
||||
ifdef PUBLISH_BINTRAY_DEBIAN
|
||||
publish-bintray-debian: $(PUBLISH_BINTRAY_DEBIAN)
|
||||
$(foreach publishable,$^,$(call execute-command,./scripts/publish/bintray-debian.sh \
|
||||
-f $(publishable) \
|
||||
-v $(APPLICATION_VERSION_DEBIAN) \
|
||||
-r $(TARGET_ARCH) \
|
||||
-c $(APPLICATION_NAME_LOWERCASE) \
|
||||
-t $(RELEASE_TYPE)))
|
||||
|
||||
TARGETS += publish-bintray-debian
|
||||
endif
|
||||
|
||||
.PHONY: $(TARGETS)
|
||||
|
||||
cli-develop:
|
||||
./scripts/build/dependencies-npm.sh \
|
||||
-r "$(TARGET_ARCH)" \
|
||||
-v "$(ELECTRON_VERSION)" \
|
||||
-t node \
|
||||
-s "$(TARGET_PLATFORM)"
|
||||
|
||||
electron-develop:
|
||||
./scripts/build/dependencies-npm.sh \
|
||||
-r "$(TARGET_ARCH)" \
|
||||
-v "$(ELECTRON_VERSION)" \
|
||||
-t electron \
|
||||
-s "$(TARGET_PLATFORM)"
|
||||
|
||||
help:
|
||||
@echo "Available targets: $(TARGETS)"
|
||||
|
||||
info:
|
||||
@echo "Application version : $(APPLICATION_VERSION)"
|
||||
@echo "Release type : $(RELEASE_TYPE)"
|
||||
@echo "Host platform : $(HOST_PLATFORM)"
|
||||
@echo "Host arch : $(HOST_ARCH)"
|
||||
@echo "Target platform : $(TARGET_PLATFORM)"
|
||||
@echo "Target arch : $(TARGET_ARCH)"
|
||||
|
||||
sanity-checks:
|
||||
./scripts/ci/ensure-all-node-requirements-available.sh
|
||||
./scripts/ci/ensure-staged-sass.sh
|
||||
./scripts/ci/ensure-npm-dependencies-compatibility.sh
|
||||
./scripts/ci/ensure-npm-shrinkwrap-versions.sh
|
||||
./scripts/ci/ensure-all-file-extensions-in-gitattributes.sh
|
||||
./scripts/ci/ensure-all-text-files-only-ascii.sh
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIRECTORY)
|
||||
|
||||
distclean: clean
|
||||
rm -rf node_modules
|
||||
|
||||
.DEFAULT_GOAL = help
|
144
README.md
@ -1,120 +1,106 @@
|
||||
# Etcher
|
||||
Etcher
|
||||
======
|
||||
|
||||
> Flash OS images to SD cards & USB drives, safely and easily.
|
||||
|
||||
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.
|
||||
|
||||
[](https://balena.io/etcher)
|
||||
[](https://github.com/balena-io/etcher/blob/master/LICENSE)
|
||||
[](https://forums.balena.io/c/etcher)
|
||||
[](https://david-dm.org/resin-io/etcher.svg)
|
||||
[](https://travis-ci.org/resin-io/etcher)
|
||||
[](https://ci.appveyor.com/project/resin-io/etcher/branch/master)
|
||||
[](https://gitter.im/resin-io/etcher?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||
[](https://waffle.io/resin-io/etcher)
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
[**Download**][etcher] | [**Support**][support] | [**Documentation**][user-documentation] | [**Contributing**][contributing] | [**Roadmap**][milestones]
|
||||
[**Download**][etcher] | [**Support**][SUPPORT] | [**Documentation**][USER-DOCUMENTATION] | [**Contributing**][CONTRIBUTING] | [**Roadmap**][milestones] | [**CLI**][CLI]
|
||||
|
||||
## 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.
|
||||
Supported Operating Systems
|
||||
---------------------------
|
||||
|
||||
## Installers
|
||||
- Linux (most distros)
|
||||
- macOS 10.9 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. Save the following as `/etc/apt/sources.list.d/etcher.list`:
|
||||
|
||||
##### Install .deb file using apt
|
||||
```
|
||||
deb https://dl.bintray.com/resin-io/debian stable etcher
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo apt install ./balena-etcher_******_amd64.deb
|
||||
```
|
||||
2. Trust Bintray.com's GPG key:
|
||||
|
||||
```sh
|
||||
sudo apt-key adv --keyserver hkp://pgp.mit.edu:80 --recv-keys 379CE192D401AB61
|
||||
```
|
||||
|
||||
3. Update and install:
|
||||
|
||||
```sh
|
||||
sudo apt-get update
|
||||
sudo apt-get install etcher-electron
|
||||
```
|
||||
|
||||
##### Uninstall
|
||||
|
||||
```sh
|
||||
sudo apt remove balena-etcher
|
||||
```
|
||||
|
||||
#### Redhat (RHEL) and Fedora-based Package Repository (GNU/Linux x86/x64)
|
||||
|
||||
##### Yum
|
||||
|
||||
Package for Fedora-based and Redhat can be downloaded from the [Github release page](https://github.com/balena-io/etcher/releases/)
|
||||
|
||||
1. Install using yum
|
||||
|
||||
```sh
|
||||
sudo yum localinstall balena-etcher-***.x86_64.rpm
|
||||
sudo apt-get remove etcher-electron
|
||||
sudo rm /etc/apt/sources.list.d/etcher.list
|
||||
sudo apt-get update
|
||||
```
|
||||
|
||||
#### Arch/Manjaro Linux (GNU/Linux x64)
|
||||
#### Brew Cask (macOS)
|
||||
|
||||
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:
|
||||
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
|
||||
yay -S balena-etcher
|
||||
brew cask install etcher
|
||||
```
|
||||
|
||||
##### Uninstall
|
||||
|
||||
```sh
|
||||
yay -R balena-etcher
|
||||
brew cask uninstall etcher
|
||||
```
|
||||
|
||||
#### WinGet (Windows)
|
||||
Support
|
||||
-------
|
||||
|
||||
This package is updated by [gh-action](https://github.com/vedantmgoyal2009/winget-releaser), and is kept up to date automatically.
|
||||
If you're having any problem, please [raise an issue][newissue] on GitHub and
|
||||
the resin.io team will be happy to help.
|
||||
|
||||
```sh
|
||||
winget install balenaEtcher #or Balena.Etcher
|
||||
```
|
||||
License
|
||||
-------
|
||||
|
||||
##### Uninstall
|
||||
|
||||
```sh
|
||||
winget uninstall balenaEtcher
|
||||
```
|
||||
|
||||
#### Chocolatey (Windows)
|
||||
|
||||
This package is maintained by [@majkinetor](https://github.com/majkinetor), and
|
||||
is kept up to date automatically.
|
||||
|
||||
```sh
|
||||
choco install etcher
|
||||
```
|
||||
|
||||
##### Uninstall
|
||||
|
||||
```sh
|
||||
choco uninstall etcher
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
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
|
||||
[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
|
||||
[etcher]: https://etcher.io
|
||||
[electron]: http://electron.atom.io
|
||||
[electron-supported-platforms]: http://electron.atom.io/docs/tutorial/supported-platforms/
|
||||
[SUPPORT]: https://github.com/resin-io/etcher/blob/master/SUPPORT.md
|
||||
[CONTRIBUTING]: https://github.com/resin-io/etcher/blob/master/docs/CONTRIBUTING.md
|
||||
[CLI]: https://github.com/resin-io/etcher/blob/master/docs/CLI.md
|
||||
[USER-DOCUMENTATION]: https://github.com/resin-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
|
||||
[milestones]: https://github.com/resin-io/etcher/milestones
|
||||
[newissue]: https://github.com/resin-io/etcher/issues/new
|
||||
[license]: https://github.com/resin-io/etcher/blob/master/LICENSE
|
||||
|
33
SUPPORT.md
Normal file
@ -0,0 +1,33 @@
|
||||
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.
|
||||
|
||||
Gitter
|
||||
------
|
||||
|
||||
We have a [Gitter chat room][gitter] for Etcher which is open to everyone,
|
||||
please come join us :). Drop us a line there and the resin.io staff and
|
||||
community users will be happy to assist.
|
||||
|
||||
Make sure to mention the following information to help us provide better
|
||||
support:
|
||||
|
||||
- The Etcher version you're running.
|
||||
|
||||
- The operating system you're running Etcher in.
|
||||
|
||||
- Relevant logging output, if any, from DevTools, which you can open by
|
||||
pressing `Ctrl+Alt+I` or `Cmd+Alt+I` depending on your platform.
|
||||
|
||||
GitHub
|
||||
------
|
||||
|
||||
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].
|
||||
|
||||
[gitter]: https://gitter.im/resin-io/etcher
|
||||
[issues]: https://github.com/resin-io/etcher/issues
|
||||
[new-issue]: https://github.com/resin-io/etcher/issues/new
|
@ -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
|
59
appveyor.yml
Normal file
@ -0,0 +1,59 @@
|
||||
# appveyor file
|
||||
# http://www.appveyor.com/docs/appveyor-yml
|
||||
|
||||
image: Visual Studio 2015
|
||||
|
||||
cache:
|
||||
- C:\Users\appveyor\.node-gyp
|
||||
- '%AppData%\npm-cache'
|
||||
- C:\ProgramData\chocolatey\bin -> appveyor.yml
|
||||
- C:\ProgramData\chocolatey\lib -> appveyor.yml
|
||||
- C:\Users\appveyor\AppData\Local\Temp\chocolatey -> appveyor.yml
|
||||
|
||||
# what combinations to test
|
||||
environment:
|
||||
global:
|
||||
ELECTRON_NO_ATTACH_CONSOLE: true
|
||||
nodejs_version: 6.1.0
|
||||
matrix:
|
||||
- TARGET_ARCH: x64
|
||||
- TARGET_ARCH: x86
|
||||
|
||||
clone_depth: 3
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
|
||||
install:
|
||||
- ps: Install-Product node $env:nodejs_version x64
|
||||
- npm install -g npm@4.4.4
|
||||
- npm install -g rimraf asar
|
||||
- choco install nsis -version 2.51
|
||||
- choco install jq
|
||||
- choco install curl
|
||||
- set PATH=C:\Program Files (x86)\Windows Kits\8.1\bin\x86;%PATH%
|
||||
- set PATH=C:\Program Files (x86)\NSIS;%PATH%
|
||||
- set PATH=C:\MinGW\bin;%PATH%
|
||||
- set PATH=C:\MinGW\msys\1.0\bin;%PATH%
|
||||
- pip install codespell==1.9.2 awscli
|
||||
- make info
|
||||
- make electron-develop
|
||||
|
||||
build: off
|
||||
|
||||
test_script:
|
||||
- node --version
|
||||
- npm --version
|
||||
- make sanity-checks
|
||||
- cmd: npm test
|
||||
|
||||
deploy_script:
|
||||
- cmd: .\scripts\ci\deploy-appveyor.bat
|
||||
|
||||
notifications:
|
||||
|
||||
- provider: Webhook
|
||||
url: https://webhooks.gitter.im/e/0becb34b32e20d389bb8
|
||||
on_build_success: false
|
||||
on_build_failure: true
|
||||
on_build_status_changed: true
|
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 49 KiB |
BIN
assets/icon.icns
Executable file → Normal file
BIN
assets/icon.ico
Executable file → Normal file
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 361 KiB |
BIN
assets/icon.png
Executable file → Normal file
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 63 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 479 B |
Before Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 802 B |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 11 KiB |
BIN
assets/osx/installer.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
assets/osx/installer@2x.png
Normal file
After Width: | Height: | Size: 32 KiB |
2
bin/etcher
Executable file
@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require('../lib/cli/etcher');
|
2
dictionary
Normal file
@ -0,0 +1,2 @@
|
||||
boolen->boolean
|
||||
aknowledge->acknowledge
|
@ -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]
|
||||
|
||||
@ -37,18 +40,64 @@ to submit their work or bug reports.
|
||||
|
||||
These are the main Etcher components, in a nutshell:
|
||||
|
||||
- [Drivelist](https://github.com/balena-io-modules/drivelist)
|
||||
- [Etcher Image Write][etcher-image-write]
|
||||
|
||||
This is the repository that implements the actual procedures to write an image
|
||||
to a raw device and the place where image validation resides. Its main purpose
|
||||
is to abstract the messy details of interacting with raw devices in all major
|
||||
operating systems.
|
||||
|
||||
- [Etcher Image Stream](https://github.com/resin-io-modules/etcher-image-stream)
|
||||
|
||||
The goal of this project is to convert any kind of input into a readable stream
|
||||
representing the image so it can be plugged to [etcher-image-write]. Inputs
|
||||
that this module might handle could be, for example: a simple image file, a URL
|
||||
to an image, a compressed image, an image inside a ZIP archive, etc. Together
|
||||
with [etcher-image-write], these modules are the building blocks needed to take
|
||||
an image representation to the user's device, the "Etcher's backend".
|
||||
|
||||
- [Drivelist](https://github.com/resin-io-modules/drivelist)
|
||||
|
||||
As the name implies, this module's duty is to detect the connected drives
|
||||
uniformly in all major operating systems, along with valuable metadata, like if
|
||||
a drive is removable or not, to prevent users from trying to write an image to
|
||||
a system drive.
|
||||
|
||||
- [Etcher](https://github.com/balena-io/etcher)
|
||||
- [Etcher](https://github.com/resin-io/etcher)
|
||||
|
||||
This is the *"main repository"*, from which you're reading this from, which is
|
||||
basically the front-end and glue for all previously listed projects.
|
||||
|
||||
Front-ends
|
||||
----------
|
||||
|
||||
The main repository consists of the implementation of the Etcher CLI and the
|
||||
Etcher GUI (the desktop application), located at [`lib/cli/`][cli-dir] and
|
||||
[`lib/gui/`][gui-dir], respectively.
|
||||
|
||||
In fact, the only front-end that interacts directly with Etcher's backend is
|
||||
the CLI. The GUI merely forks the CLI and communicates with its child process
|
||||
to get state information.
|
||||
|
||||
In this sense, you can consider the GUI as being the front-end to the CLI,
|
||||
which is in turn the front-end to the actual image writing functionality.
|
||||
|
||||
As a way to simplify how the GUI forks the CLI in a packaged and distributed
|
||||
context, both the CLI and GUI share the same application entry point. This
|
||||
means that the same Etcher binary can behave as CLI or GUI as needed.
|
||||
|
||||
## Process communication
|
||||
|
||||
As mentioned before, the Etcher GUI forks the CLI and retrieves information
|
||||
from it to update its state. In order to accomplish this, the Etcher CLI
|
||||
contains certain features to ease communication:
|
||||
|
||||
- [Well-documented exit codes.][exit-codes]
|
||||
|
||||
- An environment variable called `ETCHER_CLI_ROBOT` option, which when set
|
||||
causes the Etcher CLI to output state in a way that can be easily
|
||||
parsed by a machine.
|
||||
|
||||
Summary
|
||||
-------
|
||||
|
||||
@ -59,12 +108,17 @@ since fresh eyes could help unveil things that we take for granted, but should
|
||||
be documented instead!
|
||||
|
||||
[lego-blocks]: https://github.com/sindresorhus/ama/issues/10#issuecomment-117766328
|
||||
[exit-codes]: https://github.com/balena-io/etcher/blob/master/lib/shared/exit-codes.js
|
||||
[gui-dir]: https://github.com/balena-io/etcher/tree/master/lib/gui
|
||||
[etcher-image-write]: https://github.com/resin-io-modules/etcher-image-write
|
||||
[exit-codes]: https://github.com/resin-io/etcher/blob/master/lib/shared/exit-codes.js
|
||||
[cli-dir]: https://github.com/resin-io/etcher/tree/master/lib/cli
|
||||
[gui-dir]: https://github.com/resin-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
|
||||
|
51
docs/CLI.md
Normal file
@ -0,0 +1,51 @@
|
||||
Etcher CLI
|
||||
==========
|
||||
|
||||
The Etcher CLI is a command-line tool that aims to provide all the benefits of
|
||||
the Etcher desktop application in a way that can be run from a terminal, or
|
||||
even used from a script.
|
||||
|
||||
In fact, the Etcher desktop application is simply a wrapper around the CLI,
|
||||
which is the place where the actual writing logic takes place.
|
||||
|
||||
Running
|
||||
-------
|
||||
|
||||
We are not oficially releasing the Etcher CLI as a separate package yet, but
|
||||
you can run it locally with the following steps:
|
||||
|
||||
- Clone the Etcher repository.
|
||||
|
||||
```
|
||||
git clone https://github.com/resin-io/etcher
|
||||
```
|
||||
|
||||
- Install the dependencies by running:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
- Run the Etcher CLI from `bin/etcher`.
|
||||
|
||||
```
|
||||
./bin/etcher --help
|
||||
```
|
||||
|
||||
Options
|
||||
-------
|
||||
|
||||
```
|
||||
--help, -h show help
|
||||
--version, -v show version number
|
||||
--drive, -d drive
|
||||
--check, -c validate write
|
||||
--yes, -y confirm non-interactively
|
||||
--unmount, -u unmount on success
|
||||
```
|
||||
|
||||
Debug mode
|
||||
----------
|
||||
|
||||
You can set the `ETCHER_CLI_DEBUG` environment variable to make the Etcher CLI
|
||||
print error stack traces.
|
@ -12,29 +12,72 @@ 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.
|
||||
|
||||
When it applies, the scope must be either `GUI` or `CLI`.
|
||||
|
||||
A commit that takes part in both the GUI and CLI scopes, and makes more logical
|
||||
sense that way, might entirely omit the scope.
|
||||
|
||||
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
|
||||
----
|
||||
|
||||
@ -79,8 +122,129 @@ A commit can include multiple instances of this tag.
|
||||
Examples:
|
||||
|
||||
```
|
||||
Closes: https://github.com/balena-io/etcher/issues/XXX
|
||||
Fixes: https://github.com/balena-io/etcher/issues/XXX
|
||||
Closes: https://github.com/resin-io/etcher/issues/XXX
|
||||
Fixes: https://github.com/resin-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/resin-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/resin-io-modules/etcher-image-write/blob/master/CHANGELOG.md#502---2016-06-27
|
||||
Fixes: https://github.com/resin-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/resin-io/etcher/issues/396
|
||||
```
|
||||
|
||||
[angular-commit-guidelines]: https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#commit
|
||||
[semver]: http://semver.org
|
||||
|
@ -10,74 +10,41 @@ High-level Etcher overview
|
||||
Make sure you checkout our [ARCHITECTURE.md][ARCHITECTURE] guide, which aims to
|
||||
explain how all the pieces fit together.
|
||||
|
||||
Running locally
|
||||
---------------
|
||||
|
||||
See the [RUNNING-LOCALLY.md][RUNNING-LOCALLY] guide.
|
||||
|
||||
Developing
|
||||
----------
|
||||
|
||||
### Prerequisites
|
||||
We rely on various `npm` scripts to perform some common tasks:
|
||||
|
||||
#### Common
|
||||
- `npm run lint`: Run the linter.
|
||||
- `npm run sass`: Compile SCSS files.
|
||||
|
||||
- [NodeJS](https://nodejs.org) (at least v16.11)
|
||||
- [Python 3](https://www.python.org)
|
||||
- [jq](https://stedolan.github.io/jq/)
|
||||
- [curl](https://curl.haxx.se/)
|
||||
- [npm](https://www.npmjs.com/)
|
||||
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
|
||||
process.
|
||||
|
||||
```sh
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
Updating a dependency
|
||||
---------------------
|
||||
|
||||
You might need to run this with `sudo` or administrator permissions.
|
||||
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.
|
||||
|
||||
#### Windows
|
||||
Use the following steps to ensure everything goes flawlessly:
|
||||
|
||||
- [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
|
||||
setup, select "Modify" and then check `Visual C++ -> Common Tools for Visual
|
||||
C++` (see http://stackoverflow.com/a/31955339)
|
||||
- [MinGW](http://www.mingw.org)
|
||||
- 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.
|
||||
|
||||
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).
|
||||
- Install the new version of the dependency. For example: `npm install --save
|
||||
<package>@<version>`. This will update the `npm-shrinkwrap.json` file.
|
||||
|
||||
The following MinGW packages are required:
|
||||
|
||||
- `msys-make`
|
||||
- `msys-unzip`
|
||||
- `msys-zip`
|
||||
- `msys-bash`
|
||||
- `msys-coreutils`
|
||||
|
||||
#### macOS
|
||||
|
||||
- [Xcode](https://developer.apple.com/xcode/)
|
||||
|
||||
It's not enough to have [Xcode Command Line Tools] installed. Xcode must be installed
|
||||
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)
|
||||
|
||||
### Cloning the project
|
||||
|
||||
```sh
|
||||
git clone --recursive https://github.com/balena-io/etcher
|
||||
cd etcher
|
||||
```
|
||||
|
||||
### Running the application
|
||||
|
||||
#### GUI
|
||||
|
||||
```sh
|
||||
# Build and start application
|
||||
npm start
|
||||
```
|
||||
- Commit *both* `package.json` and `npm-shrinkwrap.json`.
|
||||
|
||||
Testing
|
||||
-------
|
||||
@ -102,45 +69,6 @@ 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 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
|
||||
process.
|
||||
|
||||
Updating a dependency
|
||||
---------------------
|
||||
|
||||
- Install new version of dependency using npm
|
||||
- Commit *both* `package.json` and `npm-shrinkwrap.json`.
|
||||
|
||||
Diffing Binaries
|
||||
----------------
|
||||
|
||||
Binary files are tagged as "binary" in the `.gitattributes` file, but also have
|
||||
a `diff=hex` tag, which allows you to see hexdump-style diffs for binaries,
|
||||
if you add the following to either your global or repository-local git config:
|
||||
|
||||
```sh
|
||||
$ git config diff.hex.textconv hexdump
|
||||
$ git config diff.hex.binary true
|
||||
```
|
||||
|
||||
And global, respectively:
|
||||
|
||||
```sh
|
||||
$ git config --global diff.hex.textconv hexdump
|
||||
$ git config --global diff.hex.binary true
|
||||
```
|
||||
|
||||
If you don't have `hexdump` available on your platform,
|
||||
you can try [hxd], which is also a bit faster.
|
||||
|
||||
Commit Guidelines
|
||||
-----------------
|
||||
|
||||
See [COMMIT-GUIDELINES.md][COMMIT-GUIDELINES] for a thorough guide on how to
|
||||
write commit messages.
|
||||
|
||||
Sending a pull request
|
||||
----------------------
|
||||
|
||||
@ -167,7 +95,7 @@ Before your pull request can be merged, the following conditions must hold:
|
||||
|
||||
- The linter doesn't throw any warning.
|
||||
|
||||
- All the tests pass.
|
||||
- All the tests passes.
|
||||
|
||||
- The coding style aligns with the project's convention.
|
||||
|
||||
@ -176,9 +104,7 @@ systems we support.
|
||||
|
||||
Don't hesitate to get in touch if you have any questions or need any help!
|
||||
|
||||
[ARCHITECTURE]: https://github.com/balena-io/etcher/blob/master/docs/ARCHITECTURE.md
|
||||
[COMMIT-GUIDELINES]: https://github.com/balena-io/etcher/blob/master/docs/COMMIT-GUIDELINES.md
|
||||
[ARCHITECTURE]: https://github.com/resin-io/etcher/blob/master/docs/ARCHITECTURE.md
|
||||
[RUNNING-LOCALLY]: https://github.com/resin-io/etcher/blob/master/docs/RUNNING-LOCALLY.md
|
||||
[EditorConfig]: http://editorconfig.org
|
||||
[shrinkwrap]: https://docs.npmjs.com/cli/shrinkwrap
|
||||
[hxd]: https://github.com/jhermsmeier/hxd
|
||||
[Xcode Command Line Tools]: https://developer.apple.com/library/content/technotes/tn2339/_index.html
|
||||
|
52
docs/FAQ.md
@ -1,52 +0,0 @@
|
||||
## Why is my drive not bootable?
|
||||
|
||||
Etcher copies images to drives byte by byte, without doing any transformation to the final device, which means images that require special treatment to be made bootable, like Windows images, will not work out of the box. In these cases, the general advice is to use software specific to those kind of images, usually available from the image publishers themselves. You can find more information [here](https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md#why-is-my-drive-not-bootable).
|
||||
|
||||
## How can I configure persistent storage?
|
||||
|
||||
Some programs, usually oriented at making GNU/Linux live USB drives, include an option to set persistent storage. This is currently not supported by Etcher, so if you require this functionality, we advise to fallback to [UNetbootin](https://unetbootin.github.io/).
|
||||
|
||||
## How do I flash Ubuntu ISOs
|
||||
|
||||
Ubuntu images (and potentially some other related GNU/Linux distributions) have a peculiar format that allows the image to boot without any further modification from both CDs and USB drives.
|
||||
A consequence of this enhancement is that some programs, like parted get confused about the drive's format and partition table, printing warnings such as:
|
||||
|
||||
> /dev/xxx contains GPT signatures, indicating that it has a GPT table. However, it does not have a valid fake msdos partition table, as it should. Perhaps it was corrupted -- possibly by a program that doesn't understand GPT partition tables. Or perhaps you deleted the GPT table, and are now using an msdos partition table. Is this a GPT partition table? Both the primary and backup GPT tables are corrupt. Try making a fresh table, and using Parted's rescue feature to recover partitions.
|
||||
|
||||
> Warning: The driver descriptor says the physical block size is 2048 bytes, but Linux says it is 512 bytes.
|
||||
|
||||
All these warnings are safe to ignore, and your drive should be able to boot without any problems.
|
||||
Refer to [the following message from Ubuntu's mailing list](https://lists.ubuntu.com/archives/ubuntu-devel/2011-June/033495.html) if you want to learn more.
|
||||
|
||||
## How do I run Etcher on Wayland?
|
||||
|
||||
The XWayland Server provides backwards compatibility to run any X client on Wayland, including Etcher.
|
||||
This usually works out of the box on mainstream GNU/Linux distributions that properly support Wayland. If it doesn't, make sure the xwayland.so module is being loaded by declaring it in your [weston.ini](http://manpages.ubuntu.com/manpages/wily/man5/weston.ini.5.html):
|
||||
|
||||
```
|
||||
[core]
|
||||
modules=xwayland.so
|
||||
```
|
||||
|
||||
## What are the runtime GNU/LINUX dependencies?
|
||||
|
||||
[This entry](https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md#runtime-gnulinux-dependencies) aims to provide an up to date list of runtime dependencies needed to run Etcher on a GNU/Linux system.
|
||||
|
||||
## How can I recover the broken drive?
|
||||
|
||||
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
|
||||
|
||||
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>`
|
@ -1,112 +1,46 @@
|
||||
Maintaining Etcher
|
||||
==================
|
||||
|
||||
This document is meant to serve as a guide for maintainers to perform common tasks.
|
||||
This document is meant to serve as a guide for maintainers to perform common
|
||||
tasks.
|
||||
|
||||
Releasing
|
||||
---------
|
||||
Preparing a new version
|
||||
-----------------------
|
||||
|
||||
### Release Types
|
||||
- Bump the version number in the `package.json`'s `version` property.
|
||||
|
||||
- **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
|
||||
- Bump the version number in the `package.json`'s `builder.win.version`
|
||||
|
||||
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.
|
||||
- Bump the version number in the `npm-shrinkwrap.json`'s `version` property.
|
||||
|
||||
- Add a new entry to `CHANGELOG.md` by running `npm run changelog`.
|
||||
|
||||
#### Preparation
|
||||
- Re-take `screenshot.png` so it displays the latest version in the bottom
|
||||
right corner.
|
||||
|
||||
- [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
|
||||
- Commit the changes with the version number as the commit title, including the
|
||||
`v` prefix, to `master`. For example:
|
||||
|
||||
#### Testing
|
||||
|
||||
- Test the prepared release and build artifacts properly on **all supported operating systems** to prevent regressions that went uncaught by the CI tests (see [MANUAL-TESTING.md](MANUAL-TESTING.md))
|
||||
- If regressions or other issues arise, create issues on the repository for each one, and decide whether to fix them in this release (meaning repeating the process up until this point), or to follow up with a patch release
|
||||
|
||||
#### Publishing
|
||||
|
||||
- [Publish release draft on GitHub](https://github.com/balena-io/etcher/releases)
|
||||
- [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, Amplitude) 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
|
||||
- If this release packs noteworthy major changes:
|
||||
- Write a blog post about it, and / or
|
||||
- Write about it to the Etcher mailing list
|
||||
|
||||
### Generating binaries
|
||||
|
||||
**Environment**
|
||||
|
||||
Make sure to set the analytics tokens when generating production release binaries:
|
||||
|
||||
```bash
|
||||
export ANALYTICS_SENTRY_TOKEN="xxxxxx"
|
||||
export ANALYTICS_AMPLITUDE_TOKEN="xxxxxx"
|
||||
```sh
|
||||
git commit -m "v1.0.0" # not 1.0.0
|
||||
```
|
||||
|
||||
#### Linux
|
||||
- Create an annotated tag for the new version. The commit title should equal
|
||||
the annotated tag name. For example:
|
||||
|
||||
##### Clean dist folder
|
||||
|
||||
Delete `.webpack` and `out/`.
|
||||
|
||||
##### Generating artifacts
|
||||
|
||||
The artifacts are generated by the CI and published as draft-release or pre-release.
|
||||
Etcher is built with electron-forge. Run:
|
||||
|
||||
```
|
||||
npm run make
|
||||
```sh
|
||||
git tag -a v1.0.0 -m "v1.0.0"
|
||||
```
|
||||
|
||||
Our CI will appropriately sign artifacts for macOS and some Windows targets.
|
||||
- Push the commit and the annotated tag.
|
||||
|
||||
|
||||
### Uploading packages to Cloudfront
|
||||
|
||||
Log in to cloudfront and upload the `rpm` and `deb` files.
|
||||
|
||||
### Dealing with a Problematic Release
|
||||
|
||||
There can be times where a release is accidentally plagued with bugs. If you
|
||||
released a new version and notice the error rates are higher than normal, then
|
||||
revert the problematic release as soon as possible, until the bugs are fixed.
|
||||
|
||||
You can revert a version by deleting its builds from the S3 bucket and Bintray.
|
||||
Refer to the `Makefile` for the up to date information about the S3 bucket
|
||||
where we push builds to, and get in touch with the balena.io operations team to
|
||||
get write access to it.
|
||||
|
||||
The Etcher update notifier dialog and the website only show the a certain
|
||||
version if all the expected files have been uploaded to it, so deleting a
|
||||
single package or two is enough to bring down the whole version.
|
||||
|
||||
Use the following command to delete files from S3:
|
||||
|
||||
```bash
|
||||
aws s3api delete-object --bucket <bucket name> --key <file name>
|
||||
```sh
|
||||
git push
|
||||
git push --tags
|
||||
```
|
||||
|
||||
The Bintray dashboard provides an easy way to delete a version's files.
|
||||
Upgrading Electron
|
||||
------------------
|
||||
|
||||
|
||||
### Submitting binaries to Symantec
|
||||
|
||||
- [Report a Suspected Erroneous Detection](https://submit.symantec.com/false_positive/standard/)
|
||||
- Fill out form:
|
||||
- **Select Submission Type:** "Provide a direct download URL"
|
||||
- **Name of the software being detected:** Etcher
|
||||
- **Name of detection given by Symantec product:** WS.Reputation.1
|
||||
- **Contact name:** Balena.io Ltd
|
||||
- **E-mail address:** hello@etcher.io
|
||||
- **Are you the creator or distributor of the software in question?** Yes
|
||||
- Upgrade the `electron-prebuilt` dependency version in `package.json` to an
|
||||
*exact version* (no `~`, `^`, etc).
|
||||
|
@ -1,115 +0,0 @@
|
||||
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
|
||||
---------------
|
||||
|
||||
- [ ] Cancel image selection dialog
|
||||
- [ ] Select an unbootable image (without a partition table), and expect a
|
||||
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
|
||||
---------------
|
||||
|
||||
- [ ] Open the drive selection modal
|
||||
- [ ] Switch drive selection
|
||||
- [ ] Insert a single drive, and expect auto-selection
|
||||
- [ ] Insert more than one drive, and don't expect auto-selection
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
|
||||
Image Support
|
||||
-------------
|
||||
|
||||
Run the following tests with and without validation enabled:
|
||||
|
||||
- [ ] Flash an uncompressed image
|
||||
- [ ] Flash a Bzip2 image
|
||||
- [ ] Flash a XZ image
|
||||
- [ ] Flash a ZIP image
|
||||
- [ ] Flash a GZ image
|
||||
- [ ] Flash a DMG image
|
||||
- [ ] Flash an image whose size is not a multiple of 512 bytes
|
||||
- [ ] Flash a compressed image whose size is not a multiple of 512 bytes
|
||||
- [ ] Flash an archive whose image size is not a multiple of 512 bytes
|
||||
- [ ] Flash an archive image containing a logo
|
||||
- [ ] Flash an archive image containing a blockmap file
|
||||
- [ ] Flash an archive image containing a manifest metadata file
|
||||
|
||||
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
|
||||
|
||||
### Child Writer
|
||||
|
||||
- [ ] Kill the child writer process (i.e. with `SIGINT` or `SIGKILL`), and
|
||||
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
|
||||
- [ ] Force kill the application (using a process monitor tool, etc)
|
||||
|
||||
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
|
||||
----
|
||||
|
||||
- [ ] Close application from the terminal using Ctrl-C while the application is
|
||||
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
|
||||
- [ ] Flash consecutive images without closing the application
|
||||
- [ ] Remove the selected drive right before clicking "Flash"
|
||||
- [ ] Minimize the application
|
||||
- [ ] Start the application given no internet connection
|
||||
|
||||
Success Banner
|
||||
--------------
|
||||
|
||||
- [ ] Click an external link on the success banner (with and without internet
|
||||
connection)
|
||||
|
||||
Elevation Prompt
|
||||
----------------
|
||||
|
||||
- [ ] Flash an image as `root`/administrator
|
||||
- [ ] Reject elevation prompt
|
||||
- [ ] Put incorrect elevation prompt password
|
||||
- [ ] Unplug the drive during elevation
|
||||
|
||||
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
|
||||
|
||||
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 Amplitude**
|
@ -4,20 +4,13 @@ Publishing Etcher
|
||||
This is a small guide to package and publish Etcher to all supported operating
|
||||
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.
|
||||
|
||||
Signing
|
||||
-------
|
||||
|
||||
### OS X
|
||||
|
||||
1. Get our Apple Developer ID certificate for signing applications distributed
|
||||
outside the Mac App Store from the balena.io Apple account.
|
||||
outside the Mac App Store from the resin.io Apple account.
|
||||
|
||||
2. Install the Developer ID certificate to your Mac's Keychain by double
|
||||
clicking on the certificate file.
|
||||
@ -27,7 +20,7 @@ packaging for OS X.
|
||||
|
||||
### Windows
|
||||
|
||||
1. Get access to our code signing certificate and decryption key as a balena.io
|
||||
1. Get access to our code signing certificate and decryption key as a resin.io
|
||||
employee by asking for it from the relevant people.
|
||||
|
||||
2. Place the certificate in the root of the Etcher repository naming it
|
||||
@ -36,24 +29,74 @@ employee by asking for it from the relevant people.
|
||||
Packaging
|
||||
---------
|
||||
|
||||
Run the following command on each platform:
|
||||
|
||||
### OS X
|
||||
|
||||
Run the following command:
|
||||
|
||||
```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.
|
||||
The resulting installers will be saved to `release/out`.
|
||||
|
||||
The resulting artifacts can be found in `out/make`.
|
||||
### GNU/Linux
|
||||
|
||||
Run the following command:
|
||||
|
||||
Publishing to Cloudfront
|
||||
```sh
|
||||
make electron-installer-appimage
|
||||
make electron-installer-debian
|
||||
```
|
||||
|
||||
The resulting installers will be saved to `release/out`.
|
||||
|
||||
### Windows
|
||||
|
||||
Run the following command:
|
||||
|
||||
```sh
|
||||
make electron-installer-zip
|
||||
make electron-installer-nsis
|
||||
```
|
||||
|
||||
The resulting installers will be saved to `etcher-release/installers`.
|
||||
|
||||
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 RELEASE_TYPE=<production|snapshot>
|
||||
```
|
||||
|
||||
Publishing to S3
|
||||
----------------
|
||||
|
||||
- [AWS CLI][aws-cli]
|
||||
|
||||
Make sure you have the [AWS CLI tool][aws-cli] installed and configured to
|
||||
access resin.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 RELEASE_TYPE=<production|snapshot>
|
||||
```
|
||||
|
||||
Also add links to each AWS S3 file in [GitHub Releases][github-releases]. See
|
||||
[`v1.0.0-beta.17`](https://github.com/resin-io/etcher/releases/tag/v1.0.0-beta.17)
|
||||
as an example.
|
||||
|
||||
Publishing to Homebrew Cask
|
||||
---------------------------
|
||||
@ -67,16 +110,14 @@ Publishing to Homebrew Cask
|
||||
Announcing
|
||||
----------
|
||||
|
||||
Post messages to the [Etcher forum][balena-forum-etcher] announcing the new version
|
||||
Post messages to the [Etcher forum][resin-forum-etcher] and
|
||||
[Etcher gitter channel][gitter-etcher] announcing the new version
|
||||
of Etcher, and including the relevant section of the Changelog.
|
||||
|
||||
[aws-cli]: https://aws.amazon.com/cli
|
||||
[cloudfront]: https://cloudfront.com
|
||||
[etcher-cask-file]: https://github.com/caskroom/homebrew-cask/blob/master/Casks/balenaetcher.rb
|
||||
[bintray]: https://bintray.com
|
||||
[etcher-cask-file]: https://github.com/caskroom/homebrew-cask/blob/master/Casks/etcher.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)
|
||||
[resin-forum-etcher]: https://talk.resin.io/c/etcher/annoucements
|
||||
[gitter-etcher]: https://gitter.im/resin-io/etcher
|
||||
[github-releases]: https://github.com/resin-io/etcher/releases
|
||||
|
85
docs/RUNNING-LOCALLY.md
Normal file
@ -0,0 +1,85 @@
|
||||
Running locally
|
||||
===============
|
||||
|
||||
This document aims to serve as a guide to get Etcher running locally on your
|
||||
development machine.
|
||||
|
||||
Prerequisites
|
||||
-------------
|
||||
|
||||
### Common
|
||||
|
||||
- [NodeJS](https://nodejs.org) (at least v6)
|
||||
- [Python](https://www.python.org)
|
||||
- [jq](https://stedolan.github.io/jq/)
|
||||
- [Asar](https://github.com/electron/asar)
|
||||
- [Codespell](https://github.com/lucasdemarchi/codespell)
|
||||
- [curl](https://curl.haxx.se/)
|
||||
|
||||
### Windows
|
||||
|
||||
- [Rimraf](https://github.com/isaacs/rimraf)
|
||||
- [NSIS v2.51](http://nsis.sourceforge.net/Main_Page) (v3.x won't work)
|
||||
- Either one of the following:
|
||||
- [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++ 2015` (see http://stackoverflow.com/a/31955339)
|
||||
- [MinGW](http://www.mingw.org)
|
||||
|
||||
The following MinGW packages are required:
|
||||
|
||||
- `msys-make`
|
||||
- `msys-unzip`
|
||||
- `msys-zip`
|
||||
- `msys-bash`
|
||||
- `msys-coreutils`
|
||||
|
||||
### OS X
|
||||
|
||||
- [XCode](https://developer.apple.com/xcode/)
|
||||
- [afsctool](https://brkirch.wordpress.com/afsctool/)
|
||||
|
||||
Cloning the project
|
||||
-------------------
|
||||
|
||||
```sh
|
||||
git clone https://github.com/resin-io/etcher
|
||||
cd etcher
|
||||
```
|
||||
|
||||
Installing npm dependencies
|
||||
---------------------------
|
||||
|
||||
**Make sure you have all the pre-requisites listed above installed in your
|
||||
system before running the `install` script.**
|
||||
|
||||
Please make use of the following scripts 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
|
||||
npm start
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```sh
|
||||
node bin/etcher
|
||||
```
|
@ -1,43 +0,0 @@
|
||||
Getting help with BalenaEtcher
|
||||
===============================
|
||||
|
||||
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
|
||||
------
|
||||
|
||||
We have a [Discourse forum][discourse] which is open to everyone, so please
|
||||
come join us :). Drop us a line there and the balena.io staff and community
|
||||
users will be happy to assist. Your question might already be answered, so take
|
||||
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 operating system you're running Etcher in.
|
||||
|
||||
- Relevant logging output, if any, from DevTools, which you can open by
|
||||
pressing `Ctrl+Shift+I` or `Cmd+Alt+I` depending on your platform.
|
||||
|
||||
GitHub
|
||||
------
|
||||
|
||||
If you encounter an issue or have a suggestion, head on over to BalenaEtcher'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
|
@ -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?
|
||||
-----------------------------
|
||||
|
||||
@ -19,8 +14,8 @@ images, usually available from the image publishers themselves.
|
||||
|
||||
Images known to require special treatment:
|
||||
|
||||
- Microsoft Windows (use [Windows USB/DVD Download Tool][windows-usb-tool],
|
||||
[Rufus][rufus], or [WoeUSB][woeusb]).
|
||||
- Microsoft Windows (use [Windows USB/DVD Download Tool][windows-usb-tool], or
|
||||
[Rufus][rufus]).
|
||||
|
||||
- Windows 10 IoT (use the [Windows 10 IoT Core Dashboard][windows-iot-dashboard])
|
||||
|
||||
@ -35,7 +30,7 @@ if you require this functionality, we advise to fallback to
|
||||
Deactivate desktop shortcut prompt on GNU/Linux
|
||||
-----------------------------------------------
|
||||
|
||||
This is a feature provided by [AppImages][appimage], where the applications
|
||||
This is a feature provided by [AppImages](appimage), where the applications
|
||||
prompts the user to automatically register a desktop shortcut to easily access
|
||||
the application.
|
||||
|
||||
@ -122,6 +117,7 @@ run Etcher on a GNU/Linux system.
|
||||
- xrender
|
||||
- xtst
|
||||
- xscrnsaver
|
||||
- gconf-2.0
|
||||
- gmodule-2.0
|
||||
- nss
|
||||
|
||||
@ -134,6 +130,25 @@ run Etcher on a GNU/Linux system.
|
||||
|
||||
- liblzma (for xz decompression)
|
||||
|
||||
Disable update notifications
|
||||
----------------------------
|
||||
|
||||
You can disable update notifications, which can be useful when running Etcher
|
||||
outside a common desktop environment (like in a [resin.io] application), by
|
||||
setting the `ETCHER_DISABLE_UPDATES` environment variable.
|
||||
|
||||
In GNU/Linux and Mac OS X:
|
||||
|
||||
```sh
|
||||
export ETCHER_DISABLE_UPDATES=1
|
||||
```
|
||||
|
||||
In Windows:
|
||||
|
||||
```sh
|
||||
set ETCHER_DISABLE_UPDATES=1
|
||||
```
|
||||
|
||||
Recovering broken drives
|
||||
------------------------
|
||||
|
||||
@ -163,18 +178,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 +185,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
|
||||
@ -207,20 +210,20 @@ Running in older macOS versions
|
||||
-------------------------------
|
||||
|
||||
Etcher GUI is based on the [Electron][electron] framework, [which only supports
|
||||
macOS 10.10 (Yosemite) and newer versions][electron-supported-platforms].
|
||||
macOS 10.9 and newer versions][electron-supported-platforms].
|
||||
|
||||
[balena.io]: https://balena.io
|
||||
You can however, run the [Etcher CLI][etcher-cli], which should work in older
|
||||
platforms.
|
||||
|
||||
[resin.io]: https://resin.io
|
||||
[appimage]: http://appimage.org
|
||||
[xwayland]: https://wayland.freedesktop.org/xserver.html
|
||||
[weston.ini]: http://manpages.ubuntu.com/manpages/wily/man5/weston.ini.5.html
|
||||
[diskpart]: https://technet.microsoft.com/en-us/library/cc770877(v=ws.11).aspx
|
||||
[electron]: https://electronjs.org/
|
||||
[electron-supported-platforms]: https://electronjs.org/docs/tutorial/support#supported-platforms
|
||||
[publishing]: https://github.com/balena-io/etcher/blob/master/docs/PUBLISHING.md
|
||||
[electron]: http://electron.atom.io
|
||||
[electron-supported-platforms]: https://github.com/electron/electron/blob/master/docs/tutorial/supported-platforms.md
|
||||
[etcher-cli]: https://github.com/resin-io/etcher/blob/master/docs/CLI.md
|
||||
[windows-usb-tool]: https://www.microsoft.com/en-us/download/windows-usb-dvd-download-tool
|
||||
[rufus]: https://rufus.akeo.ie
|
||||
[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.
|
1
docs/_config.yml
Normal file
@ -0,0 +1 @@
|
||||
theme: jekyll-theme-minimal
|
@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<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>
|
159
forge.config.ts
@ -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;
|
168
forge.sidecar.ts
@ -1,168 +0,0 @@
|
||||
import { PluginBase } from '@electron-forge/plugin-base';
|
||||
import type {
|
||||
ForgeHookMap,
|
||||
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 * as d from 'debug';
|
||||
|
||||
const debug = d('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;
|
||||
|
||||
debug(`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]) => {
|
||||
debug('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));
|
||||
debug(`copying '${binPath}' to '${dest}'`);
|
||||
fs.copyFileSync(binPath, dest);
|
||||
}
|
||||
|
||||
export class SidecarPlugin extends PluginBase<void> {
|
||||
name = 'sidecar';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.getHooks = this.getHooks.bind(this);
|
||||
debug('isStartScript:', isStartScrpt());
|
||||
}
|
||||
|
||||
getHooks(): ForgeHookMap {
|
||||
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) => {
|
||||
debug('resolveForgeConfig');
|
||||
return addWebpackDefine(currentConfig, DEFINE_NAME, BIN_DIR, BIN_NAME);
|
||||
},
|
||||
generateAssets: async (_config, platform, arch) => {
|
||||
debug('generateAssets', { platform, arch });
|
||||
build(SRC_DIR, arch, BIN_DIR, BIN_NAME);
|
||||
},
|
||||
packageAfterCopy: async (
|
||||
_config,
|
||||
buildPath,
|
||||
electronVersion,
|
||||
platform,
|
||||
arch,
|
||||
) => {
|
||||
debug('packageAfterCopy', {
|
||||
buildPath,
|
||||
electronVersion,
|
||||
platform,
|
||||
arch,
|
||||
});
|
||||
copyArtifact(buildPath, arch, BIN_DIR, BIN_NAME);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
69
lib/child-writer/README.md
Normal file
@ -0,0 +1,69 @@
|
||||
Etcher Child Writer
|
||||
===================
|
||||
|
||||
This module is in charge of dealing with the gory details of elevating and
|
||||
managing the child writer process. As a word of warning, it contains tons of
|
||||
workarounds and "hacks" to deal with platform differences, packaging, and
|
||||
inter-process communication. This empowers us to write this small guide to
|
||||
explain how it works in a more high level manner, hoping to make it easier to
|
||||
grok for contributors.
|
||||
|
||||
The problem
|
||||
-----------
|
||||
|
||||
Elevating a forked process is an easy task. Thanks to the widely available NPM
|
||||
modules to display nice GUI prompt dialogs, elevation is just a matter of
|
||||
executing the process with one of those modules instead of with `child_process`
|
||||
directly.
|
||||
|
||||
The main problems we faced are:
|
||||
|
||||
- The modules that implement elevation provide "execution" support, but don't
|
||||
allow us to fork/spawn the process and consume its `stdout` and `stderr` in a
|
||||
stream fashion. This also means that we can't use the nice `process.send` IPC
|
||||
communication channel directly that `child_process.fork` gives us to send
|
||||
messages back to the parent.
|
||||
|
||||
- Since we can't assume anything from the environment Etcher is running on, we
|
||||
must make use of the same application entry point to execute both the GUI and
|
||||
the CLI code, which starts to get messy once we throw `asar` packaging into
|
||||
the mix.
|
||||
|
||||
- Each elevation mechanism has its quirks, mainly on GNU/Linux. Making sure
|
||||
that the forked process was elevated correctly and could work without issues
|
||||
required various workarounds targeting `pkexec` or `kdesudo`.
|
||||
|
||||
How it works
|
||||
------------
|
||||
|
||||
The Etcher binary runs in CLI or GUI mode depending on an environment variable
|
||||
called `ELECTRON_RUN_AS_NODE`. When this variable is set, it instructs Electron
|
||||
to run as a normal NodeJS process (without Chromium, etc), but still keep any
|
||||
patches applied by Electron, like `asar` support.
|
||||
|
||||
When the Etcher GUI is ran, and the user presses the "Flash!" button, the GUI
|
||||
creates an IPC server, and forks a process called the "writer proxy", passing
|
||||
it all the required information to perform the flashing, such as the image
|
||||
path, the device path, the current settings, etc.
|
||||
|
||||
The writer proxy then checks if its currently elevated, and if not, prompts the
|
||||
user for elevation and re-spawns itself.
|
||||
|
||||
Once the writer proxy has enough permissions to directly access devices, it
|
||||
spawns the Etcher CLI passing the `--robot` option along with all the
|
||||
information gathered before. The `--robot` option basically tells the Etcher
|
||||
CLI to output state information in a way that can be very easily parsed by the
|
||||
parent process.
|
||||
|
||||
The output of the Etcher CLI is then sent to the IPC server that was opened by
|
||||
the GUI, which nicely displays them in the progress bar the user sees.
|
||||
|
||||
Summary
|
||||
-------
|
||||
|
||||
There are lots of details we're omitting for the sake of clarity. Feel free to
|
||||
dive in inside the child writer code, which is heavily commented to explain the
|
||||
reasons behind each decision or workaround.
|
||||
|
||||
Don't hesitate in getting in touch if you have any suggestion, or just want to
|
||||
know more!
|
100
lib/child-writer/cli.js
Normal file
@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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');
|
||||
|
||||
/**
|
||||
* @summary Get the explicit boolean form of an argument
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @description
|
||||
* We refer as "explicit boolean form of an argument" to a boolean
|
||||
* argument in either normal or negated form.
|
||||
*
|
||||
* For example: `--check` and `--no-check`;
|
||||
*
|
||||
* @param {String} argumentName - argument name
|
||||
* @param {Boolean} value - argument value
|
||||
* @returns {String} argument
|
||||
*
|
||||
* @example
|
||||
* console.log(cli.getBooleanArgumentForm('check', true));
|
||||
* > '--check'
|
||||
*
|
||||
* @example
|
||||
* console.log(cli.getBooleanArgumentForm('check', false));
|
||||
* > '--no-check'
|
||||
*/
|
||||
exports.getBooleanArgumentForm = (argumentName, value) => {
|
||||
const prefix = _.attempt(() => {
|
||||
if (!value) {
|
||||
return '--no-';
|
||||
}
|
||||
|
||||
const SHORT_OPTION_LENGTH = 1;
|
||||
if (_.size(argumentName) === SHORT_OPTION_LENGTH) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return '--';
|
||||
});
|
||||
|
||||
return prefix + argumentName;
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get CLI writer arguments
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} options - options
|
||||
* @param {String} options.image - image
|
||||
* @param {String} options.device - device
|
||||
* @param {String} options.entryPoint - entry point
|
||||
* @param {Boolean} [options.validateWriteOnSuccess] - validate write on success
|
||||
* @param {Boolean} [options.unmountOnSuccess] - unmount on success
|
||||
* @returns {String[]} arguments
|
||||
*
|
||||
* @example
|
||||
* const argv = cli.getArguments({
|
||||
* image: 'path/to/rpi.img',
|
||||
* device: '/dev/disk2'
|
||||
* entryPoint: 'path/to/app.asar',
|
||||
* validateWriteOnSuccess: true,
|
||||
* unmountOnSuccess: true
|
||||
* });
|
||||
*/
|
||||
exports.getArguments = (options) => {
|
||||
const argv = [
|
||||
options.entryPoint,
|
||||
options.image,
|
||||
'--drive',
|
||||
options.device,
|
||||
|
||||
// Explicitly set the boolean flag in positive
|
||||
// or negative way in order to be on the safe
|
||||
// side in case the Etcher CLI changes the
|
||||
// default value of these options.
|
||||
exports.getBooleanArgumentForm('unmount', options.unmountOnSuccess),
|
||||
exports.getBooleanArgumentForm('check', options.validateWriteOnSuccess)
|
||||
|
||||
];
|
||||
|
||||
return argv;
|
||||
};
|
40
lib/child-writer/constants.js
Normal file
@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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 path = require('path');
|
||||
|
||||
/**
|
||||
* @summary Child writer constants
|
||||
* @namespace CONSTANTS
|
||||
* @public
|
||||
*/
|
||||
module.exports = {
|
||||
|
||||
/**
|
||||
* @property {String} PROJECT_ROOT
|
||||
* @memberof CONSTANTS
|
||||
*/
|
||||
PROJECT_ROOT: path.join(__dirname, '..', '..'),
|
||||
|
||||
/**
|
||||
* @property {String} WRITER_PROXY_SCRIPT
|
||||
* @memberof CONSTANTS
|
||||
*/
|
||||
WRITER_PROXY_SCRIPT: path.join(__dirname, 'writer-proxy.js')
|
||||
|
||||
};
|
198
lib/child-writer/index.js
Normal file
@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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 EventEmitter = require('events').EventEmitter;
|
||||
const _ = require('lodash');
|
||||
const childProcess = require('child_process');
|
||||
const ipc = require('node-ipc');
|
||||
const rendererUtils = require('./renderer-utils');
|
||||
const cli = require('./cli');
|
||||
const CONSTANTS = require('./constants');
|
||||
const EXIT_CODES = require('../shared/exit-codes');
|
||||
const robot = require('../shared/robot');
|
||||
|
||||
/**
|
||||
* @summary Perform a write
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} image - image
|
||||
* @param {Object} drive - drive
|
||||
* @param {Object} options - options
|
||||
* @returns {EventEmitter} event emitter
|
||||
*
|
||||
* @example
|
||||
* const child = childWriter.write('path/to/rpi.img', {
|
||||
* device: '/dev/disk2'
|
||||
* }, {
|
||||
* validateWriteOnSuccess: true,
|
||||
* unmountOnSuccess: true
|
||||
* });
|
||||
*
|
||||
* child.on('progress', (state) => {
|
||||
* console.log(state);
|
||||
* });
|
||||
*
|
||||
* child.on('error', (error) => {
|
||||
* throw error;
|
||||
* });
|
||||
*
|
||||
* child.on('done', () => {
|
||||
* console.log('Validation was successful!');
|
||||
* });
|
||||
*/
|
||||
exports.write = (image, drive, options) => {
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
const argv = cli.getArguments({
|
||||
entryPoint: rendererUtils.getApplicationEntryPoint(),
|
||||
image,
|
||||
device: drive.device,
|
||||
validateWriteOnSuccess: options.validateWriteOnSuccess,
|
||||
unmountOnSuccess: options.unmountOnSuccess
|
||||
});
|
||||
|
||||
// There might be multiple Etcher instances running at
|
||||
// the same time, therefore we must ensure each IPC
|
||||
// server/client has a different name.
|
||||
process.env.IPC_SERVER_ID = `etcher-server-${process.pid}`;
|
||||
process.env.IPC_CLIENT_ID = `etcher-client-${process.pid}`;
|
||||
|
||||
ipc.config.id = process.env.IPC_SERVER_ID;
|
||||
ipc.config.silent = true;
|
||||
ipc.serve();
|
||||
|
||||
/**
|
||||
* @summary Safely terminate the IPC server
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @example
|
||||
* terminateServer();
|
||||
*/
|
||||
const terminateServer = () => {
|
||||
|
||||
// Turns out we need to destroy all sockets for
|
||||
// the server to actually close. Otherwise, it
|
||||
// just stops receiving any further connections,
|
||||
// but remains open if there are active ones.
|
||||
_.each(ipc.server.sockets, (socket) => {
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
ipc.server.stop();
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Emit an error to the client
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Error} error - error
|
||||
*
|
||||
* @example
|
||||
* emitError(new Error('foo bar'));
|
||||
*/
|
||||
const emitError = (error) => {
|
||||
terminateServer();
|
||||
emitter.emit('error', error);
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Bridge robot message to the child writer caller
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {String} message - robot message
|
||||
*
|
||||
* @example
|
||||
* bridgeRobotMessage(robot.buildMessage('foo', {
|
||||
* bar: 'baz'
|
||||
* }));
|
||||
*/
|
||||
const bridgeRobotMessage = (message) => {
|
||||
try {
|
||||
const parsedMessage = robot.parseMessage(message);
|
||||
|
||||
// These are lighweight accessor methods for
|
||||
// the properties of the parsed message
|
||||
const messageCommand = robot.getCommand(parsedMessage);
|
||||
const messageData = robot.getData(parsedMessage);
|
||||
|
||||
// The error object is decomposed by the CLI for serialisation
|
||||
// purposes. We compose it back to an `Error` here in order
|
||||
// to provide better encapsulation.
|
||||
if (messageCommand === 'error') {
|
||||
emitError(robot.recomposeErrorMessage(parsedMessage));
|
||||
} else if (messageCommand === 'log') {
|
||||
console.log(messageData);
|
||||
} else {
|
||||
emitter.emit(messageCommand, messageData);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
}
|
||||
};
|
||||
|
||||
ipc.server.on('error', emitError);
|
||||
ipc.server.on('message', bridgeRobotMessage);
|
||||
|
||||
ipc.server.on('start', () => {
|
||||
const child = childProcess.fork(CONSTANTS.WRITER_PROXY_SCRIPT, argv, {
|
||||
silent: true,
|
||||
env: process.env
|
||||
});
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
console.info(`WRITER: ${data.toString()}`);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
bridgeRobotMessage(data.toString());
|
||||
|
||||
// This function causes the `close` event to be emitted
|
||||
child.kill();
|
||||
|
||||
});
|
||||
|
||||
child.on('error', emitError);
|
||||
|
||||
child.on('close', (code) => {
|
||||
terminateServer();
|
||||
|
||||
if (code === EXIT_CODES.CANCELLED) {
|
||||
return emitter.emit('done', {
|
||||
cancelled: true
|
||||
});
|
||||
}
|
||||
|
||||
// We shouldn't emit the `done` event manually here
|
||||
// since the writer process will take care of it.
|
||||
if (code === EXIT_CODES.SUCCESS || code === EXIT_CODES.VALIDATION_ERROR) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return emitError(new Error(`Child process exited with error code: ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
ipc.server.start();
|
||||
|
||||
return emitter;
|
||||
};
|
51
lib/child-writer/renderer-utils.js
Normal file
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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';
|
||||
|
||||
/**
|
||||
* This file is only meant to be loaded by the renderer process.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const isRunningInAsar = require('electron-is-running-in-asar');
|
||||
const electron = require('electron');
|
||||
const CONSTANTS = require('./constants');
|
||||
|
||||
/**
|
||||
* @summary Get application entry point
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} entry point
|
||||
*
|
||||
* @example
|
||||
* const entryPoint = rendererUtils.getApplicationEntryPoint();
|
||||
*/
|
||||
exports.getApplicationEntryPoint = () => {
|
||||
if (isRunningInAsar()) {
|
||||
return path.join(process.resourcesPath, 'app.asar');
|
||||
}
|
||||
|
||||
const ENTRY_POINT_ARGV_INDEX = 1;
|
||||
const relativeEntryPoint = electron.remote.process.argv[ENTRY_POINT_ARGV_INDEX];
|
||||
|
||||
// On GNU/Linux, `pkexec` resolves relative paths
|
||||
// from `/root`, therefore we pass an absolute path,
|
||||
// in order to be on the safe side.
|
||||
return path.join(CONSTANTS.PROJECT_ROOT, relativeEntryPoint);
|
||||
|
||||
};
|
45
lib/child-writer/utils.js
Normal file
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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');
|
||||
|
||||
/**
|
||||
* @summary Split stringified object lines
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function takes special care to not consider new lines
|
||||
* inside the object properties.
|
||||
*
|
||||
* @param {String} lines - lines
|
||||
* @returns {String[]} split lines
|
||||
*
|
||||
* @example
|
||||
* const result = utils.splitObjectLines('{"foo":"bar"}\n{"hello":"Hello\nWorld"}');
|
||||
* console.log(result);
|
||||
*
|
||||
* > [ '{"foo":"bar"}', '{"hello":"Hello\nWorld"}' ]
|
||||
*/
|
||||
exports.splitObjectLines = (lines) => {
|
||||
return _.chain(lines)
|
||||
.split(/((?:[^\n"']|"[^"]*"|'[^']*')+)/)
|
||||
.map(_.trim)
|
||||
.reject(_.isEmpty)
|
||||
.value();
|
||||
};
|
245
lib/child-writer/writer-proxy.js
Normal file
@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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 childProcess = require('child_process');
|
||||
const commandJoin = require('command-join');
|
||||
const ipc = require('node-ipc');
|
||||
const _ = require('lodash');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const sudoPrompt = Bluebird.promisifyAll(require('sudo-prompt'));
|
||||
const utils = require('./utils');
|
||||
const EXIT_CODES = require('../shared/exit-codes');
|
||||
const errors = require('../shared/errors');
|
||||
const robot = require('../shared/robot');
|
||||
const permissions = require('../shared/permissions');
|
||||
const packageJSON = require('../../package.json');
|
||||
|
||||
// This script is in charge of spawning the writer process and
|
||||
// ensuring it has the necessary privileges. It might look a bit
|
||||
// complex at first sight, but this is only because elevation
|
||||
// modules don't work in a spawn/fork fashion.
|
||||
//
|
||||
// This script spawns the writer process and redirects its `stdout`
|
||||
// and `stderr` to the parent process using IPC communication,
|
||||
// taking care of the writer elevation as needed.
|
||||
|
||||
/**
|
||||
* @summary The Etcher executable file path
|
||||
* @constant
|
||||
* @private
|
||||
* @type {String}
|
||||
*/
|
||||
const executable = _.first(process.argv);
|
||||
|
||||
/**
|
||||
* @summary The first index that represents an actual option argument
|
||||
* @constant
|
||||
* @private
|
||||
* @type {Number}
|
||||
*
|
||||
* @description
|
||||
* The first arguments are usually the program executable itself, etc.
|
||||
*/
|
||||
const OPTIONS_INDEX_START = 2;
|
||||
|
||||
/**
|
||||
* @summary The list of Etcher argument options
|
||||
* @constant
|
||||
* @private
|
||||
* @type {String[]}
|
||||
*/
|
||||
const etcherArguments = process.argv.slice(OPTIONS_INDEX_START);
|
||||
|
||||
return permissions.isElevated().then((elevated) => {
|
||||
|
||||
if (!elevated) {
|
||||
console.log('Attempting to elevate');
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
const elevator = Bluebird.promisifyAll(require('elevator'));
|
||||
|
||||
const commandArguments = [
|
||||
'set',
|
||||
'ELECTRON_RUN_AS_NODE=1',
|
||||
'&&',
|
||||
'set',
|
||||
`IPC_SERVER_ID=${process.env.IPC_SERVER_ID}`,
|
||||
'&&',
|
||||
'set',
|
||||
`IPC_CLIENT_ID=${process.env.IPC_CLIENT_ID}`,
|
||||
'&&',
|
||||
|
||||
// This is a trick to make the binary afterwards catch
|
||||
// the environment variables set just previously.
|
||||
'call'
|
||||
|
||||
].concat(process.argv);
|
||||
|
||||
// For debugging purposes
|
||||
console.log(`Running: ${commandArguments.join(' ')}`);
|
||||
|
||||
return elevator.executeAsync(commandArguments, {
|
||||
hidden: true,
|
||||
terminating: true,
|
||||
doNotPushdCurrentDirectory: true,
|
||||
waitForTermination: true
|
||||
}).catch({
|
||||
code: 'ELEVATE_CANCELLED'
|
||||
}, () => {
|
||||
process.exit(EXIT_CODES.CANCELLED);
|
||||
});
|
||||
}
|
||||
|
||||
const commandArguments = _.attempt(() => {
|
||||
const commandPrefix = [
|
||||
|
||||
// Some elevation tools, like `pkexec` or `kdesudo`, don't
|
||||
// provide a way to preserve the environment, therefore we
|
||||
// have to make sure the environment variables we're interested
|
||||
// in are manually inherited.
|
||||
'env',
|
||||
'ELECTRON_RUN_AS_NODE=1',
|
||||
`IPC_SERVER_ID=${process.env.IPC_SERVER_ID}`,
|
||||
`IPC_CLIENT_ID=${process.env.IPC_CLIENT_ID}`,
|
||||
|
||||
// This environment variable prevents the AppImages
|
||||
// desktop integration script from presenting the
|
||||
// "installation" dialog.
|
||||
'SKIP=1'
|
||||
|
||||
];
|
||||
|
||||
if (process.env.APPIMAGE && process.env.APPDIR) {
|
||||
|
||||
// Translate the current arguments to point to the AppImage
|
||||
// Relative paths are resolved from `/tmp/.mount_XXXXXX/usr`
|
||||
const translatedArguments = _.chain(process.argv)
|
||||
.tail()
|
||||
.invokeMap('replace', path.join(process.env.APPDIR, 'usr/'), '')
|
||||
.value();
|
||||
|
||||
return commandPrefix
|
||||
.concat([ process.env.APPIMAGE ])
|
||||
.concat(translatedArguments);
|
||||
}
|
||||
|
||||
return commandPrefix.concat(process.argv);
|
||||
});
|
||||
|
||||
const command = commandJoin(commandArguments);
|
||||
|
||||
// For debugging purposes
|
||||
console.log(`Running: ${command}`);
|
||||
|
||||
return sudoPrompt.execAsync(command, {
|
||||
name: packageJSON.displayName
|
||||
}).then((stdout, stderr) => {
|
||||
if (!_.isEmpty(stderr)) {
|
||||
throw errors.createError(stderr);
|
||||
}
|
||||
|
||||
// We're hardcoding internal error messages declared by `sudo-prompt`.
|
||||
// There doesn't seem to be a better way to handle these errors, so
|
||||
// for now, we should make sure we double check if the error messages
|
||||
// have changed every time we upgrade `sudo-prompt`.
|
||||
|
||||
}).catch({
|
||||
message: 'User did not grant permission.'
|
||||
}, () => {
|
||||
process.exit(EXIT_CODES.CANCELLED);
|
||||
}).catch({
|
||||
message: 'No polkit authentication agent found.'
|
||||
}, () => {
|
||||
throw errors.createUserError(
|
||||
'No polkit authentication agent found',
|
||||
'Please install a polkit authentication agent for your desktop environment of choice to continue'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Re-spawning with elevation');
|
||||
|
||||
return new Bluebird((resolve, reject) => {
|
||||
ipc.config.id = process.env.IPC_CLIENT_ID;
|
||||
ipc.config.silent = true;
|
||||
|
||||
// > If set to 0, the client will NOT try to reconnect.
|
||||
// See https://github.com/RIAEvangelist/node-ipc/
|
||||
//
|
||||
// The purpose behind this change is for this process
|
||||
// to emit a "disconnect" event as soon as the GUI
|
||||
// process is closed, so we can kill the CLI as well.
|
||||
ipc.config.stopRetrying = 0;
|
||||
|
||||
ipc.connectTo(process.env.IPC_SERVER_ID, () => {
|
||||
ipc.of[process.env.IPC_SERVER_ID].on('error', reject);
|
||||
ipc.of[process.env.IPC_SERVER_ID].on('connect', () => {
|
||||
|
||||
const child = childProcess.spawn(executable, etcherArguments, {
|
||||
env: {
|
||||
|
||||
// The CLI might call operating system utilities (like `diskutil`),
|
||||
// so we must ensure the `PATH` is inherited.
|
||||
PATH: process.env.PATH,
|
||||
|
||||
ELECTRON_RUN_AS_NODE: 1,
|
||||
ETCHER_CLI_ROBOT: 1
|
||||
}
|
||||
});
|
||||
|
||||
ipc.of[process.env.IPC_SERVER_ID].on('disconnect', _.bind(child.kill, child));
|
||||
child.on('error', reject);
|
||||
child.on('close', resolve);
|
||||
|
||||
/**
|
||||
* @summary Emit an object message to the IPC server
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Buffer} data - json message data
|
||||
*
|
||||
* @example
|
||||
* emitMessage(Buffer.from(JSON.stringify({
|
||||
* foo: 'bar'
|
||||
* })));
|
||||
*/
|
||||
const emitMessage = (data) => {
|
||||
|
||||
// Output from stdout/stderr coming from the CLI might be buffered,
|
||||
// causing several progress lines to come up at once as single message.
|
||||
// Trying to parse multiple JSON objects separated by new lines will
|
||||
// of course make the parser confused, causing errors later on.
|
||||
_.each(utils.splitObjectLines(data.toString()), (object) => {
|
||||
ipc.of[process.env.IPC_SERVER_ID].emit('message', object);
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
child.stdout.on('data', emitMessage);
|
||||
child.stderr.on('data', emitMessage);
|
||||
});
|
||||
});
|
||||
}).then((exitCode) => {
|
||||
process.exit(exitCode);
|
||||
});
|
||||
}).catch((error) => {
|
||||
robot.printError(error);
|
||||
process.exit(EXIT_CODES.GENERAL_ERROR);
|
||||
});
|
46
lib/cli/README.md
Normal file
@ -0,0 +1,46 @@
|
||||
Etcher CLI
|
||||
==========
|
||||
|
||||
The Etcher CLI is a command line interface to the Etcher writer backend, and
|
||||
currently the only module in the "Etcher" umbrella that makes use of this
|
||||
backend directly.
|
||||
|
||||
This module also has the task of unmounting the drives before and after
|
||||
flashing.
|
||||
|
||||
Notice the Etcher CLI is not worried about elevation, and assumes it has enough
|
||||
permissions to continue, throwing an error otherwise. Consult the
|
||||
[`lib/child-writer`][child-writer] module to understand how elevation works on
|
||||
Etcher.
|
||||
|
||||
The robot option
|
||||
----------------
|
||||
|
||||
Setting the `ETCHER_CLI_ROBOT` environment variable allows other applications
|
||||
to easily consume the output of the Etcher CLI in real-time. When using the
|
||||
`ETCHER_CLI_ROBOT` option, the `--yes` option is implicit, therefore you need
|
||||
to manually specify `--drive`.
|
||||
|
||||
When `ETCHER_CLI_ROBOT` is used, the program will output JSON lines containing
|
||||
the progress state and other useful information. For example:
|
||||
|
||||
```
|
||||
$ sudo ETCHER_CLI_ROBOT=1 etcher image.iso --drive /dev/disk2
|
||||
{"command":"progress","data":{"type":"write","percentage":1,"eta":130,"speed":1703936}}
|
||||
...
|
||||
{"command":"progress","data":{"type":"check","percentage":100,"eta":0,"speed":17180514}}
|
||||
{"command":"done","data":{"sourceChecksum":"27c39a5d"}}
|
||||
```
|
||||
|
||||
See documentation about the robot mode at [`lib/shared/robot`][robot].
|
||||
|
||||
Exit codes
|
||||
----------
|
||||
|
||||
The Etcher CLI uses certain exit codes to signal the result of the operation.
|
||||
These are documented in [`lib/shared/exit-codes.js`][exit-codes] and are also
|
||||
printed on the Etcher CLI help page.
|
||||
|
||||
[exit-codes]: https://github.com/resin-io/etcher/blob/master/lib/shared/exit-codes.js
|
||||
[robot]: https://github.com/resin-io/etcher/tree/master/lib/shared/robot
|
||||
[child-writer]: https://github.com/resin-io/etcher/tree/master/lib/child-writer
|
144
lib/cli/etcher.js
Normal file
@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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 Bluebird = require('bluebird');
|
||||
const visuals = require('resin-cli-visuals');
|
||||
const form = require('resin-cli-form');
|
||||
const drivelist = Bluebird.promisifyAll(require('drivelist'));
|
||||
const writer = require('./writer');
|
||||
const utils = require('./utils');
|
||||
const options = require('./options');
|
||||
const robot = require('../shared/robot');
|
||||
const messages = require('../shared/messages');
|
||||
const EXIT_CODES = require('../shared/exit-codes');
|
||||
const errors = require('../shared/errors');
|
||||
const permissions = require('../shared/permissions');
|
||||
|
||||
const ARGV_IMAGE_PATH_INDEX = 0;
|
||||
const imagePath = options._[ARGV_IMAGE_PATH_INDEX];
|
||||
|
||||
permissions.isElevated().then((elevated) => {
|
||||
if (!elevated) {
|
||||
throw errors.createUserError(
|
||||
messages.error.elevationRequired(),
|
||||
'This tool requires special permissions to write to external drives'
|
||||
);
|
||||
}
|
||||
|
||||
return form.run([
|
||||
{
|
||||
message: 'Select drive',
|
||||
type: 'drive',
|
||||
name: 'drive'
|
||||
},
|
||||
{
|
||||
message: 'This will erase the selected drive. Are you sure?',
|
||||
type: 'confirm',
|
||||
name: 'yes',
|
||||
default: false
|
||||
}
|
||||
], {
|
||||
override: {
|
||||
drive: options.drive,
|
||||
|
||||
// If `options.yes` is `false`, pass `null`,
|
||||
// otherwise the question will not be asked because
|
||||
// `false` is a defined value.
|
||||
yes: robot.isEnabled(process.env) || options.yes || null
|
||||
|
||||
}
|
||||
});
|
||||
}).then((answers) => {
|
||||
if (!answers.yes) {
|
||||
throw errors.createUserError('Aborted', 'We can\'t proceed without confirmation');
|
||||
}
|
||||
|
||||
const progressBars = {
|
||||
write: new visuals.Progress('Flashing'),
|
||||
check: new visuals.Progress('Validating')
|
||||
};
|
||||
|
||||
return drivelist.listAsync().then((drives) => {
|
||||
const selectedDrive = _.find(drives, {
|
||||
device: answers.drive
|
||||
});
|
||||
|
||||
if (!selectedDrive) {
|
||||
throw errors.createUserError(
|
||||
'The selected drive was not found',
|
||||
`We can't find ${answers.drive} in your system. Did you unplug the drive?`
|
||||
);
|
||||
}
|
||||
|
||||
return writer.writeImage(imagePath, selectedDrive, {
|
||||
unmountOnSuccess: options.unmount,
|
||||
validateWriteOnSuccess: options.check
|
||||
}, (state) => {
|
||||
|
||||
if (robot.isEnabled(process.env)) {
|
||||
robot.printMessage('progress', {
|
||||
type: state.type,
|
||||
percentage: Math.floor(state.percentage),
|
||||
eta: state.eta,
|
||||
speed: Math.floor(state.speed)
|
||||
});
|
||||
} else {
|
||||
progressBars[state.type].update(state);
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
}).then((results) => {
|
||||
|
||||
return Bluebird.try(() => {
|
||||
if (robot.isEnabled(process.env)) {
|
||||
return robot.printMessage('done', {
|
||||
sourceChecksum: results.sourceChecksum
|
||||
});
|
||||
}
|
||||
|
||||
console.log(messages.info.flashComplete());
|
||||
|
||||
if (results.sourceChecksum) {
|
||||
console.log(`Checksum: ${results.sourceChecksum}`);
|
||||
}
|
||||
|
||||
return Bluebird.resolve();
|
||||
}).then(() => {
|
||||
process.exit(EXIT_CODES.SUCCESS);
|
||||
});
|
||||
|
||||
}).catch((error) => {
|
||||
|
||||
return Bluebird.try(() => {
|
||||
if (robot.isEnabled(process.env)) {
|
||||
return robot.printError(error);
|
||||
}
|
||||
|
||||
utils.printError(error);
|
||||
return Bluebird.resolve();
|
||||
}).then(() => {
|
||||
if (error.code === 'EVALIDATION') {
|
||||
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
process.exit(EXIT_CODES.GENERAL_ERROR);
|
||||
});
|
||||
|
||||
});
|
159
lib/cli/options.js
Normal file
@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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 fs = require('fs');
|
||||
const yargs = require('yargs');
|
||||
const utils = require('./utils');
|
||||
const robot = require('../shared/robot');
|
||||
const EXIT_CODES = require('../shared/exit-codes');
|
||||
const errors = require('../shared/errors');
|
||||
const packageJSON = require('../../package.json');
|
||||
|
||||
/**
|
||||
* @summary The minimum required number of CLI arguments
|
||||
* @constant
|
||||
* @private
|
||||
* @type {Number}
|
||||
*/
|
||||
const MINIMUM_NUMBER_OF_ARGUMENTS = 1;
|
||||
|
||||
/**
|
||||
* @summary The index of the image argument
|
||||
* @constant
|
||||
* @private
|
||||
* @type {Number}
|
||||
*/
|
||||
const IMAGE_PATH_ARGV_INDEX = 0;
|
||||
|
||||
/**
|
||||
* @summary The first index that represents an actual option argument
|
||||
* @constant
|
||||
* @private
|
||||
* @type {Number}
|
||||
*
|
||||
* @description
|
||||
* The first arguments are usually the program executable itself, etc.
|
||||
*/
|
||||
const OPTIONS_INDEX_START = 2;
|
||||
|
||||
/**
|
||||
* @summary Parsed CLI options and arguments
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
module.exports = yargs
|
||||
|
||||
// Don't wrap at all
|
||||
.wrap(null)
|
||||
|
||||
.demand(MINIMUM_NUMBER_OF_ARGUMENTS, 'Missing image')
|
||||
|
||||
// Usage help
|
||||
.usage('Usage: $0 [options] <image>')
|
||||
.epilogue([
|
||||
'Exit codes:',
|
||||
_.map(EXIT_CODES, (value, key) => {
|
||||
const reason = _.map(_.split(key, '_'), _.capitalize).join(' ');
|
||||
return ` ${value} - ${reason}`;
|
||||
}).join('\n'),
|
||||
'',
|
||||
'If you need help, don\'t hesitate in contacting us at:',
|
||||
'',
|
||||
' GitHub: https://github.com/resin-io/etcher/issues/new',
|
||||
' Gitter: https://gitter.im/resin-io/etcher'
|
||||
].join('\n'))
|
||||
|
||||
// Examples
|
||||
.example('$0 raspberry-pi.img')
|
||||
.example('$0 --no-check raspberry-pi.img')
|
||||
.example('$0 -d /dev/disk2 ubuntu.iso')
|
||||
.example('$0 -d /dev/disk2 -y rpi.img')
|
||||
|
||||
// Help option
|
||||
.help()
|
||||
|
||||
// Version option
|
||||
.version(_.constant(packageJSON.version))
|
||||
|
||||
// Error reporting
|
||||
.fail((message, error) => {
|
||||
const errorObject = error || errors.createUserError(message);
|
||||
|
||||
if (robot.isEnabled(process.env)) {
|
||||
robot.printError(errorObject);
|
||||
} else {
|
||||
yargs.showHelp();
|
||||
utils.printError(errorObject);
|
||||
}
|
||||
|
||||
process.exit(EXIT_CODES.GENERAL_ERROR);
|
||||
})
|
||||
|
||||
// Assert that image exists
|
||||
.check((argv) => {
|
||||
fs.accessSync(argv._[IMAGE_PATH_ARGV_INDEX]);
|
||||
return true;
|
||||
})
|
||||
|
||||
.check((argv) => {
|
||||
if (robot.isEnabled(process.env) && !argv.drive) {
|
||||
throw errors.createUserError(
|
||||
'Missing drive',
|
||||
'You need to explicitly pass a drive when enabling robot mode'
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
|
||||
.options({
|
||||
help: {
|
||||
describe: 'show help',
|
||||
boolean: true,
|
||||
alias: 'h'
|
||||
},
|
||||
version: {
|
||||
describe: 'show version number',
|
||||
boolean: true,
|
||||
alias: 'v'
|
||||
},
|
||||
drive: {
|
||||
describe: 'drive',
|
||||
string: true,
|
||||
alias: 'd'
|
||||
},
|
||||
check: {
|
||||
describe: 'validate write',
|
||||
boolean: true,
|
||||
alias: 'c',
|
||||
default: true
|
||||
},
|
||||
yes: {
|
||||
describe: 'confirm non-interactively',
|
||||
boolean: true,
|
||||
alias: 'y'
|
||||
},
|
||||
unmount: {
|
||||
describe: 'unmount on success',
|
||||
boolean: true,
|
||||
alias: 'u',
|
||||
default: true
|
||||
}
|
||||
})
|
||||
.parse(process.argv.slice(OPTIONS_INDEX_START));
|
47
lib/cli/utils.js
Normal file
@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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 chalk = require('chalk');
|
||||
const errors = require('../shared/errors');
|
||||
|
||||
/**
|
||||
* @summary Print an error to stderr
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Error} error - error
|
||||
*
|
||||
* @example
|
||||
* utils.printError(new Error('Oops!'));
|
||||
*/
|
||||
exports.printError = (error) => {
|
||||
const title = errors.getTitle(error);
|
||||
const description = errors.getDescription(error, {
|
||||
userFriendlyDescriptionsOnly: true
|
||||
});
|
||||
|
||||
console.error(chalk.red(title));
|
||||
|
||||
if (description) {
|
||||
console.error(`\n${chalk.red(description)}`);
|
||||
}
|
||||
|
||||
if (process.env.ETCHER_CLI_DEBUG && error.stack) {
|
||||
console.error(`\n${chalk.red(error.stack)}`);
|
||||
}
|
||||
};
|
115
lib/cli/writer.js
Normal file
@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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 imageWrite = require('etcher-image-write');
|
||||
const Bluebird = require('bluebird');
|
||||
const fs = Bluebird.promisifyAll(require('fs'));
|
||||
const mountutils = Bluebird.promisifyAll(require('mountutils'));
|
||||
const os = require('os');
|
||||
const imageStream = require('../image-stream');
|
||||
const errors = require('../shared/errors');
|
||||
const constraints = require('../shared/drive-constraints');
|
||||
|
||||
/**
|
||||
* @summary Write an image to a disk drive
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* See https://github.com/resin-io-modules/etcher-image-write for information
|
||||
* about the `state` object passed to `onProgress` callback.
|
||||
*
|
||||
* @param {String} imagePath - path to image
|
||||
* @param {Object} drive - drive
|
||||
* @param {Object} options - options
|
||||
* @param {Boolean} [options.unmountOnSuccess=false] - unmount on success
|
||||
* @param {Boolean} [options.validateWriteOnSuccess=false] - validate write on success
|
||||
* @param {Function} onProgress - on progress callback (state)
|
||||
*
|
||||
* @fulfil {Boolean} - whether the operation was successful
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* writer.writeImage('path/to/image.img', {
|
||||
* device: '/dev/disk2'
|
||||
* }, {
|
||||
* unmountOnSuccess: true,
|
||||
* validateWriteOnSuccess: true
|
||||
* }, (state) => {
|
||||
* console.log(state.percentage);
|
||||
* }).then(() => {
|
||||
* console.log('Done!');
|
||||
* });
|
||||
*/
|
||||
exports.writeImage = (imagePath, drive, options, onProgress) => {
|
||||
return Bluebird.try(() => {
|
||||
|
||||
// Unmounting a drive in Windows means we can't write to it anymore
|
||||
if (os.platform() === 'win32') {
|
||||
return Bluebird.resolve();
|
||||
}
|
||||
|
||||
return mountutils.unmountDiskAsync(drive.device);
|
||||
}).then(() => {
|
||||
return fs.openAsync(drive.raw, 'rs+');
|
||||
}).then((driveFileDescriptor) => {
|
||||
return imageStream.getFromFilePath(imagePath).then((image) => {
|
||||
if (!constraints.isDriveLargeEnough(drive, image)) {
|
||||
throw errors.createUserError(
|
||||
'The image you selected is too big for this drive',
|
||||
'Please connect a bigger drive and try again'
|
||||
);
|
||||
}
|
||||
|
||||
return imageWrite.write({
|
||||
fd: driveFileDescriptor,
|
||||
device: drive.raw,
|
||||
size: drive.size
|
||||
}, {
|
||||
stream: image.stream,
|
||||
size: image.size.original
|
||||
}, {
|
||||
check: options.validateWriteOnSuccess,
|
||||
transform: image.transform,
|
||||
bmap: image.bmap,
|
||||
bytesToZeroOutFromTheBeginning: image.bytesToZeroOutFromTheBeginning
|
||||
});
|
||||
}).then((writer) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
writer.on('progress', onProgress);
|
||||
writer.on('error', reject);
|
||||
writer.on('done', resolve);
|
||||
});
|
||||
}).tap(() => {
|
||||
|
||||
// Make sure the device stream file descriptor is closed
|
||||
// before returning control the the caller. Not closing
|
||||
// the file descriptor (and waiting for it) results in
|
||||
// `EBUSY` errors when attempting to unmount the drive
|
||||
// right afterwards in some Windows 7 systems.
|
||||
return fs.closeAsync(driveFileDescriptor).then(() => {
|
||||
|
||||
if (!options.unmountOnSuccess) {
|
||||
return Bluebird.resolve();
|
||||
}
|
||||
|
||||
return mountutils.unmountDiskAsync(drive.device);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
272
lib/gui/app.js
Normal file
@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright 2016 resin.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 Bluebird = require('bluebird');
|
||||
const EXIT_CODES = require('../shared/exit-codes');
|
||||
const messages = require('../shared/messages');
|
||||
const packageJSON = require('../../package.json');
|
||||
const flashState = require('./models/flash-state');
|
||||
|
||||
const Store = require('./models/store');
|
||||
|
||||
const app = angular.module('Etcher', [
|
||||
require('angular-ui-router'),
|
||||
require('angular-ui-bootstrap'),
|
||||
require('angular-if-state'),
|
||||
|
||||
// Etcher modules
|
||||
require('./modules/analytics'),
|
||||
require('./modules/error'),
|
||||
require('./modules/drive-scanner'),
|
||||
|
||||
// Models
|
||||
require('./models/selection-state'),
|
||||
require('./models/drives'),
|
||||
|
||||
// Components
|
||||
require('./components/svg-icon/svg-icon'),
|
||||
require('./components/update-notifier/update-notifier'),
|
||||
require('./components/warning-modal/warning-modal'),
|
||||
|
||||
// Pages
|
||||
require('./pages/main/main'),
|
||||
require('./pages/finish/finish'),
|
||||
require('./pages/settings/settings'),
|
||||
|
||||
// OS
|
||||
require('./os/window-progress/window-progress'),
|
||||
require('./os/open-external/open-external'),
|
||||
require('./os/dropzone/dropzone'),
|
||||
require('./os/dialog/dialog'),
|
||||
|
||||
// Utils
|
||||
require('./utils/manifest-bind/manifest-bind')
|
||||
]);
|
||||
|
||||
app.run(() => {
|
||||
console.log([
|
||||
' _____ _ _',
|
||||
'| ___| | | |',
|
||||
'| |__ | |_ ___| |__ ___ _ __',
|
||||
'| __|| __/ __| \'_ \\ / _ \\ \'__|',
|
||||
'| |___| || (__| | | | __/ |',
|
||||
'\\____/ \\__\\___|_| |_|\\___|_|',
|
||||
'',
|
||||
'Interested in joining the Etcher team?',
|
||||
'Drop us a line at join+etcher@resin.io'
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
app.run((AnalyticsService, ErrorService, UpdateNotifierService, SelectionStateModel) => {
|
||||
AnalyticsService.logEvent('Application start');
|
||||
|
||||
const shouldCheckForUpdates = UpdateNotifierService.shouldCheckForUpdates();
|
||||
|
||||
if (!shouldCheckForUpdates || process.env.ETCHER_DISABLE_UPDATES) {
|
||||
AnalyticsService.logEvent('Not checking for updates', {
|
||||
shouldCheckForUpdates,
|
||||
disableUpdatesEnvironmentVariable: process.env.ETCHER_DISABLE_UPDATES
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
AnalyticsService.logEvent('Checking for updates', {
|
||||
currentVersion: packageJSON.version
|
||||
});
|
||||
|
||||
UpdateNotifierService.isLatestVersion().then((isLatestVersion) => {
|
||||
|
||||
if (isLatestVersion) {
|
||||
AnalyticsService.logEvent('Update notification skipped', {
|
||||
reason: 'Latest version'
|
||||
});
|
||||
return Bluebird.resolve();
|
||||
}
|
||||
|
||||
// In case the internet connection is not good and checking the
|
||||
// latest published version takes too long, only show notify
|
||||
// the user about the new version if he didn't start the flash
|
||||
// process (e.g: selected an image), otherwise such interruption
|
||||
// might be annoying.
|
||||
if (SelectionStateModel.hasImage()) {
|
||||
AnalyticsService.logEvent('Update notification skipped', {
|
||||
reason: 'Image selected'
|
||||
});
|
||||
return Bluebird.resolve();
|
||||
}
|
||||
|
||||
AnalyticsService.logEvent('Notifying update');
|
||||
|
||||
return UpdateNotifierService.notify();
|
||||
|
||||
}).catch(ErrorService.reportException);
|
||||
|
||||
});
|
||||
|
||||
app.run((AnalyticsService, OSWindowProgressService) => {
|
||||
Store.subscribe(() => {
|
||||
const currentFlashState = flashState.getFlashState();
|
||||
|
||||
// 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.
|
||||
//
|
||||
// We use the presence of `.eta` to determine that the actual
|
||||
// writing started.
|
||||
if (!flashState.isFlashing() || !currentFlashState.eta) {
|
||||
return;
|
||||
}
|
||||
|
||||
AnalyticsService.logDebug([
|
||||
`Progress (${currentFlashState.type}):`,
|
||||
`${currentFlashState.percentage}% at ${currentFlashState.speed} MB/s`,
|
||||
`(eta ${currentFlashState.eta}s)`
|
||||
].join(' '));
|
||||
|
||||
OSWindowProgressService.set(currentFlashState.percentage);
|
||||
});
|
||||
});
|
||||
|
||||
app.run(($timeout, DriveScannerService, DrivesModel, ErrorService) => {
|
||||
DriveScannerService.on('drives', (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(() => {
|
||||
DrivesModel.setDrives(drives);
|
||||
});
|
||||
});
|
||||
|
||||
DriveScannerService.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.
|
||||
DriveScannerService.stop();
|
||||
|
||||
return ErrorService.reportException(error);
|
||||
});
|
||||
|
||||
DriveScannerService.start();
|
||||
});
|
||||
|
||||
app.run(($window, AnalyticsService, WarningModalService, ErrorService, OSDialogService) => {
|
||||
let popupExists = false;
|
||||
|
||||
$window.addEventListener('beforeunload', (event) => {
|
||||
if (!flashState.isFlashing() || popupExists) {
|
||||
AnalyticsService.logEvent('Close application', {
|
||||
isFlashing: flashState.isFlashing()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't close window while flashing
|
||||
event.returnValue = false;
|
||||
|
||||
// Don't open any more popups
|
||||
popupExists = true;
|
||||
|
||||
AnalyticsService.logEvent('Close attempt while flashing');
|
||||
|
||||
OSDialogService.showWarning({
|
||||
confirmationLabel: 'Yes, quit',
|
||||
rejectionLabel: 'Cancel',
|
||||
title: 'Are you sure you want to close Etcher?',
|
||||
description: messages.warning.exitWhileFlashing()
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
AnalyticsService.logEvent('Close confirmed while flashing');
|
||||
|
||||
// This circumvents the 'beforeunload' event unlike
|
||||
// electron.remote.app.quit() which does not.
|
||||
electron.remote.process.exit(EXIT_CODES.SUCCESS);
|
||||
|
||||
}
|
||||
|
||||
AnalyticsService.logEvent('Close rejected while flashing');
|
||||
popupExists = false;
|
||||
}).catch(ErrorService.reportException);
|
||||
});
|
||||
});
|
||||
|
||||
app.run(($rootScope, AnalyticsService) => {
|
||||
$rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
|
||||
|
||||
// Ignore first navigation
|
||||
if (!fromState.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
AnalyticsService.logEvent('Navigate', {
|
||||
to: toState.name,
|
||||
from: fromState.name
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.config(($urlRouterProvider) => {
|
||||
$urlRouterProvider.otherwise('/main');
|
||||
});
|
||||
|
||||
app.config(($provide) => {
|
||||
$provide.decorator('$exceptionHandler', ($delegate, $injector) => {
|
||||
return (exception, cause) => {
|
||||
const ErrorService = $injector.get('ErrorService');
|
||||
ErrorService.reportException(exception);
|
||||
$delegate(exception, cause);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
app.controller('HeaderController', function(SelectionStateModel, OSOpenExternalService) {
|
||||
|
||||
/**
|
||||
* @summary Open help page
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This application will open either the image's support url, declared
|
||||
* in the archive `manifest.json`, or the default Etcher help page.
|
||||
*
|
||||
* @example
|
||||
* HeaderController.openHelpPage();
|
||||
*/
|
||||
this.openHelpPage = () => {
|
||||
const DEFAULT_SUPPORT_URL = 'https://github.com/resin-io/etcher/blob/master/SUPPORT.md';
|
||||
const supportUrl = SelectionStateModel.getImageSupportUrl() || DEFAULT_SUPPORT_URL;
|
||||
OSOpenExternalService.open(supportUrl);
|
||||
};
|
||||
|
||||
});
|
@ -1,238 +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(),
|
||||
});
|
||||
|
||||
const applicationSessionUuid = store.getState().toJS().applicationSessionUuid;
|
||||
const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid;
|
||||
|
||||
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 currentVersion = packageJSON.version;
|
||||
|
||||
analytics.logEvent('Application start', {
|
||||
packageType: packageJSON.packageType,
|
||||
version: currentVersion,
|
||||
});
|
||||
|
||||
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) {
|
||||
analytics.logEvent('Close application', {
|
||||
isFlashing: flashState.isFlashing(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't close window while flashing
|
||||
event.returnValue = false;
|
||||
|
||||
// Don't open any more popups
|
||||
popupExists = true;
|
||||
|
||||
analytics.logEvent('Close attempt while flashing');
|
||||
|
||||
try {
|
||||
const confirmed = await osDialog.showWarning({
|
||||
confirmationLabel: i18next.t('yesExit'),
|
||||
rejectionLabel: i18next.t('cancel'),
|
||||
title: i18next.t('reallyExit'),
|
||||
description: messages.warning.exitWhileFlashing(),
|
||||
});
|
||||
if (confirmed) {
|
||||
analytics.logEvent('Close confirmed while flashing', {
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
});
|
||||
|
||||
// This circumvents the 'beforeunload' event unlike
|
||||
// remote.app.quit() which does not.
|
||||
remote.process.exit(EXIT_CODES.SUCCESS);
|
||||
}
|
||||
|
||||
analytics.logEvent('Close rejected while flashing', {
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
});
|
||||
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
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
@ -1,572 +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 { logEvent, 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) {
|
||||
logEvent('Open driver link modal', {
|
||||
url: 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
@ -1,125 +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 * as React from 'react';
|
||||
import { Flex } from 'rendition';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
|
||||
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 analytics from '../../modules/analytics';
|
||||
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';
|
||||
|
||||
function restart(goToMain: () => void) {
|
||||
selectionState.deselectAllDrives();
|
||||
analytics.logEvent('Restart');
|
||||
|
||||
// Reset the flashing workflow uuid
|
||||
store.dispatch({
|
||||
type: Actions.SET_FLASHING_WORKFLOW_UUID,
|
||||
data: uuidV4(),
|
||||
});
|
||||
|
||||
goToMain();
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
} = 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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default FinishPage;
|
@ -1,244 +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 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 React from 'react';
|
||||
import type { FlexProps, TableColumn } from 'rendition';
|
||||
import { Flex, Link, Txt } from 'rendition';
|
||||
import styled 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 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 = '',
|
||||
errors,
|
||||
results,
|
||||
skip,
|
||||
...props
|
||||
}: {
|
||||
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,
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
@ -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} </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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,211 +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';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
|
||||
/**
|
||||
* @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;
|
||||
const { webContents, ...webviewEvent } = event;
|
||||
analytics.logEvent('SafeWebview loaded', {
|
||||
...webviewEvent,
|
||||
});
|
||||
this.setState({
|
||||
shouldShow: event.statusCode === HTTP_OK,
|
||||
});
|
||||
if (this.props.onWebviewShow) {
|
||||
this.props.onWebviewShow(event.statusCode === HTTP_OK);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,158 +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 GithubSvg from '@fortawesome/fontawesome-free/svgs/brands/github.svg';
|
||||
import * as _ from 'lodash';
|
||||
import * as React from 'react';
|
||||
import { Box, Checkbox, Flex, Txt } from 'rendition';
|
||||
|
||||
import { version, packageType } from '../../../../../package.json';
|
||||
import * as settings from '../../models/settings';
|
||||
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';
|
||||
|
||||
interface Setting {
|
||||
name: string;
|
||||
label: string | JSX.Element;
|
||||
}
|
||||
|
||||
async function getSettingsList(): Promise<Setting[]> {
|
||||
const list: Setting[] = [
|
||||
{
|
||||
name: 'errorReporting',
|
||||
label: i18next.t('settings.errorReporting'),
|
||||
},
|
||||
{
|
||||
name: 'autoBlockmapping',
|
||||
label: i18next.t('settings.trimExtPartitions'),
|
||||
},
|
||||
];
|
||||
if (['appimage', 'nsis', 'dmg'].includes(packageType)) {
|
||||
list.push({
|
||||
name: 'updatesEnabled',
|
||||
label: i18next.t('settings.autoUpdate'),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
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];
|
||||
analytics.logEvent('Toggle setting', { setting, value });
|
||||
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>
|
||||
);
|
||||
}
|
@ -1,814 +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() {
|
||||
analytics.logEvent('Reselect image', {
|
||||
previousImage: selectionState.getImage(),
|
||||
});
|
||||
|
||||
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)) {
|
||||
analytics.logEvent('Possibly Windows image', { image: 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) {
|
||||
analytics.logEvent('Missing partition table', { metadata });
|
||||
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) {
|
||||
analytics.logEvent('Missing partition table', { selected });
|
||||
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);
|
||||
analytics.logEvent('Select 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: {
|
||||
...metadata,
|
||||
logo: Boolean(metadata.logo),
|
||||
blockMap: Boolean(metadata.blockMap),
|
||||
},
|
||||
});
|
||||
}
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
analytics.logEvent(title, { path: sourcePath });
|
||||
}
|
||||
|
||||
private async openImageSelector() {
|
||||
analytics.logEvent('Open image selector');
|
||||
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) {
|
||||
analytics.logEvent('Image selector closed');
|
||||
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() {
|
||||
analytics.logEvent('Open image URL selector');
|
||||
|
||||
this.setState({
|
||||
showURLSelector: true,
|
||||
});
|
||||
}
|
||||
|
||||
private openDriveSelector() {
|
||||
analytics.logEvent('Open drive selector');
|
||||
|
||||
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() {
|
||||
analytics.logEvent('Show selected image tooltip', {
|
||||
imagePath: selectionState.getImage()?.path,
|
||||
});
|
||||
|
||||
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) {
|
||||
analytics.logEvent('URL selector closed');
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -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} />;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,195 +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 {
|
||||
isDriveSelected,
|
||||
getImage,
|
||||
getSelectedDrives,
|
||||
deselectDrive,
|
||||
selectDrive,
|
||||
deselectAllDrives,
|
||||
} from '../../models/selection-state';
|
||||
import { observe } from '../../models/store';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
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) => {
|
||||
analytics.logEvent('Toggle drive', {
|
||||
drive,
|
||||
previouslySelected: true,
|
||||
});
|
||||
deselectDrive(drive.device);
|
||||
});
|
||||
// select drives
|
||||
modalTargets.forEach((drive) => {
|
||||
// Don't send events for drives that were already selected
|
||||
if (!isDriveSelected(drive.device)) {
|
||||
analytics.logEvent('Toggle drive', {
|
||||
drive,
|
||||
previouslySelected: false,
|
||||
});
|
||||
}
|
||||
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={() => {
|
||||
analytics.logEvent('Reselect drive');
|
||||
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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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.
|
@ -1,162 +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 and usage statistics 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;
|
@ -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;
|
@ -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;
|
@ -1,10 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>balenaEtcher</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="main"></main>
|
||||
</body>
|
||||
</html>
|
@ -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;
|
||||
}
|
@ -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 }));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -1,232 +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 type { Client } from 'analytics-client';
|
||||
import { createClient, createNoopClient } from 'analytics-client';
|
||||
import * as SentryRenderer from '@sentry/electron/renderer';
|
||||
import * as settings from '../models/settings';
|
||||
import { store } from '../models/store';
|
||||
import { version } from '../../../../package.json';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
let analyticsClient: Client;
|
||||
/**
|
||||
* @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',
|
||||
});
|
||||
|
||||
const projectName =
|
||||
settings.getSync('analyticsAmplitudeToken') || process.env.AMPLITUDE_TOKEN;
|
||||
|
||||
const clientConfig = {
|
||||
projectName,
|
||||
endpoint: 'data.balena-cloud.com',
|
||||
componentName: 'etcher',
|
||||
componentVersion: version,
|
||||
};
|
||||
analyticsClient = projectName
|
||||
? createClient(clientConfig)
|
||||
: createNoopClient();
|
||||
});
|
||||
|
||||
const getCircularReplacer = () => {
|
||||
const seen = new WeakSet();
|
||||
return (_key: any, value: any) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
};
|
||||
|
||||
function flattenObject(obj: any) {
|
||||
const toReturn: AnalyticsPayload = {};
|
||||
|
||||
for (const i in obj) {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj[i])) {
|
||||
toReturn[i] = obj[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof obj[i] === 'object' && obj[i] !== null) {
|
||||
const flatObject = flattenObject(obj[i]);
|
||||
for (const x in flatObject) {
|
||||
if (!Object.prototype.hasOwnProperty.call(flatObject, x)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toReturn[i.toLowerCase() + '.' + x.toLowerCase()] = flatObject[x];
|
||||
}
|
||||
} else {
|
||||
toReturn[i] = obj[i];
|
||||
}
|
||||
}
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
function formatEvent(data: any): AnalyticsPayload {
|
||||
const event = JSON.parse(JSON.stringify(data, getCircularReplacer()));
|
||||
return anonymizeAnalyticsPayload(flattenObject(event));
|
||||
}
|
||||
|
||||
function reportAnalytics(message: string, data: AnalyticsPayload = {}) {
|
||||
const { applicationSessionUuid, flashingWorkflowUuid } = store
|
||||
.getState()
|
||||
.toJS();
|
||||
|
||||
const event = formatEvent({
|
||||
...data,
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
});
|
||||
analyticsClient.track(message, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Log an event
|
||||
*
|
||||
* @description
|
||||
* This function sends the debug message to product analytics services.
|
||||
*/
|
||||
export async function logEvent(message: string, data: AnalyticsPayload = {}) {
|
||||
const shouldReportAnalytics = await settings.get('errorReporting');
|
||||
if (shouldReportAnalytics) {
|
||||
initAnalytics();
|
||||
reportAnalytics(message, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
@ -1,251 +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
|
||||
// TOOD: 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(
|
||||
`Spawning ${
|
||||
withPrivileges ? 'priviledged' : 'unpriviledged'
|
||||
} 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('Spwaning the child process was cancelled');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error spawning child process', error);
|
||||
throw new Error('Error spawning the child 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(
|
||||
`Retrying to connect to child process in ${connectionRetryDelay}... ${retry} / ${connectionRetryAttempts}`,
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, connectionRetryDelay),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return { failed, emit, registerHandler };
|
||||
}
|
||||
throw new Error('Connection to etcher-util timed out');
|
||||
} catch (error) {
|
||||
console.error('Error connecting to child process', error);
|
||||
throw new Error('Connection to etcher-util failed');
|
||||
}
|
||||
}
|
||||
|
||||
export { spawnChildAndConnect };
|
@ -1,278 +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 type { Drive as DrivelistDrive } from 'drivelist';
|
||||
import type * as sdk from 'etcher-sdk';
|
||||
import type { Dictionary } from 'lodash';
|
||||
import * as errors from '../../../shared/errors';
|
||||
import type { SourceMetadata } from '../../../shared/typings/source-selector';
|
||||
import * as flashState from '../models/flash-state';
|
||||
import * as selectionState from '../models/selection-state';
|
||||
import * as settings from '../models/settings';
|
||||
import * as analytics from '../modules/analytics';
|
||||
import * as windowProgress from '../os/window-progress';
|
||||
import { spawnChildAndConnect } from './api';
|
||||
|
||||
/**
|
||||
* @summary Handle a flash error and log it to analytics
|
||||
*/
|
||||
function handleErrorLogging(
|
||||
error: Error & { code: string },
|
||||
analyticsData: any,
|
||||
) {
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
};
|
||||
|
||||
if (error.code === 'EVALIDATION') {
|
||||
analytics.logEvent('Validation error', eventData);
|
||||
} else if (error.code === 'EUNPLUGGED') {
|
||||
analytics.logEvent('Drive unplugged', eventData);
|
||||
} else if (error.code === 'EIO') {
|
||||
analytics.logEvent('Input/output error', eventData);
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
analytics.logEvent('Out of space', eventData);
|
||||
} else if (error.code === 'ECHILDDIED') {
|
||||
analytics.logEvent('Child died unexpectedly', eventData);
|
||||
} else {
|
||||
analytics.logEvent('Flash error', {
|
||||
...eventData,
|
||||
error: errors.toJSON(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let cancelEmitter: (type: string) => void | undefined;
|
||||
|
||||
interface FlashResults {
|
||||
skip?: boolean;
|
||||
cancelled?: boolean;
|
||||
results?: {
|
||||
bytesWritten: number;
|
||||
devices: {
|
||||
failed: number;
|
||||
successful: number;
|
||||
};
|
||||
errors: Error[];
|
||||
};
|
||||
}
|
||||
|
||||
async function performWrite(
|
||||
image: SourceMetadata,
|
||||
drives: DrivelistDrive[],
|
||||
onProgress: sdk.multiWrite.OnProgressFunction,
|
||||
): Promise<{ cancelled?: boolean }> {
|
||||
const { autoBlockmapping, decompressFirst } = await settings.getAll();
|
||||
|
||||
// Spawn the child process with privileges and wait for the connection to be made
|
||||
const { emit, registerHandler } = await spawnChildAndConnect({
|
||||
withPrivileges: true,
|
||||
});
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
// if the connection failed, reject the promise
|
||||
|
||||
const flashResults: FlashResults = {};
|
||||
|
||||
const analyticsData = {
|
||||
image,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
};
|
||||
|
||||
const onFail = ({ device, error }: { device: any; error: any }) => {
|
||||
console.log('fail event');
|
||||
console.log(device);
|
||||
console.log(error);
|
||||
if (device.devicePath) {
|
||||
flashState.addFailedDeviceError({ device, error });
|
||||
}
|
||||
handleErrorLogging(error, analyticsData);
|
||||
finish();
|
||||
};
|
||||
|
||||
const onDone = (payload: any) => {
|
||||
console.log('CHILD: flash done', payload);
|
||||
payload.results.errors = payload.results.errors.map(
|
||||
(data: Dictionary<any> & { message: string }) => {
|
||||
return errors.fromJSON(data);
|
||||
},
|
||||
);
|
||||
flashResults.results = payload.results;
|
||||
finish();
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
console.log('CHILD: flash aborted');
|
||||
flashResults.cancelled = true;
|
||||
finish();
|
||||
};
|
||||
|
||||
const onSkip = () => {
|
||||
console.log('CHILD: validation skipped');
|
||||
flashResults.skip = true;
|
||||
finish();
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
console.log('Flash results', flashResults);
|
||||
|
||||
// The flash wasn't cancelled and we didn't get a 'done' event
|
||||
// Catch unexpected situation
|
||||
if (
|
||||
!flashResults.cancelled &&
|
||||
!flashResults.skip &&
|
||||
flashResults.results === undefined
|
||||
) {
|
||||
console.log(flashResults);
|
||||
reject(
|
||||
errors.createUserError({
|
||||
title: 'The writer process ended unexpectedly',
|
||||
description:
|
||||
'Please try again, and contact the Etcher team if the problem persists',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
resolve(flashResults);
|
||||
};
|
||||
|
||||
registerHandler('state', onProgress);
|
||||
registerHandler('fail', onFail);
|
||||
registerHandler('done', onDone);
|
||||
registerHandler('abort', onAbort);
|
||||
registerHandler('skip', onSkip);
|
||||
|
||||
cancelEmitter = (cancelStatus: string) => emit('cancel', cancelStatus);
|
||||
|
||||
// Now that we know we're connected we can instruct the child process to start the write
|
||||
const parameters = {
|
||||
image,
|
||||
destinations: drives,
|
||||
SourceType: image.SourceType,
|
||||
autoBlockmapping,
|
||||
decompressFirst,
|
||||
};
|
||||
console.log('params', parameters);
|
||||
emit('write', parameters);
|
||||
});
|
||||
|
||||
// The process continue in the event handler
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Flash an image to drives
|
||||
*/
|
||||
export async function flash(
|
||||
image: SourceMetadata,
|
||||
drives: DrivelistDrive[],
|
||||
// This function is a parameter so it can be mocked in tests
|
||||
write = performWrite,
|
||||
): Promise<void> {
|
||||
if (flashState.isFlashing()) {
|
||||
throw new Error('There is already a flash in progress');
|
||||
}
|
||||
|
||||
await flashState.setFlashingFlag();
|
||||
|
||||
flashState.setDevicePaths(
|
||||
drives.map((d) => d.devicePath).filter((p) => p != null) as string[],
|
||||
);
|
||||
|
||||
const analyticsData = {
|
||||
image,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
status: 'started',
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
};
|
||||
|
||||
analytics.logEvent('Flash', analyticsData);
|
||||
|
||||
// start api and call the flasher
|
||||
try {
|
||||
const result = await write(image, drives, flashState.setProgressState);
|
||||
console.log('got results', result);
|
||||
await flashState.unsetFlashingFlag(result);
|
||||
console.log('removed flashing flag');
|
||||
} catch (error: any) {
|
||||
await flashState.unsetFlashingFlag({
|
||||
cancelled: false,
|
||||
errorCode: error.code,
|
||||
});
|
||||
|
||||
windowProgress.clear();
|
||||
|
||||
const { results = {} } = flashState.getFlashResults();
|
||||
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
errors: results.errors,
|
||||
devices: results.devices,
|
||||
status: 'failed',
|
||||
error,
|
||||
};
|
||||
analytics.logEvent('Write failed', eventData);
|
||||
throw error;
|
||||
}
|
||||
|
||||
windowProgress.clear();
|
||||
|
||||
if (flashState.wasLastFlashCancelled()) {
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
status: 'cancel',
|
||||
};
|
||||
analytics.logEvent('Elevation cancelled', eventData);
|
||||
} else {
|
||||
const { results = {} } = flashState.getFlashResults();
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
errors: results.errors,
|
||||
devices: results.devices,
|
||||
status: 'finished',
|
||||
bytesWritten: results.bytesWritten,
|
||||
sourceMetadata: results.sourceMetadata,
|
||||
};
|
||||
analytics.logEvent('Done', eventData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Cancel write operation
|
||||
* //TODO: find a better solution to handle cancellation
|
||||
*/
|
||||
export async function cancel(type: string) {
|
||||
const status = type.toLowerCase();
|
||||
const drives = selectionState.getSelectedDevices();
|
||||
const analyticsData = {
|
||||
image: selectionState.getImage()?.path,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
status,
|
||||
};
|
||||
analytics.logEvent('Cancel', analyticsData);
|
||||
|
||||
if (cancelEmitter) {
|
||||
cancelEmitter(status);
|
||||
}
|
||||
}
|