Compare commits

..

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

115 changed files with 39398 additions and 32260 deletions

View File

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

454
.eslintrc.yml Normal file
View File

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

4
.gitattributes vendored
View File

@ -62,3 +62,7 @@ CODEOWNERS text
*.ttf binary diff=hex *.ttf binary diff=hex
xz-without-extension binary diff=hex xz-without-extension binary diff=hex
wmic-output.txt binary diff=hex wmic-output.txt binary diff=hex
# gitsecret
*.secret binary
.gitsecret/** binary

View File

@ -3,36 +3,36 @@ name: package and publish GitHub (draft) release
# https://github.com/product-os/flowzone/tree/master/.github/actions # https://github.com/product-os/flowzone/tree/master/.github/actions
inputs: inputs:
json: json:
description: 'JSON stringified object containing all the inputs from the calling workflow' description: "JSON stringified object containing all the inputs from the calling workflow"
required: true required: true
secrets: secrets:
description: 'JSON stringified object containing all the secrets from the calling workflow' description: "JSON stringified object containing all the secrets from the calling workflow"
required: true required: true
# --- custom environment # --- custom environment
XCODE_APP_LOADER_EMAIL:
type: string
default: "accounts+apple@balena.io"
NODE_VERSION: NODE_VERSION:
type: string type: string
# Beware that native modules will be built for this version, default: "16.x"
# 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: VERBOSE:
type: string type: string
default: 'true' default: "true"
runs: runs:
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action # https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
using: 'composite' using: "composite"
steps: steps:
- name: Download custom source artifact - name: Download custom source artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}-${{ runner.arch }} name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}
path: ${{ runner.temp }} path: ${{ runner.temp }}
- name: Extract custom source artifact - name: Extract custom source artifact
if: runner.os != 'Windows' if: runner.os != 'Windows'
shell: bash shell: pwsh
working-directory: . working-directory: .
run: tar -xf ${{ runner.temp }}/custom.tgz run: tar -xf ${{ runner.temp }}/custom.tgz
@ -48,158 +48,122 @@ runs:
node-version: ${{ inputs.NODE_VERSION }} node-version: ${{ inputs.NODE_VERSION }}
cache: npm cache: npm
- name: Install host dependencies - name: Install yq
if: runner.os == 'Linux' shell: bash --noprofile --norc -eo pipefail -x {0}
shell: bash run: choco install yq
run: sudo apt-get install -y --no-install-recommends fakeroot dpkg rpm if: runner.os == 'Windows'
# 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://www.electron.build/code-signing.html
# https://dev.to/rwwagner90/signing-electron-apps-with-github-actions-4cof # https://github.com/Apple-Actions/import-codesign-certs
- name: Import Apple code signing certificate - name: Import Apple code signing certificate
if: runner.os == 'macOS' if: runner.os == 'macOS'
shell: bash uses: apple-actions/import-codesign-certs@v1
run: | with:
KEY_CHAIN=build.keychain p12-file-base64: ${{ fromJSON(inputs.secrets).APPLE_SIGNING }}
CERTIFICATE_P12=certificate.p12 p12-password: ${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}
# 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 - name: Import Windows code signing certificate
if: runner.os == 'Windows' if: runner.os == 'Windows'
id: import_win_signing_cert
shell: powershell shell: powershell
run: | run: |
Set-Content -Path ${{ runner.temp }}/certificate.base64 -Value $env:SM_CLIENT_CERT_FILE_B64 Set-Content -Path ${{ runner.temp }}/certificate.base64 -Value $env:WINDOWS_CERTIFICATE
certutil -decode ${{ runner.temp }}/certificate.base64 ${{ runner.temp }}/Certificate_pkcs12.p12 certutil -decode ${{ runner.temp }}/certificate.base64 ${{ runner.temp }}/certificate.pfx
Remove-Item -path ${{ runner.temp }} -include certificate.base64 Remove-Item -path ${{ runner.temp }} -include certificate.base64
echo "certFilePath=${{ runner.temp }}/Certificate_pkcs12.p12" >> $GITHUB_OUTPUT Import-PfxCertificate `
-FilePath ${{ runner.temp }}/certificate.pfx `
-CertStoreLocation Cert:\CurrentUser\My `
-Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -Force -AsPlainText)
Remove-Item -path ${{ runner.temp }} -include certificate.pfx
env: env:
SM_CLIENT_CERT_FILE_B64: ${{ fromJSON(inputs.secrets).SM_CLIENT_CERT_FILE_B64 }} WINDOWS_CERTIFICATE: ${{ fromJSON(inputs.secrets).WINDOWS_SIGNING }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ fromJSON(inputs.secrets).WINDOWS_SIGNING_PASSWORD }}
# ... or refactor (e.g.) https://github.com/samuelmeuli/action-electron-builder
# https://github.com/product-os/scripts/tree/master/electron
# https://github.com/product-os/scripts/tree/master/shared
# https://github.com/product-os/balena-concourse/blob/master/pipelines/github-events/template.yml
- name: Package release - name: Package release
shell: bash id: package_release
# IMPORTANT: before making changes to this step please consult @engineering in balena's chat. shell: bash --noprofile --norc -eo pipefail -x {0}
run: | run: |
## FIXME: causes issues with `xxhash` which tries to load a debug build which doens't exist and cannot be compiled set -ea
# if [[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]]; then
# export DEBUG='electron-forge:*,sidecar'
# fi
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
runner_os="$(echo "${RUNNER_OS}" | tr '[:upper:]' '[:lower:]')"
runner_arch="$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')"
ELECTRON_BUILDER_ARCHITECTURE="${runner_arch}"
APPLICATION_VERSION="$(jq -r '.version' package.json)" APPLICATION_VERSION="$(jq -r '.version' package.json)"
HOST_ARCH="$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')" ARCHITECTURE_FLAGS="--${ELECTRON_BUILDER_ARCHITECTURE}"
if [[ "${RUNNER_OS}" == Linux ]]; then if [[ $runner_os =~ linux ]]; then
PLATFORM=Linux ELECTRON_BUILDER_OS='--linux'
SHA256SUM_BIN=sha256sum TARGETS="$(yq e .linux.target[] electron-builder.yml)"
elif [[ "${RUNNER_OS}" == macOS ]]; then elif [[ $runner_os =~ darwin|macos|osx ]]; then
PLATFORM=Darwin CSC_KEY_PASSWORD=${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}
SHA256SUM_BIN='shasum -a 256' CSC_KEYCHAIN=signing_temp
CSC_LINK=${{ fromJSON(inputs.secrets).APPLE_SIGNING }}
ELECTRON_BUILDER_OS='--mac'
TARGETS="$(yq e .mac.target[] electron-builder.yml)"
elif [[ "${RUNNER_OS}" == Windows ]]; then elif [[ $runner_os =~ windows|win ]]; then
PLATFORM=Windows ARCHITECTURE_FLAGS="--ia32 ${ARCHITECTURE_FLAGS}"
SHA256SUM_BIN=sha256sum CSC_KEY_PASSWORD=${{ fromJSON(inputs.secrets).WINDOWS_SIGNING_PASSWORD }}
CSC_LINK=${{ fromJSON(inputs.secrets).WINDOWS_SIGNING }}
# Install DigiCert Signing Manager Tools ELECTRON_BUILDER_OS='--win'
curl --silent --retry 3 --fail https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download \ TARGETS="$(yq e .win.target[] electron-builder.yml)"
-H "x-api-key:$SM_API_KEY" \
-o smtools-windows-x64.msi
msiexec -i smtools-windows-x64.msi -qn
PATH="/c/Program Files/DigiCert/DigiCert One Signing Manager Tools:${PATH}"
smksp_registrar.exe list
smctl.exe keypair ls
smctl.exe windows certsync
/c/Windows/System32/certutil.exe -csp "DigiCert Signing Manager KSP" -key -user
# (signtool.exe) https://github.com/actions/runner-images/blob/main/images/win/Windows2019-Readme.md#installed-windows-sdks
PATH="/c/Program Files (x86)/Windows Kits/10/bin/${runner_arch}:${PATH}"
else else
echo "ERROR: unexpected runner OS: ${RUNNER_OS}" exit 1
exit 1
fi fi
# Currently, we can only build for the host architecture. npm link electron-builder
npx electron-forge make
for target in ${TARGETS}; do
electron-builder ${ELECTRON_BUILDER_OS} ${target} ${ARCHITECTURE_FLAGS} \
--c.extraMetadata.analytics.sentry.token='https://739bbcfc0ba4481481138d3fc831136d@o95242.ingest.sentry.io/4504451487301632' \
--c.extraMetadata.analytics.amplitude.token='balena-etcher' \
--c.extraMetadata.packageType="${target}"
find dist -type f -maxdepth 1
done
echo "version=${APPLICATION_VERSION}" >> $GITHUB_OUTPUT 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: env:
# ensure we sign the artifacts # Apple notarization (afterSignHook.js)
NODE_ENV: production XCODE_APP_LOADER_EMAIL: ${{ inputs.XCODE_APP_LOADER_EMAIL }}
# 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_PASSWORD: ${{ fromJSON(inputs.secrets).XCODE_APP_LOADER_PASSWORD }}
XCODE_APP_LOADER_TEAM_ID: ${{ fromJSON(inputs.secrets).XCODE_APP_LOADER_TEAM_ID }} # https://github.blog/2020-08-03-github-actions-improvements-for-fork-and-pull-request-workflows/#improvements-for-public-repository-forks
# Windows signing # https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
SM_CLIENT_CERT_PASSWORD: ${{ fromJSON(inputs.secrets).SM_CLIENT_CERT_PASSWORD }} CSC_FOR_PULL_REQUEST: true
SM_CLIENT_CERT_FILE: '${{ runner.temp }}\Certificate_pkcs12.p12'
SM_HOST: ${{ fromJSON(inputs.secrets).SM_HOST }} # https://www.electron.build/auto-update.html#staged-rollouts
SM_API_KEY: ${{ fromJSON(inputs.secrets).SM_API_KEY }} - name: Configure staged rollout(s)
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ fromJSON(inputs.secrets).SM_CODE_SIGNING_CERT_SHA1_HASH }} shell: bash --noprofile --norc -eo pipefail -x {0}
TIMESTAMP_SERVER: http://timestamp.digicert.com run: |
set -ea
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
percentage="$(cat < repo.yml | yq e .triggerNotification.stagingPercentage)"
find dist -type f -maxdepth 1 \
-name "latest*.yml" \
-exec yq -i e .version=\"${{ steps.package_release.outputs.version }}\" {} \;
find dist -type f -maxdepth 1 \
-name "latest*.yml" \
-exec yq -i e .stagingPercentage=\"$percentage\" {} \;
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: gh-release-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}-${{ runner.arch }} name: gh-release-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}
path: dist path: dist
retention-days: 1 retention-days: 1
if-no-files-found: error

View File

@ -3,23 +3,23 @@ name: test release
# https://github.com/product-os/flowzone/tree/master/.github/actions # https://github.com/product-os/flowzone/tree/master/.github/actions
inputs: inputs:
json: json:
description: 'JSON stringified object containing all the inputs from the calling workflow' description: "JSON stringified object containing all the inputs from the calling workflow"
required: true required: true
secrets: secrets:
description: 'JSON stringified object containing all the secrets from the calling workflow' description: "JSON stringified object containing all the secrets from the calling workflow"
required: true required: true
# --- custom environment # --- custom environment
NODE_VERSION: NODE_VERSION:
type: string type: string
default: '20.19' default: "16.x"
VERBOSE: VERBOSE:
type: string type: string
default: 'true' default: "true"
runs: runs:
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action # https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
using: 'composite' using: "composite"
steps: steps:
# https://github.com/actions/setup-node#caching-global-packages-data # https://github.com/actions/setup-node#caching-global-packages-data
- name: Setup Node.js - name: Setup Node.js
@ -28,50 +28,27 @@ runs:
node-version: ${{ inputs.NODE_VERSION }} node-version: ${{ inputs.NODE_VERSION }}
cache: npm 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 - name: Test release
shell: bash shell: bash --noprofile --norc -eo pipefail -x {0}
run: | run: |
## FIXME: causes issues with `xxhash` which tries to load a debug build which doens't exist and cannot be compiled set -ea
# if [[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]]; then
# export DEBUG='electron-forge:*,sidecar'
# fi
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
runner_os="$(echo "${RUNNER_OS}" | tr '[:upper:]' '[:lower:]')"
npm run flowzone-preinstall-${runner_os}
npm ci npm ci
npm run build
# as the shrinkwrap might have been done on mac/linux, this is ensure the package is there for windows npm run test-${runner_os}
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: env:
# https://www.electronjs.org/docs/latest/api/environment-variables # https://www.electronjs.org/docs/latest/api/environment-variables
ELECTRON_NO_ATTACH_CONSOLE: 'true' ELECTRON_NO_ATTACH_CONSOLE: true
- name: Compress custom source - name: Compress custom source
if: runner.os != 'Windows' if: runner.os != 'Windows'
shell: bash shell: pwsh
run: tar -acf ${{ runner.temp }}/custom.tgz . run: tar -acf ${{ runner.temp }}/custom.tgz .
- name: Compress custom source - name: Compress custom source
@ -80,8 +57,8 @@ runs:
run: C:\"Program Files"\Git\usr\bin\tar.exe --force-local -acf ${{ runner.temp }}\custom.tgz . run: C:\"Program Files"\Git\usr\bin\tar.exe --force-local -acf ${{ runner.temp }}\custom.tgz .
- name: Upload custom artifact - name: Upload custom artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}-${{ runner.arch }} name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}
path: ${{ runner.temp }}/custom.tgz path: ${{ runner.temp }}/custom.tgz
retention-days: 1 retention-days: 1

View File

@ -1,4 +1,5 @@
name: Flowzone name: Flowzone
on: on:
pull_request: pull_request:
types: [opened, synchronize, closed] types: [opened, synchronize, closed]
@ -7,6 +8,7 @@ on:
pull_request_target: pull_request_target:
types: [opened, synchronize, closed] types: [opened, synchronize, closed]
branches: [main, master] branches: [main, master]
jobs: jobs:
flowzone: flowzone:
name: Flowzone name: Flowzone
@ -18,24 +20,11 @@ jobs:
(github.event.pull_request.head.repo.full_name != github.repository && github.event_name == 'pull_request_target') (github.event.pull_request.head.repo.full_name != github.repository && github.event_name == 'pull_request_target')
secrets: inherit secrets: inherit
with: with:
custom_test_matrix: > tests_run_on: '["ubuntu-20.04","macos-latest","windows-2019"]'
{
"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 restrict_custom_actions: false
github_prerelease: true github_prerelease: true
repo_config: true
repo_description: "Flash OS images to SD cards & USB drives, safely and easily."
repo_homepage: https://etcher.io/
repo_enable_wiki: true
cloudflare_website: "etcher" cloudflare_website: "etcher"

View File

@ -6,9 +6,8 @@ jobs:
publish: publish:
runs-on: windows-latest # action can only be run on windows runs-on: windows-latest # action can only be run on windows
steps: steps:
- uses: vedantmgoyal2009/winget-releaser@v2 - uses: vedantmgoyal2009/winget-releaser@v1
with: with:
identifier: Balena.Etcher identifier: Balena.Etcher
# matches something like "balenaEtcher-1.19.0.Setup.exe" installers-regex: 'balenaEtcher-Setup.*.exe$'
installers-regex: 'balenaEtcher-[\d.-]+\.Setup.exe$'
token: ${{ secrets.WINGET_PAT }} token: ${{ secrets.WINGET_PAT }}

112
.gitignore vendored
View File

@ -1,103 +1,40 @@
# -- ADD NEW ENTRIES AT THE END OF THE FILE ---
# Logs # Logs
logs /logs
*.log *.log
npm-debug.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 # Runtime data
pids pids
*.pid *.pid
*.seed *.seed
*.pid.lock
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover # Directory for instrumented libs generated by jscoverage/JSCover
lib-cov /lib-cov
# Image stream output directory
/tests/image-stream/output
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage /coverage
*.lcov
# nyc test coverage # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.nyc_output .grunt
# node-waf configuration # node-waf configuration
.lock-wscript .lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html) # Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release /build
# Dependency directories # Generated files
node_modules/ /generated
jspm_packages/
# TypeScript v1 declaration files # Dependency directory
typings/ # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
node_modules
# TypeScript cache # Compiled Etcher releases
*.tsbuildinfo /dist
# 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/
# Certificates # Certificates
*.spc *.spc
@ -107,17 +44,16 @@ dist/
*.crt *.crt
*.pem *.pem
# Secrets # OSX files
.DS_Store
# VSCode files
.vscode
.gitsecret/keys/random_seed .gitsecret/keys/random_seed
!*.secret !*.secret
secrets/APPLE_SIGNING_PASSWORD.txt secrets/APPLE_SIGNING_PASSWORD.txt
secrets/WINDOWS_SIGNING_PASSWORD.txt secrets/WINDOWS_SIGNING_PASSWORD.txt
secrets/XCODE_APP_LOADER_PASSWORD.txt secrets/XCODE_APP_LOADER_PASSWORD.txt
secrets/WINDOWS_SIGNING.pfx secrets/WINDOWS_SIGNING.pfx
# Image stream output directory
/tests/image-stream/output
#local development
.yalc
yalc.lock

4
.gitmodules vendored Normal file
View File

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

BIN
.gitsecret/keys/pubring.kbx Normal file

Binary file not shown.

Binary file not shown.

BIN
.gitsecret/keys/trustdb.gpg Normal file

Binary file not shown.

View File

@ -0,0 +1,5 @@
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

2
.nvmrc
View File

@ -1 +1 @@
18 16

View File

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

View File

@ -1,573 +1,3 @@
- commits:
- subject: Remove stale secrets
hash: c2fc36971c9460eac6bd02cfc7bdcabec7b97a6d
body: ""
footer:
change-type: patch
author: Anton Belodedenko
nested: []
version: 2.1.3
title: ""
date: 2025-05-15T18:09:55.848Z
- commits:
- subject: "patch: remove analytics"
hash: aa6d526fea010d181f49dd81ae3bdaefb8d1938e
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 2.1.2
title: ""
date: 2025-05-08T08:51:44.810Z
- commits:
- subject: "patch: fix signin windows artifacts"
hash: a1e9be2f94629447e02994e52e12c67ec98de831
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 2.1.1
title: ""
date: 2025-05-05T17:19:50.443Z
- commits:
- subject: Add informational notice about how to disable analytics collection
hash: aac092fd4df8750024c082b25dcbd0ae6ee618fd
body: ""
footer:
Change-type: minor
change-type: minor
author: myarmolinsky
nested: []
version: 2.1.0
title: ""
date: 2025-02-27T16:16:57.036Z
- commits:
- subject: "major: build on ubuntu 22 and macos 13"
hash: 039a022353d1980ef9ddd19166515c531e48aba4
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 2.0.0
title: ""
date: 2025-02-20T14:27:01.338Z
- commits:
- subject: "patch: bump etcher-sdk to 9.1.2"
hash: c726b51dca3383c76f4bf824fd5d594ac3069180
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.25
title: ""
date: 2024-10-10T10:03:29.519Z
- commits:
- subject: "patch: etcher-util is corrupted in RPM package"
hash: e43ee788ec5ec49e105ff804206919bb10a59ea7
body: |
rpmbuild strips executables by default when generating an rpm packge.
This was causing the JavaScript code bundled in the etcher-util file
to be removed, causing "Pkg: Error reading from file." whenever
etcher-util was called.
This in turn caused balena-etcher to generate the error message
`Error: (0, h.requestMetadata) is not a function` when attempting
to write an SD card.
This fixes the issue for RPM builds by replacing the `strip` command
with `true` so that rpmbuild no longer strips the executables and
the embeded code stays intact.
See: https://github.com/balena-io/etcher/issues/4150
footer:
Signed-off-by: Richard Glidden <richard@glidden.org>
signed-off-by: Richard Glidden <richard@glidden.org>
author: Richard Glidden
nested: []
version: 1.19.24
title: ""
date: 2024-10-09T14:22:56.623Z
- commits:
- subject: "patch: remove gconf2 libgconf-2-4 deps"
hash: 2ed779ef371db367e4e413c9d0d08fcd738edb5b
body: "Closes #4096"
footer: {}
author: Marc-Aurèle Brothier
nested: []
version: 1.19.23
title: ""
date: 2024-10-09T13:52:54.936Z
- commits:
- subject: Replace deprecated Flowzone inputs
hash: 52d396aa7ea9ae1ef6d68151f582f04f57191b14
body: ""
footer:
Change-type: patch
change-type: patch
author: Kyle Harding
nested: []
version: 1.19.22
title: ""
date: 2024-07-18T18:12:56.368Z
- commits:
- subject: "patch: fix missing windows dependency"
hash: 8dad81ae34b8d71f3d4f7151ee60717e6207ccd8
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: fix missing windows dependency"
hash: d28719daf249f2994acdf94b4bb7ea937ffcab9b
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: fix missing windows dependency"
hash: 98db4df0dc147e5fec9180c50f4e21acf1fd0a58
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.21
title: ""
date: 2024-05-30T15:00:35.706Z
- commits:
- subject: "patch: fix missing windows dependency"
hash: c4d3f8db8769418925a9909ac700edc5f425a068
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.20
title: ""
date: 2024-05-30T10:17:29.075Z
- commits:
- subject: "patch: add sentry debug flag"
hash: 8223130e8dfce180481550d77f022064255601e4
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.19
title: ""
date: 2024-05-28T12:09:51.167Z
- commits:
- subject: "patch: fix Sentry DSN for main process"
hash: 4ffda6e208a6e2f109f652d39e1248bec23a2ddf
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.18
title: ""
date: 2024-05-22T13:28:03.659Z
- commits:
- subject: "patch: fix injection of analytics key at build time"
hash: e94767aca7b07e674bd60176ef77c11440131ace
body: ""
footer: {}
author: JOASSART Edwin
nested: []
version: 1.19.17
title: ""
date: 2024-05-09T06:33:45.091Z
- commits:
- subject: "patch: hold request for metadata while waiting for flasher"
hash: 2dfa795129e287f887b9ea02f2eca717575d27ac
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.16
title: ""
date: 2024-04-26T14:33:19.111Z
- commits:
- subject: "patch: bump etcher-sdk to 9.0.11 to fix url loading using http/2"
hash: cb03fb83754f38d647fc951b94470725b46b2b31
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.15
title: ""
date: 2024-04-26T13:26:57.047Z
- commits:
- subject: "patch: pretty-bytes to 6.1.1"
hash: fa642270f7153f14e45ee03a73bad1f0797cbd51
body: ""
footer: {}
author: JOASSART Edwin
nested: []
version: 1.19.14
title: ""
date: 2024-04-25T21:11:35.350Z
- commits:
- subject: "patch: use etcher icon as loading for windows installer"
hash: bc3340960a765e99f2f02bc21adace91d228d26f
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: fix windows squirrel install"
hash: d498248a0f1416045b836646b72c7b4c588119d3
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.13
title: ""
date: 2024-04-25T19:02:23.576Z
- commits:
- subject: "patch: bump minors & patch"
hash: afd659f9e586e012be7e3b02490d14a8ac64bb35
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: bump @electron-forge/* to 7.4.0"
hash: ffdeccf7efd1412a2e2838fd07df5b21f1233efe
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: bump electron to 30.0.1 & @electron/remote to 2.1.2"
hash: 37ac323e10c07db35a7e47b576d07e1d4d41a470
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: npm upgrade"
hash: 7c8f3c35d3d159e7be73442ab215019dc2388f54
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: bump @balena/lint to 8.0.2 and fix formating"
hash: 4aa4140d65189920938c42c41a6a781c97148c8a
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: fix pretty-bytes imports"
hash: 064261107954dd64d03f94d6aeffd95cd2211df0
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: bump etcher-sdk to 9.0.9"
hash: 2f4a12a48facf0634ed457fe6ed7c50e21b419ee
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.12
title: ""
date: 2024-04-25T16:47:43.024Z
- commits:
- subject: "patch: setup wdio and port (most) tests"
hash: a661d102bc94bf2707f01958d1e9d260efc06c14
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.11
title: ""
date: 2024-04-25T13:00:13.805Z
- commits:
- subject: "patch: remove node-ipc and tests"
hash: ccc31bb9aaba8df88b2af612824d9106051e2804
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: switch api; use ws; integrate sudo-prompt - switch api roles
flow - use websocket instead of node-ipc - integrate; modernize;
simplify and deprecate sudo-prompt"
hash: b3e33824ed1f70719b04f18dcb7f7dd76451b7f6
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: refactor api to use a single topic"
hash: 6582260355fcc5280932bee771602fbfb5190619
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: set require node engine to 20"
hash: b1d2bdaa06bfb35f4a66d92275ca21c731d1cf8e
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.10
title: ""
date: 2024-04-23T10:28:00.127Z
- commits:
- subject: "patch: prevent rebuild of native deps by @electron/rebuild"
hash: 003abfb88f2c7bff0ee291828f3815c738340afa
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.9
title: ""
date: 2024-04-22T10:20:10.534Z
- commits:
- subject: "patch: replace deprecated pkg with yao-pkg and bump etcher-util node v
to 20.10"
hash: c696c389c9988c75ad9ccc472bdac7edefe762ed
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.8
title: ""
date: 2024-04-22T09:37:37.561Z
- commits:
- subject: "patch: fix formating"
hash: 1a9a3d2cdc5642a754b73628f4ae2636e3ffd8eb
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: configure prettier in the project to use balena-lint
configuration"
hash: faeaa58ec548e47abaf30b2498ab145e7c0c6f76
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.7
title: ""
date: 2024-04-22T06:52:18.878Z
- commits:
- subject: "patch: fix win signature process"
hash: f629e6d53b5329cd7e8105050df042f3873a35ee
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.6
title: ""
date: 2024-04-19T15:59:28.200Z
- commits:
- subject: Replace deprecated flowzone input tests_run_on
hash: bec0e50741bfeda63ca9785217576613f74ca043
body: |
The `custom_runs_on` array supports multiple runner labels
in nested arrays.
footer:
Change-type: patch
change-type: patch
Signed-off-by: Kyle Harding <kyle@balena.io>
signed-off-by: Kyle Harding <kyle@balena.io>
author: Kyle Harding
nested: []
version: 1.19.5
title: ""
date: 2024-02-14T19:51:16.321Z
- commits:
- subject: "patch: remove screensaver error when not on etcher-pro"
hash: 196fd8ae24de2a23ebaeae736c6ca41007162fa1
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: fix typo in IPC server id"
hash: 5d436992423961258ad861c01e3b9b30f3317aab
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.19.4
title: ""
date: 2024-01-26T17:29:27.301Z
- commits:
- subject: Update dependencies
hash: 0f2b4dbc106c55fe104f0b10e62c35c16bcfe9b3
body: >
- upgrade pretty_bytes to 6.1.1
- upgrade electron-remote to 2.1.0
- upgrade semver to 7.5.4 + @types/semver to 7.5.6
- upgrade chai to 4.3.11 + @types/chai to 4.3.10
- upgrade mocha to 10.2.0 + @types/mocha to 10.0.6
- upgrade sinon to 17.0.1 + @types/sinon to 17.0.2
- remove useless @types
- upgrade @svgr/webpack to 8.1.0
- upgrade @sentry/electron to 4.15.1
- upgrade tslib to 2.6.2
- upgrade immutable to 4.3.4
- upgrade redux to 4.2.1
- upgrade ts-node to 10.9.2 & ts-loader to 9.5.1
- remove mini-css-extract-plugin
- upgrade husky to 8.0.3
- upgrade uuid to 9.0.1
- upgrade lint-staged to 15.2.1
- upgrade @types/node to 18.11.9
- upgrade @fortawesome/fontawesome-free to 6.5.1
- upgrade i18next to 23.7.8 & react-i18next to 11.18.6
- bump react, react-dom + related @types to 17.0.2 and rendition to
35.1.0
- fix getuid for ts
- fix @types/react being in wrong deps
- upgrade @types/tmp to 0.2.6
- upgrade typescript to 5.3.3
- upgrade @types/mime-types to 2.1.4
- remove d3 from deps
- upgrade electron-updater to 6.1.7
- upgrade rendition to 35.1.2
- upgrade node-ipc to 9.2.3
- upgrade @types/node-ipc to 9.2.3
- upgrade electron to 27.1.3
- upgrade @electron-forge/* to 7.2.0
- upgrade @reforged/marker-appimage to 3.3.2
- upgrade style-loader to 3.3.3
- upgrade balena-lint to 7.2.4
- run CI with node 18.19
- add xxhash-addon to sidecar assets
footer:
Change-type: patch
change-type: patch
author: Edwin Joassart
nested: []
version: 1.19.3
title: ""
date: 2023-12-22T16:13:00.924Z
- commits:
- subject: "fix: typos"
hash: aaac1336702b7ac4a07992f41db4f0bcdb931c70
body: ""
footer:
Change-type: patch
change-type: patch
author: Rotzbua
nested: []
version: 1.19.2
title: ""
date: 2023-12-22T12:57:35.441Z
- commits:
- subject: "patch: update winget-releaser v2"
hash: ea184eb6352b7988c6ab1f439d30c297610cd84e
body: ""
footer: {}
author: Vedant
nested: []
version: 1.19.1
title: ""
date: 2023-12-22T08:12:34.451Z
- commits:
- subject: Use native ARM runner for Apple Silicon builds
hash: 01a96bb6de1ff00d20f7784469dd05286069e014
body: ""
footer:
Change-type: minor
change-type: minor
author: Akis Kesoglou
nested: []
- subject: Calculate and upload build artifact sha256 checksums
hash: 2e3a75e685258961bc8efdb95dde12727b93a04a
body: ""
footer:
Change-type: minor
change-type: minor
author: Akis Kesoglou
nested: []
- subject: Migrate build pipeline to Electron Forge
hash: bd33c5b092cb5224c8dfc4d5a2caf4684cee161d
body: ""
footer:
Change-type: minor
change-type: minor
author: Akis Kesoglou
nested: []
version: 1.19.0
title: ""
date: 2023-12-21T16:41:57.426Z
- commits:
- subject: Remove repo config from flowzone.yml
hash: ecb24dad251fbb9b3f92e5b404b66aedd155a584
body: |
This functionality is being deprecated in Flowzone.
See: https://github.com/product-os/flowzone/pull/833
footer:
Change-type: patch
change-type: patch
Signed-off-by: Kyle Harding <kyle@balena.io>
signed-off-by: Kyle Harding <kyle@balena.io>
author: Kyle Harding
nested: []
- subject: Update actions/upload-artifact to v4
hash: a970f55b555f69c5fcb40374eb50ad7b98cc8f96
body: |
Also ensure we are generating unique artifact names on upload.
footer:
Change-type: patch
change-type: patch
Signed-off-by: Kyle Harding <kyle@balena.io>
signed-off-by: Kyle Harding <kyle@balena.io>
See: https://github.com/product-os/flowzone/pull/827
see: https://github.com/product-os/flowzone/pull/827
author: Kyle Harding
nested: []
version: 1.18.14
title: ""
date: 2023-12-20T16:23:00.875Z
- commits:
- subject: "patch: upgrade to electron 25"
hash: f38bca290fe26121bed58d1131265e1aa350ddb5
body: ""
footer: {}
author: Edwin Joassart
nested: []
- subject: "patch: refactor scanner, loader and flasher out of gui + upgrade to
electron 25"
hash: fb8ed5b529e22bc9e766bfe99c2b6955ed695b58
body: ""
footer: {}
author: Edwin Joassart
nested: []
version: 1.18.13
title: ""
date: 2023-10-16T13:32:26.738Z
- commits: - commits:
- subject: Update instructions for installing deb file - subject: Update instructions for installing deb file
hash: acab03ad77a1c1901d0c8a65999e93c1d27169a0 hash: acab03ad77a1c1901d0c8a65999e93c1d27169a0
@ -917,33 +347,20 @@
step forward to upgrading to a newer Electron and Node version. step forward to upgrading to a newer Electron and Node version.
Updates etcher-sdk and switches the redundant aws4-axios dependency to Updates etcher-sdk and switches the redundant aws4-axios dependency to just axios.
just axios.
Also changed bundler to stop trying to bundle wasm files — they must be Also changed bundler to stop trying to bundle wasm files — they must be included inline with JS code as data — and removed some now redundant code.
included inline with JS code as data — and removed some now redundant
code.
The crucial changes that enable support are: The crucial changes that enable support are:
1. The update to etcher-sdk@8 where some dependency fixes and updates 1. The update to etcher-sdk@8 where some dependency fixes and updates took place
took place
2. The downgrade and pinning of "electron-rebuild" to v3.2.3 until were 2. The downgrade and pinning of "electron-rebuild" to v3.2.3 until were able to update to Electron >= 14.2. The patch we need to avoid is https://github.com/electron/rebuild/pull/907. Also see: https://github.com/nodejs/node-gyp/issues/2673 and https://github.com/electron/rebuild/issues/913
able to update to Electron >= 14.2. The patch we need to avoid is
https://github.com/electron/rebuild/pull/907. Also see:
https://github.com/nodejs/node-gyp/issues/2673 and
https://github.com/electron/rebuild/issues/913
3. A rule in webpack.config to ignore `aws-crt` which is a dependency of 3. A rule in webpack.config to ignore `aws-crt` which is a dependency of (ultimately) `aws4-axios` which is used by etcher-sdk and does a runtime check to its availability. Were not currently using the “assume role” functionality (AFAIU) of aws4-axios and we dont care that its not found, so force webpack to ignore the import. See https://github.com/aws/aws-sdk-js-v3/issues/3025
(ultimately) `aws4-axios` which is used by etcher-sdk and does a runtime
check to its availability. Were not currently using the “assume role”
functionality (AFAIU) of aws4-axios and we dont care that its not
found, so force webpack to ignore the import. See
https://github.com/aws/aws-sdk-js-v3/issues/3025
footer: footer:
Change-type: minor Change-type: minor
change-type: minor change-type: minor
@ -1223,8 +640,7 @@
body: > body: >
Optimized several translations. Optimized several translations.
This commit itself is only a patch, but as a pull request must have at This commit itself is only a patch, but as a pull request must have at least one commit with a change-type.
least one commit with a change-type.
footer: footer:
Change-Type: minor Change-Type: minor
change-type: minor change-type: minor
@ -1307,8 +723,10 @@
nested: [] nested: []
- subject: "Patch: run linux build on ubuntu-20.04" - subject: "Patch: run linux build on ubuntu-20.04"
hash: adcd8e0325bc891460b3e51aa5403f8675189f13 hash: adcd8e0325bc891460b3e51aa5403f8675189f13
body: |- body: >-
as [`18.04` has been removed](https://github.blog/changelog/2022-08-09-github-actions-the-ubuntu-18-04-actions-runner-image-is-being-deprecated-and-will-be-removed-by-12-1-22/) as [`18.04` has been
removed](https://github.blog/changelog/2022-08-09-github-actions-the-ubuntu-18-04-actions-runner-image-is-being-deprecated-and-will-be-removed-by-12-1-22/)
We cannot use `latest` as the glibc version will cause issue with older ubuntu version. We cannot use `latest` as the glibc version will cause issue with older ubuntu version.
footer: {} footer: {}
@ -2841,8 +2259,7 @@
reloads without reloading the whole electron app. reloads without reloading the whole electron app.
This patch also runs the development environment in development mode, This patch also runs the development environment in development mode, which is much, much faster on builds and rebuilds.
which is much, much faster on builds and rebuilds.
footer: {} footer: {}
author: Zane Hitchcox author: Zane Hitchcox
nested: [] nested: []
@ -3247,11 +2664,9 @@
exception exception
aborting program, because WCharToUtf8() returned NULL aborting program, because WCharToUtf8() returned NULL
in some cases, and NULL was being fed to string constructor. in some cases, and NULL was being fed to string constructor.
- Fixes memory leak because memory allocated with - Fixes memory leak because memory allocated with calloc()
calloc()
in WCharToUtf8() was not being freed anywhere in WCharToUtf8() was not being freed anywhere
- Fixes undefined behavior because GetEnumeratorName() - Fixes undefined behavior because GetEnumeratorName() returns
returns
pointer to stack memory, that goes outside of scope while pointer to stack memory, that goes outside of scope while
pointer still is being used. pointer still is being used.
@ -5173,8 +4588,7 @@
Although it's possible to use a PC keyboard on a Mac, it's unusual. Although it's possible to use a PC keyboard on a Mac, it's unusual.
In any case, all of the macOS (not "Mac OS" for some years now) In any case, all of the macOS (not "Mac OS" for some years now) documentation refers to the "Opt" key.
documentation refers to the "Opt" key.
- hash: ea11f179542794294f773f503d83dad3a10cda56 - hash: ea11f179542794294f773f503d83dad3a10cda56
author: Tom author: Tom
footers: footers:
@ -5338,8 +4752,7 @@
Changes the documentation to update the disktutil command which didn't Changes the documentation to update the disktutil command which didn't
fix my case, cause the boot partition was broken. fix my case, cause the boot partition was broken.
This way it rewrites the drive into a FAT32 partition editable in This way it rewrites the drive into a FAT32 partition editable in Unix/Windows.
Unix/Windows.
- hash: b3f25c176b1bdb487d1a7bf111d7f170fe008842 - hash: b3f25c176b1bdb487d1a7bf111d7f170fe008842
author: Lorenzo Alberto Maria Ambrosi author: Lorenzo Alberto Maria Ambrosi
footers: footers:
@ -8039,8 +7452,7 @@
performance improvement performance improvement
- Make Breadcrumbs and Icon pure components to stop frequent - Make Breadcrumbs and Icon pure components to stop frequent re-rendering
re-rendering
- Initial support for array constraints - Initial support for array constraints
@ -8171,11 +7583,9 @@
the `ETCHER_EXPERIMENTAL_FILE_PICKER` environment variable. Further the `ETCHER_EXPERIMENTAL_FILE_PICKER` environment variable. Further
customisation can be done with the customisation can be done with the `ETCHER_FILE_BROWSER_CONSTRAIN_FOLDER`
`ETCHER_FILE_BROWSER_CONSTRAIN_FOLDER`
variable that takes a path and allows one to constrain the file-picker variable that takes a path and allows one to constrain the file-picker to
to
a folder. a folder.
- hash: 687e0b563b0dc3619ece4ce49d353d5838a21ff6 - hash: 687e0b563b0dc3619ece4ce49d353d5838a21ff6
@ -8274,13 +7684,11 @@
either in the user's home directory, or the current working directory. either in the user's home directory, or the current working directory.
In the case of the home directory, the config file is In the case of the home directory, the config file is `$HOME/.config/etcher/config.json`,
`$HOME/.config/etcher/config.json`,
while on Windows `$HOME/.etcher.json` is used. while on Windows `$HOME/.etcher.json` is used.
The defined settings are merged with localStorage settings, and The defined settings are merged with localStorage settings, and preceding
preceding
configuration files. configuration files.
@ -8570,8 +7978,7 @@
`_.isError()` returns `true` for anything that has a `name` and `_.isError()` returns `true` for anything that has a `name` and
`message` `message`
property, causing the check here to always keep the plain object as property, causing the check here to always keep the plain object as error.
error.
- hash: 355373f24df6be0989fad9429c2230166b33a3bf - hash: 355373f24df6be0989fad9429c2230166b33a3bf
author: Jonas Hermsmeier author: Jonas Hermsmeier
footers: footers:
@ -8588,8 +7995,7 @@
This fixes a ReferenceError that could occur when the DeviceNode was This fixes a ReferenceError that could occur when the DeviceNode was
null, null,
as well as devices being null when run after the system recovers from as well as devices being null when run after the system recovers from sleep / standby.
sleep / standby.
- hash: 6e7484d3dabc2aeaa7cd471822d7019860cc4a5c - hash: 6e7484d3dabc2aeaa7cd471822d7019860cc4a5c
author: Benedict Aas author: Benedict Aas
subject: "feat(GUI): display succeeded and failed devices on finish screen" subject: "feat(GUI): display succeeded and failed devices on finish screen"
@ -8750,8 +8156,7 @@
body: >- body: >-
This replaces shelling out to `diskpart` on Windows to clear This replaces shelling out to `diskpart` on Windows to clear
the partition table with `win-drive-clean`, which does so via the partition table with `win-drive-clean`, which does so via DeviceIoControl.
DeviceIoControl.
- hash: abf2dc3efcf214a68c0b0e329d57a3f66bb5d342 - hash: abf2dc3efcf214a68c0b0e329d57a3f66bb5d342
author: Benedict Aas author: Benedict Aas
footers: footers:
@ -8858,18 +8263,15 @@
This updates the instructions to open the Developer Tools in the issue This updates the instructions to open the Developer Tools in the issue
template, template,
as the keyboard shortcuts have changed to their defaults on Linux & as the keyboard shortcuts have changed to their defaults on Linux & Windows
Windows
from [Ctrl]+[Alt]+[I] to [Ctrl]+[Shift]+[I]. from [Ctrl]+[Alt]+[I] to [Ctrl]+[Shift]+[I].
Further, the editor config is updated to allow trailing spaces in Further, the editor config is updated to allow trailing spaces in Markdown
Markdown
files to add trailing spaces to the list items in the issue template, in files to add trailing spaces to the list items in the issue template, in
order to avoid people not putting whitespace in between, causing the order to avoid people not putting whitespace in between, causing the formatting
formatting
to not be parsed properly. to not be parsed properly.
- hash: 3dd646485fa34437ac3adb3caa5a594d439f1f68 - hash: 3dd646485fa34437ac3adb3caa5a594d439f1f68
@ -8953,8 +8355,7 @@
This replaces use of `electron.app.getName()` with the package.json's This replaces use of `electron.app.getName()` with the package.json's
`.displayName` `.displayName`
property to ensure the correct application name is displayed when property to ensure the correct application name is displayed when packaged.
packaged.
- hash: cf340f48c3582f3e96f7b2dc16c11f44b7661363 - hash: cf340f48c3582f3e96f7b2dc16c11f44b7661363
author: Jonas Hermsmeier author: Jonas Hermsmeier
footers: footers:
@ -9130,8 +8531,7 @@
body: >- body: >-
This updates `resin-cli-visuals` in order to fix drive selection in This updates `resin-cli-visuals` in order to fix drive selection in
the CLI, which was caused by incompatibility of two different the CLI, which was caused by incompatibility of two different `drivelist` versions
`drivelist` versions
- hash: bde1e32e29ae75ccecf7fc3bc1b03efd6e4f67b8 - hash: bde1e32e29ae75ccecf7fc3bc1b03efd6e4f67b8
author: Jonas Hermsmeier author: Jonas Hermsmeier
footers: footers:
@ -9432,8 +8832,7 @@
We remove a piece of code checking whether `_.keys` returns any We remove a piece of code checking whether `_.keys` returns any
non-string non-string
values in its array, but per the Lodash documentation `_.keys` always values in its array, but per the Lodash documentation `_.keys` always returns an
returns an
array of strings. array of strings.
- hash: 83528df18be32bfe62d3e9e4578101077769a7cf - hash: 83528df18be32bfe62d3e9e4578101077769a7cf
@ -9689,8 +9088,7 @@
body: >- body: >-
Due to some Windows systems missing certain C runtime libraries Due to some Windows systems missing certain C runtime libraries
(Visual C/C++ 2012 / 2015 Redistributables), we ignore errors when (Visual C/C++ 2012 / 2015 Redistributables), we ignore errors when loading
loading
this module until we can ensure distribution of those along with it. this module until we can ensure distribution of those along with it.
- hash: 21e595466d5d950d7c38b2411791f756ec6ebdca - hash: 21e595466d5d950d7c38b2411791f756ec6ebdca
@ -9775,8 +9173,7 @@
body: >- body: >-
This updates the `postshrinkwrap` script to traverse the dependency tree This updates the `postshrinkwrap` script to traverse the dependency tree
and remove all `from` fields to avoid inconsistent diffs across and remove all `from` fields to avoid inconsistent diffs across platforms,
platforms,
environments and installs when shrinkwrapping anew. environments and installs when shrinkwrapping anew.
- hash: 619051a4b0cd8995e31838f221386b9b44e6ffc8 - hash: 619051a4b0cd8995e31838f221386b9b44e6ffc8
@ -10188,8 +9585,7 @@
This works around the "Cannot fetch index base URL This works around the "Cannot fetch index base URL
http://pypi.python.org/simple/" http://pypi.python.org/simple/"
error by installing pip==9.0.1 directly from the error by installing pip==9.0.1 directly from the pypi.python.org/packages/
pypi.python.org/packages/
- hash: c8b2b652354029cedceda2637bed13fee65f8587 - hash: c8b2b652354029cedceda2637bed13fee65f8587
author: Juan Cruz Viotti author: Juan Cruz Viotti
footers: footers:
@ -10241,11 +9637,9 @@
WARNING: Binary file: lib/blobs/usbboot/raspberrypi/bootcode.bin WARNING: Binary file: lib/blobs/usbboot/raspberrypi/bootcode.bin
WARNING: Binary file: WARNING: Binary file: tests/image-stream/data/unrecognized/xz-without-extension
tests/image-stream/data/unrecognized/xz-without-extension
WARNING: Binary file: WARNING: Binary file: tests/image-stream/data/unrecognized/xz-with-invalid-extension.foo
tests/image-stream/data/unrecognized/xz-with-invalid-extension.foo
``` ```
- hash: f4e0121639d8f2cbcc15b6577ec15d7ecbab7e71 - hash: f4e0121639d8f2cbcc15b6577ec15d7ecbab7e71
@ -11978,8 +11372,7 @@
https://developer.apple.com/library/mac/technotes/tn2206/_index.html https://developer.apple.com/library/mac/technotes/tn2206/_index.html
> Code signing uses extended attributes to store signatures in > Code signing uses extended attributes to store signatures in non-Mach-O
non-Mach-O
> executables such as script files. If the extended attributes are lost > executables such as script files. If the extended attributes are lost
@ -11991,8 +11384,7 @@
> One way to guarantee preservation of extended attributes is by packing > One way to guarantee preservation of extended attributes is by packing
> up your signed code in a read-write disk image (DMG) file before > up your signed code in a read-write disk image (DMG) file before signing
signing
> and then, after signing, converting to read-only. You probably don't > and then, after signing, converting to read-only. You probably don't
@ -12055,19 +11447,31 @@
changelog-entry: Don't include user paths in Mixpanel usage reports changelog-entry: Don't include user paths in Mixpanel usage reports
link: https://github.com/resin-io-modules/etcher-image-stream/blob/master/CHANGELOG.md link: https://github.com/resin-io-modules/etcher-image-stream/blob/master/CHANGELOG.md
subject: Fix uncaught exception if no file was selected from a dialog subject: Fix uncaught exception if no file was selected from a dialog
body: |- body: >-
The following error is thrown if the open file dialog is cancelled The following error is thrown if the open file dialog is cancelled
without any selection: without any selection:
Unhandled rejection TypeError: Cannot read property '0' of undefined Unhandled rejection TypeError: Cannot read property '0' of undefined
at Number.indexedGetter (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/call_get.js:106:15) at Number.indexedGetter (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/call_get.js:106:15)
at Number.tryCatcher (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/util.js:16:23) at Number.tryCatcher (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/util.js:16:23)
at Promise._settlePromiseFromHandler (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/promise.js:503:31) at Promise._settlePromiseFromHandler (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/promise.js:503:31)
at Promise._settlePromise (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/promise.js:560:18) at Promise._settlePromise (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/promise.js:560:18)
at Promise._settlePromise0 (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/promise.js:605:10) at Promise._settlePromise0 (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/promise.js:605:10)
at Promise._settlePromises (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/promise.js:684:18) at Promise._settlePromises (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/promise.js:684:18)
at Async._drainQueue (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/async.js:126:16) at Async._drainQueue (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/async.js:126:16)
at Async._drainQueues (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/async.js:136:10) at Async._drainQueues (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/async.js:136:10)
at Immediate.Async.drainQueues [as _onImmediate] (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/async.js:16:14) at Immediate.Async.drainQueues [as _onImmediate] (/home/parallels/Projects/etcher/node_modules/bluebird/js/release/async.js:16:14)
at processImmediate [as _immediateCallback] (timers.js:383:17) at processImmediate [as _immediateCallback] (timers.js:383:17)
- hash: 6bd086f1c5c6654a47125cf2d46788655cae2553 - hash: 6bd086f1c5c6654a47125cf2d46788655cae2553
author: Juan Cruz Viotti author: Juan Cruz Viotti
@ -12082,8 +11486,7 @@
body: >- body: >-
From the documentation: From the documentation:
> `useContentSize` Boolean - The `width` and `height` would be used as > `useContentSize` Boolean - The `width` and `height` would be used as web
web
> pages size, which means the actual windows size will include window > pages size, which means the actual windows size will include window
@ -12664,14 +12067,21 @@
changelog-entry: Use info icon instead of "SHOW FULL FILE NAME" in first step. changelog-entry: Use info icon instead of "SHOW FULL FILE NAME" in first step.
fixes: https://github.com/resin-io/etcher/issues/458 fixes: https://github.com/resin-io/etcher/issues/458
subject: Make use of AppImage desktop integration script subject: Make use of AppImage desktop integration script
body: |- body: >-
This is useful to prompt the user to install the `.desktop` file. This is useful to prompt the user to install the `.desktop` file.
The `Description` key in `Etcher.desktop` was changed to `Comment` since The `Description` key in `Etcher.desktop` was changed to `Comment` since
`desktop-file-validate` complained with: `desktop-file-validate` complained with:
Etcher.desktop: error: file contains key "Description" in group "Desktop Etcher.desktop: error: file contains key "Description" in group "Desktop
Entry", but keys extending the format should start with "X-" Entry", but keys extending the format should start with "X-"
After checking the desktop file format specification, the correct key After checking the desktop file format specification, the correct key
should be "Comment" should be "Comment"
(https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s05.html). (https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s05.html).
- hash: c3e360e61933ef0044c005b5e92c879ff9a47c49 - hash: c3e360e61933ef0044c005b5e92c879ff9a47c49
author: Juan Cruz Viotti author: Juan Cruz Viotti
@ -12884,11 +12294,15 @@
changelog-entry: Fix flashing never starting after elevation in GNU/Linux. changelog-entry: Fix flashing never starting after elevation in GNU/Linux.
fixes: https://github.com/resin-io/etcher/issues/665 fixes: https://github.com/resin-io/etcher/issues/665
subject: Make all angular modules export the name of the module subject: Make all angular modules export the name of the module
body: |- body: >-
This makes them very nicely require-able, for example: This makes them very nicely require-able, for example:
angular.module('MyModule', [ angular.module('MyModule', [
require('my-dependency'); require('my-dependency');
]); ]);
From https://medium.com/@kentcdodds/how-to-distribute-your-angularjs-module-e04d4dd58ddc#.yqg2zo8im From https://medium.com/@kentcdodds/how-to-distribute-your-angularjs-module-e04d4dd58ddc#.yqg2zo8im
- hash: b8f63af3f81bca3abd055303bc91ab35eb126655 - hash: b8f63af3f81bca3abd055303bc91ab35eb126655
author: Juan Cruz Viotti author: Juan Cruz Viotti
@ -13141,8 +12555,7 @@
body: >- body: >-
Electron no longer supports 10.8. Electron no longer supports 10.8.
See See http://electron.atom.io/docs/v0.37.5/tutorial/supported-platforms/#os-x
http://electron.atom.io/docs/v0.37.5/tutorial/supported-platforms/#os-x
- hash: 097c9a4aa37029154c3efe8564edbeef048926ad - hash: 097c9a4aa37029154c3efe8564edbeef048926ad
author: Juan Cruz Viotti author: Juan Cruz Viotti
subject: Add subtle hover styling to footer links subject: Add subtle hover styling to footer links

View File

@ -3,189 +3,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
# v2.1.3
## (2025-05-15)
* Remove stale secrets [Anton Belodedenko]
# v2.1.2
## (2025-05-08)
* patch: remove analytics [Edwin Joassart]
# v2.1.1
## (2025-05-05)
* patch: fix signin windows artifacts [Edwin Joassart]
# v2.1.0
## (2025-02-27)
* Add informational notice about how to disable analytics collection [myarmolinsky]
# v2.0.0
## (2025-02-20)
* major: build on ubuntu 22 and macos 13 [Edwin Joassart]
# v1.19.25
## (2024-10-10)
* patch: bump etcher-sdk to 9.1.2 [Edwin Joassart]
# v1.19.24
## (2024-10-09)
* patch: etcher-util is corrupted in RPM package [Richard Glidden]
# v1.19.23
## (2024-10-09)
* patch: remove gconf2 libgconf-2-4 deps [Marc-Aurèle Brothier]
# v1.19.22
## (2024-07-18)
* Replace deprecated Flowzone inputs [Kyle Harding]
# v1.19.21
## (2024-05-30)
* patch: fix missing windows dependency [Edwin Joassart]
* patch: fix missing windows dependency [Edwin Joassart]
* patch: fix missing windows dependency [Edwin Joassart]
# v1.19.20
## (2024-05-30)
* patch: fix missing windows dependency [Edwin Joassart]
# v1.19.19
## (2024-05-28)
* patch: add sentry debug flag [Edwin Joassart]
# v1.19.18
## (2024-05-22)
* patch: fix Sentry DSN for main process [Edwin Joassart]
# v1.19.17
## (2024-05-09)
* patch: fix injection of analytics key at build time [JOASSART Edwin]
# v1.19.16
## (2024-04-26)
* patch: hold request for metadata while waiting for flasher [Edwin Joassart]
# v1.19.15
## (2024-04-26)
* patch: bump etcher-sdk to 9.0.11 to fix url loading using http/2 [Edwin Joassart]
# v1.19.14
## (2024-04-25)
* patch: pretty-bytes to 6.1.1 [JOASSART Edwin]
# v1.19.13
## (2024-04-25)
* patch: use etcher icon as loading for windows installer [Edwin Joassart]
* patch: fix windows squirrel install [Edwin Joassart]
# v1.19.12
## (2024-04-25)
* patch: bump minors & patch [Edwin Joassart]
* patch: bump @electron-forge/* to 7.4.0 [Edwin Joassart]
* patch: bump electron to 30.0.1 & @electron/remote to 2.1.2 [Edwin Joassart]
* patch: npm upgrade [Edwin Joassart]
* patch: bump @balena/lint to 8.0.2 and fix formating [Edwin Joassart]
* patch: fix pretty-bytes imports [Edwin Joassart]
* patch: bump etcher-sdk to 9.0.9 [Edwin Joassart]
# v1.19.11
## (2024-04-25)
* patch: setup wdio and port (most) tests [Edwin Joassart]
# v1.19.10
## (2024-04-23)
* patch: remove node-ipc and tests [Edwin Joassart]
* patch: switch api; use ws; integrate sudo-prompt - switch api roles flow - use websocket instead of node-ipc - integrate; modernize; simplify and deprecate sudo-prompt [Edwin Joassart]
* patch: refactor api to use a single topic [Edwin Joassart]
* patch: set require node engine to 20 [Edwin Joassart]
# v1.19.9
## (2024-04-22)
* patch: prevent rebuild of native deps by @electron/rebuild [Edwin Joassart]
# v1.19.8
## (2024-04-22)
* patch: replace deprecated pkg with yao-pkg and bump etcher-util node v to 20.10 [Edwin Joassart]
# v1.19.7
## (2024-04-22)
* patch: fix formating [Edwin Joassart]
* patch: configure prettier in the project to use balena-lint configuration [Edwin Joassart]
# v1.19.6
## (2024-04-19)
* patch: fix win signature process [Edwin Joassart]
# v1.19.5
## (2024-02-14)
* Replace deprecated flowzone input tests_run_on [Kyle Harding]
# v1.19.4
## (2024-01-26)
* patch: remove screensaver error when not on etcher-pro [Edwin Joassart]
* patch: fix typo in IPC server id [Edwin Joassart]
# v1.19.3
## (2023-12-22)
* Update dependencies [Edwin Joassart]
# v1.19.2
## (2023-12-22)
* fix: typos [Rotzbua]
# v1.19.1
## (2023-12-22)
* patch: update winget-releaser v2 [Vedant]
# v1.19.0
## (2023-12-21)
* Use native ARM runner for Apple Silicon builds [Akis Kesoglou]
* Calculate and upload build artifact sha256 checksums [Akis Kesoglou]
* Migrate build pipeline to Electron Forge [Akis Kesoglou]
# v1.18.14
## (2023-12-20)
* Remove repo config from flowzone.yml [Kyle Harding]
* Update actions/upload-artifact to v4 [Kyle Harding]
# v1.18.13
## (2023-10-16)
* patch: upgrade to electron 25 [Edwin Joassart]
* patch: refactor scanner, loader and flasher out of gui + upgrade to electron 25 [Edwin Joassart]
# v1.18.12 # v1.18.12
## (2023-07-19) ## (2023-07-19)

152
Makefile Normal file
View File

@ -0,0 +1,152 @@
# ---------------------------------------------------------------------
# Build configuration
# ---------------------------------------------------------------------
RESIN_SCRIPTS ?= ./scripts/resin
export NPM_VERSION ?= 6.14.8
S3_BUCKET = artifacts.ci.balena-cloud.com
# This directory will be completely deleted by the `clean` rule
BUILD_DIRECTORY ?= dist
BUILD_TEMPORARY_DIRECTORY = $(BUILD_DIRECTORY)/.tmp
$(BUILD_DIRECTORY):
mkdir $@
$(BUILD_TEMPORARY_DIRECTORY): | $(BUILD_DIRECTORY)
mkdir $@
SHELL := /bin/bash
# ---------------------------------------------------------------------
# Operating system and architecture detection
# ---------------------------------------------------------------------
# http://stackoverflow.com/a/12099167
ifeq ($(OS),Windows_NT)
PLATFORM = win32
ifeq ($(PROCESSOR_ARCHITEW6432),AMD64)
HOST_ARCH = x64
else
ifeq ($(PROCESSOR_ARCHITECTURE),AMD64)
HOST_ARCH = x64
endif
ifeq ($(PROCESSOR_ARCHITECTURE),x86)
HOST_ARCH = x86
endif
endif
else
ifeq ($(shell uname -s),Linux)
PLATFORM = linux
ifeq ($(shell uname -m),x86_64)
HOST_ARCH = x64
endif
ifneq ($(filter %86,$(shell uname -m)),)
HOST_ARCH = x86
endif
ifeq ($(shell uname -m),armv7l)
HOST_ARCH = armv7hf
endif
ifeq ($(shell uname -m),aarch64)
HOST_ARCH = aarch64
endif
ifeq ($(shell uname -m),armv8)
HOST_ARCH = aarch64
endif
ifeq ($(shell uname -m),arm64)
HOST_ARCH = aarch64
endif
endif
ifeq ($(shell uname -s),Darwin)
PLATFORM = darwin
ifeq ($(shell uname -m),x86_64)
HOST_ARCH = x64
endif
ifeq ($(shell uname -m),arm64)
HOST_ARCH = aarch64
endif
endif
endif
ifndef PLATFORM
$(error We could not detect your host platform)
endif
ifndef HOST_ARCH
$(error We could not detect your host architecture)
endif
# Default to host architecture. You can override by doing:
#
# make <target> TARGET_ARCH=<arch>
#
TARGET_ARCH ?= $(HOST_ARCH)
# ---------------------------------------------------------------------
# Electron
# ---------------------------------------------------------------------
electron-develop:
git submodule update --init && \
npm ci && \
npm run webpack
electron-test:
$(RESIN_SCRIPTS)/electron/test.sh \
-b $(shell pwd) \
-s $(PLATFORM)
assets/dmg/background.tiff: assets/dmg/background.png assets/dmg/background@2x.png
tiffutil -cathidpicheck $^ -out $@
electron-build: assets/dmg/background.tiff | $(BUILD_TEMPORARY_DIRECTORY)
$(RESIN_SCRIPTS)/electron/build.sh \
-b $(shell pwd) \
-r $(TARGET_ARCH) \
-s $(PLATFORM) \
-v production \
-n $(BUILD_TEMPORARY_DIRECTORY)/npm
# ---------------------------------------------------------------------
# Phony targets
# ---------------------------------------------------------------------
TARGETS = \
help \
info \
lint \
test \
clean \
distclean \
electron-develop \
electron-test \
electron-build
.PHONY: $(TARGETS)
lint:
npm run lint
test:
npm run test
help:
@echo "Available targets: $(TARGETS)"
info:
@echo "Platform : $(PLATFORM)"
@echo "Host arch : $(HOST_ARCH)"
@echo "Target arch : $(TARGET_ARCH)"
clean:
rm -rf $(BUILD_DIRECTORY)
distclean: clean
rm -rf node_modules
rm -rf dist
rm -rf generated
rm -rf $(BUILD_TEMPORARY_DIRECTORY)
.DEFAULT_GOAL = help

View File

@ -17,9 +17,13 @@ was written correctly, and much more. It can also directly flash Raspberry Pi de
## Supported Operating Systems ## Supported Operating Systems
- Linux; most distros; Intel 64-bit. - Linux (most distros)
- Windows 10 and later; Intel 64-bit. - macOS 10.10 (Yosemite) and later
- macOS 10.13 (High Sierra) and later; both Intel and Apple Silicon. - Microsoft Windows 7 and later
**Note**: Etcher will run on any platform officially supported by
[Electron][electron]. Read more in their
[documentation][electron-supported-platforms].
## Installers ## Installers

31
afterPack.js Normal file
View File

@ -0,0 +1,31 @@
'use strict'
const cp = require('child_process')
const fs = require('fs')
const outdent = require('outdent')
const path = require('path')
exports.default = function(context) {
if (context.packager.platform.name !== 'linux') {
return
}
const scriptPath = path.join(context.appOutDir, context.packager.executableName)
const binPath = scriptPath + '.bin'
cp.execFileSync('mv', [scriptPath, binPath])
fs.writeFileSync(
scriptPath,
outdent({trimTrailingNewline: false})`
#!/bin/bash
# Resolve symlinks. Warning, readlink -f doesn't work on MacOS/BSD
script_dir="$(dirname "$(readlink -f "\${BASH_SOURCE[0]}")")"
if [[ $EUID -ne 0 ]] || [[ $ELECTRON_RUN_AS_NODE ]]; then
"\${script_dir}"/${context.packager.executableName}.bin "$@"
else
"\${script_dir}"/${context.packager.executableName}.bin "$@" --no-sandbox
fi
`
)
cp.execFileSync('chmod', ['+x', scriptPath])
}

25
afterSignHook.js Normal file
View File

@ -0,0 +1,25 @@
'use strict'
const { notarize } = require('electron-notarize')
const { ELECTRON_SKIP_NOTARIZATION } = process.env
async function main(context) {
const { electronPlatformName, appOutDir } = context
if (electronPlatformName !== 'darwin' || ELECTRON_SKIP_NOTARIZATION === 'true') {
return
}
const appName = context.packager.appInfo.productFilename
const appleId = process.env.XCODE_APP_LOADER_EMAIL || 'accounts+apple@balena.io'
const appleIdPassword = process.env.XCODE_APP_LOADER_PASSWORD
// https://github.com/electron/notarize/blob/main/README.md
await notarize({
appBundleId: 'io.balena.etcher',
appPath: `${appOutDir}/${appName}.app`,
appleId,
appleIdPassword
})
}
exports.default = main

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

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

9
dictionary.txt Normal file
View File

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

View File

@ -75,7 +75,9 @@ cd etcher
#### GUI #### GUI
```sh ```sh
# Build and start application # Build the GUI
npm run webpack #or npm run build
# Start Electron
npm start npm start
``` ```
@ -102,6 +104,7 @@ systems as they can before sending a pull request.
*The test suite is run automatically by CI servers when you send a pull *The test suite is run automatically by CI servers when you send a pull
request.* request.*
We make use of [EditorConfig] to communicate indentation, line endings and We make use of [EditorConfig] to communicate indentation, line endings and
other text editing default. We encourage you to install the relevant plugin in 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 your text editor of choice to avoid having to fix any issues during the review
@ -110,8 +113,7 @@ process.
Updating a dependency Updating a dependency
--------------------- ---------------------
- Install new version of dependency using npm - Commit *both* `package.json` and `package-lock.json`.
- Commit *both* `package.json` and `npm-shrinkwrap.json`.
Diffing Binaries Diffing Binaries
---------------- ----------------

View File

@ -1,8 +1,10 @@
# Maintaining Etcher 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 Releasing
---------
### Release Types ### Release Types
@ -11,15 +13,16 @@ This document is meant to serve as a guide for maintainers to perform common tas
- **release**: Full releases - **release**: Full releases
Draft release is created from each PR, tagged with the branch name. 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_. 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. Mark the pre-release as final when it is necessary, then distribute the packages in alternative channels as necessary.
#### Preparation #### Preparation
- [Prepare the new version](#preparing-a-new-version) - [Prepare the new version](#preparing-a-new-version)
- [Generate build artifacts](#generating-binaries) (binaries, archives, etc.) - [Generate build artifacts](#generating-binaries) (binaries, archives, etc.)
- [Draft a release on GitHub](https://github.com/balena-io/etcher/releases) - [Draft a release on GitHub](https://github.com/balena-io/etcher/releases)
- Upload build artifacts to GitHub release draft - Upload build artifacts to GitHub release draft
#### Testing #### Testing
@ -32,7 +35,7 @@ Mark the pre-release as final when it is necessary, then distribute the packages
- [Post release note to forums](https://forums.balena.io/c/etcher) - [Post release note to forums](https://forums.balena.io/c/etcher)
- [Submit Windows binaries to Symantec for whitelisting](#submitting-binaries-to-symantec) - [Submit Windows binaries to Symantec for whitelisting](#submitting-binaries-to-symantec)
- [Update the website](https://github.com/balena-io/etcher-homepage) - [Update the website](https://github.com/balena-io/etcher-homepage)
- Wait 2-3 hours for analytics (Sentry) to trickle in and check for elevated error rates, or regressions - Wait 2-3 hours for analytics (Sentry, Amplitude) to trickle in and check for elevated error rates, or regressions
- If regressions arise; pull the release, and release a patched version, else: - If regressions arise; pull the release, and release a patched version, else:
- [Upload deb & rpm packages to Cloudfront](#uploading-packages-to-cloudfront) - [Upload deb & rpm packages to Cloudfront](#uploading-packages-to-cloudfront)
- Post changelog with `#release-notes` tag on internal chat - Post changelog with `#release-notes` tag on internal chat
@ -48,28 +51,37 @@ Make sure to set the analytics tokens when generating production release binarie
```bash ```bash
export ANALYTICS_SENTRY_TOKEN="xxxxxx" export ANALYTICS_SENTRY_TOKEN="xxxxxx"
export ANALYTICS_AMPLITUDE_TOKEN="xxxxxx"
``` ```
#### Linux #### Linux
##### Clean dist folder ##### Clean dist folder
Delete `.webpack` and `out/`. **NOTE:** Make sure to adjust the path as necessary (here the Etcher repository has been cloned to `/home/$USER/code/etcher`)
##### Generating artifacts ##### Generating artifacts
The artifacts are generated by the CI and published as draft-release or pre-release. The artifacts are generated by the CI and published as draft-release or pre-release.
Etcher is built with electron-forge. Run: `electron-builder` is used to create the packaged application.
``` #### Mac OS
npm run make
``` **ATTENTION:** For production releases you'll need the code-signing key,
and set `CSC_NAME` to generate signed binaries on Mac OS.
#### Windows
**ATTENTION:** For production releases you'll need the code-signing key,
and set `CSC_LINK`, and `CSC_KEY_PASSWORD` to generate signed binaries on Windows.
**NOTE:**
- Keep in mind to also generate artifacts for x86, with `TARGET_ARCH=x86`.
Our CI will appropriately sign artifacts for macOS and some Windows targets.
### Uploading packages to Cloudfront ### Uploading packages to Cloudfront
Log in to cloudfront and upload the `rpm` and `deb` files. Log in to cloudfront and upload the `rpm` and `deb` files.
### Dealing with a Problematic Release ### Dealing with a Problematic Release
@ -94,6 +106,7 @@ aws s3api delete-object --bucket <bucket name> --key <file name>
The Bintray dashboard provides an easy way to delete a version's files. The Bintray dashboard provides an easy way to delete a version's files.
### Submitting binaries to Symantec ### Submitting binaries to Symantec
- [Report a Suspected Erroneous Detection](https://submit.symantec.com/false_positive/standard/) - [Report a Suspected Erroneous Detection](https://submit.symantec.com/false_positive/standard/)

View File

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

View File

@ -36,17 +36,14 @@ employee by asking for it from the relevant people.
Packaging Packaging
--------- ---------
Run the following command on each platform: The resulting installers will be saved to `dist/out`.
Run the following commands on all platforms with the right arguments:
```sh ```sh
npm run make ./node_modules/electron-builder build <...>
``` ```
This will produce all targets (eg. zip, dmg) specified in forge.config.ts for the
host platform and architecture.
The resulting artifacts can be found in `out/make`.
Publishing to Cloudfront Publishing to Cloudfront
--------------------- ---------------------

View File

@ -122,6 +122,7 @@ run Etcher on a GNU/Linux system.
- xrender - xrender
- xtst - xtst
- xscrnsaver - xscrnsaver
- gconf-2.0
- gmodule-2.0 - gmodule-2.0
- nss - nss

110
electron-builder.yml Normal file
View File

@ -0,0 +1,110 @@
# https://www.electron.build/configuration/configuration
appId: io.balena.etcher
copyright: Copyright 2016-2023 Balena Ltd
productName: balenaEtcher
afterPack: ./afterPack.js
afterSign: ./afterSignHook.js
asar: false
files:
- generated
- lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js
- lib/shared/catalina-sudo/sudo-askpass.osascript-en.js
mac:
icon: assets/icon.icns
category: public.app-category.developer-tools
hardenedRuntime: true
entitlements: "entitlements.mac.plist"
entitlementsInherit: "entitlements.mac.plist"
artifactName: "${productName}-${version}.${ext}"
target:
- dmg
dmg:
background: assets/dmg/background.tiff
icon: assets/icon.icns
iconSize: 110
contents:
- x: 140
y: 225
- x: 415
y: 225
type: link
path: /Applications
window:
width: 540
height: 405
win:
icon: assets/icon.ico
target:
- zip
- nsis
- portable
nsis:
oneClick: true
runAfterFinish: true
installerIcon: assets/icon.ico
uninstallerIcon: assets/icon.ico
deleteAppDataOnUninstall: true
license: LICENSE
artifactName: "${productName}-Setup-${version}.${ext}"
portable:
artifactName: "${productName}-Portable-${version}.${ext}"
requestExecutionLevel: user
linux:
icon: assets/iconset
target:
- AppImage
- rpm
- deb
category: Utility
packageCategory: utils
executableName: balena-etcher
synopsis: balenaEtcher is a powerful OS image flasher built with web technologies to ensure flashing an SDCard or USB drive is a pleasant and safe experience. It protects you from accidentally writing to your hard-drives, ensures every byte of data was written correctly and much more.
appImage:
artifactName: ${productName}-${version}-${env.ELECTRON_BUILDER_ARCHITECTURE}.${ext}
deb:
priority: optional
compression: bzip2
depends:
- gconf-service
- gconf2
- libasound2
- libatk1.0-0
- libc6
- libcairo2
- libcups2
- libdbus-1-3
- libexpat1
- libfontconfig1
- libfreetype6
- libgbm1
- libgcc1
- libgconf-2-4
- libgdk-pixbuf2.0-0
- libglib2.0-0
- libgtk-3-0
- liblzma5
- libnotify4
- libnspr4
- libnss3
- libpango1.0-0 | libpango-1.0-0
- libstdc++6
- libx11-6
- libxcomposite1
- libxcursor1
- libxdamage1
- libxext6
- libxfixes3
- libxi6
- libxrandr2
- libxrender1
- libxss1
- libxtst6
- polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1
afterInstall: "./after-install.tpl"
rpm:
depends:
- util-linux
protocols:
name: etcher
schemes:
- etcher

View File

@ -14,11 +14,5 @@
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <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> </dict>
</plist> </plist>

View File

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

View File

@ -1,168 +0,0 @@
import { PluginBase } from '@electron-forge/plugin-base';
import type {
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);
},
};
}
}

View File

@ -16,30 +16,30 @@
import * as electron from 'electron'; import * as electron from 'electron';
import * as remote from '@electron/remote'; import * as remote from '@electron/remote';
import type { Dictionary } from 'lodash'; import * as sdk from 'etcher-sdk';
import { debounce, capitalize, values } from 'lodash'; import * as _ from 'lodash';
import outdent from 'outdent'; import outdent from 'outdent';
import * as React from 'react'; import * as React from 'react';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import * as packageJSON from '../../../package.json'; import * as packageJSON from '../../../package.json';
import type { DrivelistDrive } from '../../shared/drive-constraints'; import { DrivelistDrive, isSourceDrive } from '../../shared/drive-constraints';
import * as EXIT_CODES from '../../shared/exit-codes'; import * as EXIT_CODES from '../../shared/exit-codes';
import * as messages from '../../shared/messages'; import * as messages from '../../shared/messages';
import * as availableDrives from './models/available-drives'; import * as availableDrives from './models/available-drives';
import * as flashState from './models/flash-state'; import * as flashState from './models/flash-state';
import { deselectImage, getImage } from './models/selection-state';
import * as settings from './models/settings'; import * as settings from './models/settings';
import { Actions, observe, store } from './models/store'; import { Actions, observe, store } from './models/store';
import * as analytics from './modules/analytics'; import * as analytics from './modules/analytics';
import { spawnChildAndConnect } from './modules/api'; import { scanner as driveScanner } from './modules/drive-scanner';
import * as exceptionReporter from './modules/exception-reporter'; import * as exceptionReporter from './modules/exception-reporter';
import * as osDialog from './os/dialog'; import * as osDialog from './os/dialog';
import * as windowProgress from './os/window-progress'; import * as windowProgress from './os/window-progress';
import MainPage from './pages/main/MainPage'; import MainPage from './pages/main/MainPage';
import './css/main.css'; import './css/main.css';
import * as i18next from 'i18next'; import * as i18next from 'i18next';
import type { SourceMetadata } from '../../shared/typings/source-selector';
window.addEventListener( window.addEventListener(
'unhandledrejection', 'unhandledrejection',
@ -64,6 +64,9 @@ store.dispatch({
data: uuidV4(), data: uuidV4(),
}); });
const applicationSessionUuid = store.getState().toJS().applicationSessionUuid;
const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid;
console.log(outdent` console.log(outdent`
${outdent} ${outdent}
_____ _ _ _____ _ _
@ -79,7 +82,14 @@ console.log(outdent`
Version = ${packageJSON.version}, Type = ${packageJSON.packageType} Version = ${packageJSON.version}, Type = ${packageJSON.packageType}
`); `);
const debouncedLog = debounce(console.log, 1000, { maxWait: 1000 }); 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) { function pluralize(word: string, quantity: number) {
return `${quantity} ${word}${quantity === 1 ? '' : 's'}`; return `${quantity} ${word}${quantity === 1 ? '' : 's'}`;
@ -105,7 +115,7 @@ observe(() => {
// might cause some non-sense flashing state logs including // might cause some non-sense flashing state logs including
// `undefined` values. // `undefined` values.
debouncedLog(outdent({ newline: ' ' })` debouncedLog(outdent({ newline: ' ' })`
${capitalize(currentFlashState.type)} ${_.capitalize(currentFlashState.type)}
${active}, ${active},
${currentFlashState.percentage}% ${currentFlashState.percentage}%
at at
@ -118,43 +128,172 @@ observe(() => {
`); `);
}); });
function setDrives(drives: Dictionary<DrivelistDrive>) { /**
// prevent setting drives while flashing otherwise we might lose some while we unmount them * @summary The radix used by USB ID numbers
if (!flashState.isFlashing()) { */
availableDrives.setDrives(values(drives)); const USB_ID_RADIX = 16;
/**
* @summary The expected length of a USB ID number
*/
const USB_ID_LENGTH = 4;
/**
* @summary Convert a USB id (e.g. product/vendor) to a string
*
* @example
* console.log(usbIdToString(2652))
* > '0x0a5c'
*/
function usbIdToString(id: number): string {
return `0x${_.padStart(id.toString(USB_ID_RADIX), USB_ID_LENGTH, '0')}`;
}
/**
* @summary Product ID of BCM2708
*/
const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763;
/**
* @summary Product ID of BCM2710
*/
const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764;
/**
* @summary Compute module descriptions
*/
const COMPUTE_MODULE_DESCRIPTIONS: _.Dictionary<string> = {
[USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1',
[USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3',
};
async function driveIsAllowed(drive: {
devicePath: string;
device: string;
raw: string;
}) {
const driveBlacklist = (await settings.get('driveBlacklist')) || [];
return !(
driveBlacklist.includes(drive.devicePath) ||
driveBlacklist.includes(drive.device) ||
driveBlacklist.includes(drive.raw)
);
}
type Drive =
| sdk.sourceDestination.BlockDevice
| sdk.sourceDestination.UsbbootDrive
| sdk.sourceDestination.DriverlessDevice;
function prepareDrive(drive: Drive) {
if (drive instanceof sdk.sourceDestination.BlockDevice) {
// @ts-ignore (BlockDevice.drive is private)
return drive.drive;
} else if (drive instanceof sdk.sourceDestination.UsbbootDrive) {
// This is a workaround etcher expecting a device string and a size
// @ts-ignore
drive.device = drive.usbDevice.portId;
drive.size = null;
// @ts-ignore
drive.progress = 0;
drive.disabled = true;
drive.on('progress', (progress) => {
updateDriveProgress(drive, progress);
});
return drive;
} else if (drive instanceof sdk.sourceDestination.DriverlessDevice) {
const description =
COMPUTE_MODULE_DESCRIPTIONS[
drive.deviceDescriptor.idProduct.toString()
] || 'Compute Module';
return {
device: `${usbIdToString(
drive.deviceDescriptor.idVendor,
)}:${usbIdToString(drive.deviceDescriptor.idProduct)}`,
displayName: 'Missing drivers',
description,
mountpoints: [],
isReadOnly: false,
isSystem: false,
disabled: true,
icon: 'warning',
size: null,
link: 'https://www.raspberrypi.com/documentation/computers/compute-module.html#flashing-the-compute-module-emmc',
linkCTA: 'Install',
linkTitle: 'Install missing drivers',
linkMessage: outdent`
Would you like to download the necessary drivers from the Raspberry Pi Foundation?
This will open your browser.
Once opened, download and run the installer from the "Windows Installer" section to install the drivers
`,
};
} }
} }
// Spawning the child process without privileges to get the drives list function setDrives(drives: _.Dictionary<DrivelistDrive>) {
// TODO: clean up this mess of exports availableDrives.setDrives(_.values(drives));
export let requestMetadata: any; }
// start the api and spawn the child process function getDrives() {
spawnChildAndConnect({ return _.keyBy(availableDrives.getDrives(), 'device');
withPrivileges: false, }
})
.then(({ emit, registerHandler }) => {
// start scanning
emit('scan', {});
// make the sourceMetada awaitable to be used on source selection async function addDrive(drive: Drive) {
requestMetadata = async (params: any): Promise<SourceMetadata> => { const preparedDrive = prepareDrive(drive);
emit('sourceMetadata', JSON.stringify(params)); if (!(await driveIsAllowed(preparedDrive))) {
return;
}
const drives = getDrives();
drives[preparedDrive.device] = preparedDrive;
setDrives(drives);
}
return new Promise((resolve) => function removeDrive(drive: Drive) {
registerHandler('sourceMetadata', (data: any) => { if (
resolve(JSON.parse(data)); drive instanceof sdk.sourceDestination.BlockDevice &&
}), // @ts-ignore BlockDevice.drive is private
); isSourceDrive(drive.drive, getImage())
}; ) {
// Deselect the image if it was on the drive that was removed.
// This will also deselect the image if the drive mountpoints change.
deselectImage();
}
const preparedDrive = prepareDrive(drive);
const drives = getDrives();
delete drives[preparedDrive.device];
setDrives(drives);
}
registerHandler('drives', (data: any) => { function updateDriveProgress(
setDrives(JSON.parse(data)); drive: sdk.sourceDestination.UsbbootDrive,
}); progress: number,
}) ) {
.catch((error: any) => { const drives = getDrives();
throw new Error(`Failed to start the flasher process. error: ${error}`); // @ts-ignore
}); const driveInMap = drives[drive.device];
if (driveInMap) {
// @ts-ignore
drives[drive.device] = { ...driveInMap, progress };
setDrives(drives);
}
}
driveScanner.on('attach', addDrive);
driveScanner.on('detach', removeDrive);
driveScanner.on('error', (error) => {
// Stop the drive scanning loop in case of errors,
// otherwise we risk presenting the same error over
// and over again to the user, while also heavily
// spamming our error reporting service.
driveScanner.stop();
return exceptionReporter.report(error);
});
driveScanner.start();
let popupExists = false; let popupExists = false;
@ -162,6 +301,9 @@ analytics.initAnalytics();
window.addEventListener('beforeunload', async (event) => { window.addEventListener('beforeunload', async (event) => {
if (!flashState.isFlashing() || popupExists) { if (!flashState.isFlashing() || popupExists) {
analytics.logEvent('Close application', {
isFlashing: flashState.isFlashing(),
});
return; return;
} }
@ -171,6 +313,8 @@ window.addEventListener('beforeunload', async (event) => {
// Don't open any more popups // Don't open any more popups
popupExists = true; popupExists = true;
analytics.logEvent('Close attempt while flashing');
try { try {
const confirmed = await osDialog.showWarning({ const confirmed = await osDialog.showWarning({
confirmationLabel: i18next.t('yesExit'), confirmationLabel: i18next.t('yesExit'),
@ -179,11 +323,19 @@ window.addEventListener('beforeunload', async (event) => {
description: messages.warning.exitWhileFlashing(), description: messages.warning.exitWhileFlashing(),
}); });
if (confirmed) { if (confirmed) {
analytics.logEvent('Close confirmed while flashing', {
flashInstanceUuid: flashState.getFlashUuid(),
});
// This circumvents the 'beforeunload' event unlike // This circumvents the 'beforeunload' event unlike
// remote.app.quit() which does not. // remote.app.quit() which does not.
remote.process.exit(EXIT_CODES.SUCCESS); remote.process.exit(EXIT_CODES.SUCCESS);
} }
analytics.logEvent('Close rejected while flashing', {
applicationSessionUuid,
flashingWorkflowUuid,
});
popupExists = false; popupExists = false;
} catch (error: any) { } catch (error: any) {
exceptionReporter.report(error); exceptionReporter.report(error);

View File

@ -14,34 +14,35 @@
* limitations under the License. * limitations under the License.
*/ */
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/triangle-exclamation.svg'; import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg'; import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg';
import type * as sourceDestination from 'etcher-sdk/build/source-destination/'; import * as sourceDestination from 'etcher-sdk/build/source-destination/';
import * as React from 'react'; import * as React from 'react';
import type { ModalProps, TableColumn } from 'rendition'; import { Flex, ModalProps, Txt, Badge, Link, TableColumn } from 'rendition';
import { Flex, Txt, Badge, Link } from 'rendition';
import styled from 'styled-components'; import styled from 'styled-components';
import type {
DriveStatus,
DrivelistDrive,
} from '../../../../shared/drive-constraints';
import { import {
getDriveImageCompatibilityStatuses, getDriveImageCompatibilityStatuses,
isDriveValid, isDriveValid,
DriveStatus,
DrivelistDrive,
isDriveSizeLarge, isDriveSizeLarge,
} from '../../../../shared/drive-constraints'; } from '../../../../shared/drive-constraints';
import { compatibility, warning } from '../../../../shared/messages'; import { compatibility, warning } from '../../../../shared/messages';
import prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import { getDrives, hasAvailableDrives } from '../../models/available-drives'; import { getDrives, hasAvailableDrives } from '../../models/available-drives';
import { getImage, isDriveSelected } from '../../models/selection-state'; import { getImage, isDriveSelected } from '../../models/selection-state';
import { store } from '../../models/store'; import { store } from '../../models/store';
import { logException } from '../../modules/analytics'; import { logEvent, logException } from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external'; import { open as openExternal } from '../../os/open-external/services/open-external';
import type { GenericTableProps } from '../../styled-components'; import {
import { Alert, Modal, Table } from '../../styled-components'; Alert,
GenericTableProps,
Modal,
Table,
} from '../../styled-components';
import type { SourceMetadata } from '../../../../shared/typings/source-selector'; import { SourceMetadata } from '../source-selector/source-selector';
import { middleEllipsis } from '../../utils/middle-ellipsis'; import { middleEllipsis } from '../../utils/middle-ellipsis';
import * as i18next from 'i18next'; import * as i18next from 'i18next';
@ -309,17 +310,9 @@ export class DriveSelector extends React.Component<
case compatibility.system(): case compatibility.system():
return warning.systemDrive(); return warning.systemDrive();
case compatibility.tooSmall(): case compatibility.tooSmall():
return warning.tooSmall( const size =
{ this.state.image?.recommendedDriveSize || this.state.image?.size || 0;
size: return warning.tooSmall({ size }, drive);
this.state.image?.recommendedDriveSize ||
this.state.image?.size ||
0,
},
drive,
);
default:
return '';
} }
} }
@ -355,6 +348,9 @@ export class DriveSelector extends React.Component<
private installMissingDrivers(drive: DriverlessDrive) { private installMissingDrivers(drive: DriverlessDrive) {
if (drive.link) { if (drive.link) {
logEvent('Open driver link modal', {
url: drive.link,
});
this.setState({ missingDriversModal: { drive } }); this.setState({ missingDriversModal: { drive } });
} }
} }
@ -432,10 +428,11 @@ export class DriveSelector extends React.Component<
) : ( ) : (
<> <>
<DrivesTable <DrivesTable
refFn={() => { refFn={(t) => {
// noop if (t !== null) {
t.setRowSelection(selectedList);
}
}} }}
checkedItems={selectedList}
checkedRowsNumber={selectedList.length} checkedRowsNumber={selectedList.length}
multipleSelection={this.props.multipleSelection} multipleSelection={this.props.multipleSelection}
columns={this.tableColumns} columns={this.tableColumns}
@ -445,10 +442,7 @@ export class DriveSelector extends React.Component<
isDrivelistDrive(row) && row.isSystem ? ['system'] : [] isDrivelistDrive(row) && row.isSystem ? ['system'] : []
} }
rowKey="displayName" rowKey="displayName"
onCheck={(rows) => { onCheck={(rows: Drive[]) => {
if (rows == null) {
rows = [];
}
let newSelection = rows.filter(isDrivelistDrive); let newSelection = rows.filter(isDrivelistDrive);
if (this.props.multipleSelection) { if (this.props.multipleSelection) {
if (rows.length === 0) { if (rows.length === 0) {

View File

@ -1,12 +1,12 @@
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/triangle-exclamation.svg'; import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
import * as _ from 'lodash';
import * as React from 'react'; import * as React from 'react';
import type { ModalProps } from 'rendition'; import { Badge, Flex, Txt, ModalProps } from 'rendition';
import { Badge, Flex, Txt } from 'rendition';
import { Modal, ScrollableFlex } from '../../styled-components'; import { Modal, ScrollableFlex } from '../../styled-components';
import { middleEllipsis } from '../../utils/middle-ellipsis'; import { middleEllipsis } from '../../utils/middle-ellipsis';
import prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import type { DriveWithWarnings } from '../../pages/main/Flash'; import { DriveWithWarnings } from '../../pages/main/Flash';
import * as i18next from 'i18next'; import * as i18next from 'i18next';
const DriveStatusWarningModal = ({ const DriveStatusWarningModal = ({

View File

@ -22,13 +22,14 @@ import * as flashState from '../../models/flash-state';
import * as selectionState from '../../models/selection-state'; import * as selectionState from '../../models/selection-state';
import * as settings from '../../models/settings'; import * as settings from '../../models/settings';
import { Actions, store } from '../../models/store'; import { Actions, store } from '../../models/store';
import * as analytics from '../../modules/analytics';
import { FlashAnother } from '../flash-another/flash-another'; import { FlashAnother } from '../flash-another/flash-another';
import type { FlashError } from '../flash-results/flash-results'; import { FlashResults, FlashError } from '../flash-results/flash-results';
import { FlashResults } from '../flash-results/flash-results';
import { SafeWebview } from '../safe-webview/safe-webview'; import { SafeWebview } from '../safe-webview/safe-webview';
function restart(goToMain: () => void) { function restart(goToMain: () => void) {
selectionState.deselectAllDrives(); selectionState.deselectAllDrives();
analytics.logEvent('Restart');
// Reset the flashing workflow uuid // Reset the flashing workflow uuid
store.dispatch({ store.dispatch({

View File

@ -15,11 +15,11 @@
*/ */
import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg'; import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg';
import CheckCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle-check.svg'; import CheckCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/check-circle.svg';
import TimesCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle-xmark.svg'; import TimesCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/times-circle.svg';
import outdent from 'outdent';
import * as React from 'react'; import * as React from 'react';
import type { FlexProps, TableColumn } from 'rendition'; import { Flex, FlexProps, Link, TableColumn, Txt } from 'rendition';
import { Flex, Link, Txt } from 'rendition';
import styled from 'styled-components'; import styled from 'styled-components';
import { progress } from '../../../../shared/messages'; import { progress } from '../../../../shared/messages';
@ -139,9 +139,8 @@ export function FlashResults({
}; };
} & FlexProps) { } & FlexProps) {
const [showErrorsInfo, setShowErrorsInfo] = React.useState(false); const [showErrorsInfo, setShowErrorsInfo] = React.useState(false);
const allFailed = !skip && results.devices.successful === 0;
const allFailed = !skip && results?.devices?.successful === 0; const someFailed = results.devices.failed !== 0 || errors.length !== 0;
const someFailed = results?.devices?.failed !== 0 || errors?.length !== 0;
const effectiveSpeed = bytesToMegabytes(getEffectiveSpeed(results)).toFixed( const effectiveSpeed = bytesToMegabytes(getEffectiveSpeed(results)).toFixed(
1, 1,
); );

View File

@ -21,6 +21,7 @@ import * as React from 'react';
import * as packageJSON from '../../../../../package.json'; import * as packageJSON from '../../../../../package.json';
import * as settings from '../../models/settings'; import * as settings from '../../models/settings';
import * as analytics from '../../modules/analytics';
/** /**
* @summary Electron session identifier * @summary Electron session identifier
@ -195,6 +196,10 @@ export class SafeWebview extends React.PureComponent<
// only care about this event if it's a request for the main frame // only care about this event if it's a request for the main frame
if (event.resourceType === 'mainFrame') { if (event.resourceType === 'mainFrame') {
const HTTP_OK = 200; const HTTP_OK = 200;
const { webContents, ...webviewEvent } = event;
analytics.logEvent('SafeWebview loaded', {
...webviewEvent,
});
this.setState({ this.setState({
shouldShow: event.statusCode === HTTP_OK, shouldShow: event.statusCode === HTTP_OK,
}); });

View File

@ -17,10 +17,11 @@
import GithubSvg from '@fortawesome/fontawesome-free/svgs/brands/github.svg'; import GithubSvg from '@fortawesome/fontawesome-free/svgs/brands/github.svg';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as React from 'react'; import * as React from 'react';
import { Box, Checkbox, Flex, Txt } from 'rendition'; import { Box, Checkbox, Flex, TextWithCopy, Txt } from 'rendition';
import { version, packageType } from '../../../../../package.json'; import { version, packageType } from '../../../../../package.json';
import * as settings from '../../models/settings'; import * as settings from '../../models/settings';
import * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external'; import { open as openExternal } from '../../os/open-external/services/open-external';
import { Modal } from '../../styled-components'; import { Modal } from '../../styled-components';
import * as i18next from 'i18next'; import * as i18next from 'i18next';
@ -60,9 +61,7 @@ const EPInfo = etcherProInfo();
const InfoBox = (props: any) => ( const InfoBox = (props: any) => (
<Box fontSize={14}> <Box fontSize={14}>
<Txt>{props.label}</Txt> <Txt>{props.label}</Txt>
<Txt code copy={props.value}> <TextWithCopy code text={props.value} copy={props.value} />
{props.value}{' '}
</Txt>
</Box> </Box>
); );
@ -88,6 +87,7 @@ export function SettingsModal({ toggleModal }: SettingsModalProps) {
const toggleSetting = async (setting: string) => { const toggleSetting = async (setting: string) => {
const value = currentSettings[setting]; const value = currentSettings[setting];
analytics.logEvent('Toggle setting', { setting, value });
await settings.set(setting, !value); await settings.set(setting, !value);
setCurrentSettings({ setCurrentSettings({
...currentSettings, ...currentSettings,

View File

@ -17,20 +17,19 @@
import CopySvg from '@fortawesome/fontawesome-free/svgs/solid/copy.svg'; import CopySvg from '@fortawesome/fontawesome-free/svgs/solid/copy.svg';
import FileSvg from '@fortawesome/fontawesome-free/svgs/solid/file.svg'; import FileSvg from '@fortawesome/fontawesome-free/svgs/solid/file.svg';
import LinkSvg from '@fortawesome/fontawesome-free/svgs/solid/link.svg'; import LinkSvg from '@fortawesome/fontawesome-free/svgs/solid/link.svg';
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/triangle-exclamation.svg'; import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg'; import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg';
import ChevronRightSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-right.svg'; import ChevronRightSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-right.svg';
import type { IpcRendererEvent } from 'electron'; import { sourceDestination } from 'etcher-sdk';
import { ipcRenderer } from 'electron'; import { ipcRenderer, IpcRendererEvent } from 'electron';
import { uniqBy, isNil } from 'lodash'; import * as _ from 'lodash';
import { GPTPartition, MBRPartition } from 'partitioninfo';
import * as path from 'path'; import * as path from 'path';
import prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import * as React from 'react'; import * as React from 'react';
import { requestMetadata } from '../../app';
import type { ButtonProps } from 'rendition';
import { import {
Flex, Flex,
ButtonProps,
Modal as SmallModal, Modal as SmallModal,
Txt, Txt,
Card as BaseCard, Card as BaseCard,
@ -48,7 +47,7 @@ import { observe } from '../../models/store';
import * as analytics from '../../modules/analytics'; import * as analytics from '../../modules/analytics';
import * as exceptionReporter from '../../modules/exception-reporter'; import * as exceptionReporter from '../../modules/exception-reporter';
import * as osDialog from '../../os/dialog'; import * as osDialog from '../../os/dialog';
import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drives';
import { import {
ChangeButton, ChangeButton,
DetailsText, DetailsText,
@ -64,13 +63,9 @@ import { SVGIcon } from '../svg-icon/svg-icon';
import ImageSvg from '../../../assets/image.svg'; import ImageSvg from '../../../assets/image.svg';
import SrcSvg from '../../../assets/src.svg'; import SrcSvg from '../../../assets/src.svg';
import { DriveSelector } from '../drive-selector/drive-selector'; import { DriveSelector } from '../drive-selector/drive-selector';
import type { DrivelistDrive } from '../../../../shared/drive-constraints'; import { DrivelistDrive } from '../../../../shared/drive-constraints';
import axios, { AxiosRequestConfig } from 'axios';
import { isJson } from '../../../../shared/utils'; import { isJson } from '../../../../shared/utils';
import type {
SourceMetadata,
Authentication,
Source,
} from '../../../../shared/typings/source-selector';
import * as i18next from 'i18next'; import * as i18next from 'i18next';
const recentUrlImagesKey = 'recentUrlImages'; const recentUrlImagesKey = 'recentUrlImages';
@ -88,7 +83,7 @@ function normalizeRecentUrlImages(urls: any[]): URL[] {
} }
}) })
.filter((url) => url !== undefined); .filter((url) => url !== undefined);
urls = uniqBy(urls, (url) => url.href); urls = _.uniqBy(urls, (url) => url.href);
return urls.slice(urls.length - 5); return urls.slice(urls.length - 5);
} }
@ -306,9 +301,26 @@ const FlowSelector = styled(
} }
`; `;
export type Source =
| typeof sourceDestination.File
| typeof sourceDestination.BlockDevice
| typeof sourceDestination.Http;
export interface SourceMetadata extends sourceDestination.Metadata {
hasMBR?: boolean;
partitions?: MBRPartition[] | GPTPartition[];
path: string;
displayName: string;
description: string;
SourceType: Source;
drive?: DrivelistDrive;
extension?: string;
archiveExtension?: string;
auth?: Authentication;
}
interface SourceSelectorProps { interface SourceSelectorProps {
flashing: boolean; flashing: boolean;
hideAnalyticsAlert: () => void;
} }
interface SourceSelectorState { interface SourceSelectorState {
@ -324,6 +336,11 @@ interface SourceSelectorState {
imageLoading: boolean; imageLoading: boolean;
} }
interface Authentication {
username: string;
password: string;
}
export class SourceSelector extends React.Component< export class SourceSelector extends React.Component<
SourceSelectorProps, SourceSelectorProps,
SourceSelectorState SourceSelectorState
@ -360,29 +377,47 @@ export class SourceSelector extends React.Component<
ipcRenderer.removeListener('select-image', this.onSelectImage); 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) { private async onSelectImage(_event: IpcRendererEvent, imagePath: string) {
this.setState({ imageLoading: true }); this.setState({ imageLoading: true });
await this.selectSource( await this.selectSource(
imagePath, imagePath,
isURL(this.normalizeImagePath(imagePath)) ? 'Http' : 'File', isURL(this.normalizeImagePath(imagePath))
? sourceDestination.Http
: sourceDestination.File,
).promise; ).promise;
this.setState({ imageLoading: false }); this.setState({ imageLoading: false });
} }
private async createSource(
selected: string,
SourceType: Source,
auth?: Authentication,
) {
try {
selected = await replaceWindowsNetworkDriveLetter(selected);
} catch (error: any) {
analytics.logException(error);
}
if (isJson(decodeURIComponent(selected))) {
const config: AxiosRequestConfig = JSON.parse(
decodeURIComponent(selected),
);
return new sourceDestination.Http({
url: config.url!,
axiosInstance: axios.create(_.omit(config, ['url'])),
});
}
if (SourceType === sourceDestination.File) {
return new sourceDestination.File({
path: selected,
});
}
return new sourceDestination.Http({ url: selected, auth });
}
public normalizeImagePath(imgPath: string) { public normalizeImagePath(imgPath: string) {
const decodedPath = decodeURIComponent(imgPath); const decodedPath = decodeURIComponent(imgPath);
if (isJson(decodedPath)) { if (isJson(decodedPath)) {
@ -392,8 +427,11 @@ export class SourceSelector extends React.Component<
} }
private reselectSource() { private reselectSource() {
analytics.logEvent('Reselect image', {
previousImage: selectionState.getImage(),
});
selectionState.deselectImage(); selectionState.deselectImage();
this.props.hideAnalyticsAlert();
} }
private selectSource( private selectSource(
@ -401,16 +439,18 @@ export class SourceSelector extends React.Component<
SourceType: Source, SourceType: Source,
auth?: Authentication, auth?: Authentication,
): { promise: Promise<void>; cancel: () => void } { ): { promise: Promise<void>; cancel: () => void } {
let cancelled = false;
return { return {
cancel: () => { cancel: () => {
// noop cancelled = true;
}, },
promise: (async () => { promise: (async () => {
const sourcePath = isString(selected) ? selected : selected.device; const sourcePath = isString(selected) ? selected : selected.device;
let source;
let metadata: SourceMetadata | undefined; let metadata: SourceMetadata | undefined;
if (isString(selected)) { if (isString(selected)) {
if ( if (
SourceType === 'Http' && SourceType === sourceDestination.Http &&
!isURL(this.normalizeImagePath(selected)) !isURL(this.normalizeImagePath(selected))
) { ) {
this.handleError( this.handleError(
@ -422,6 +462,7 @@ export class SourceSelector extends React.Component<
} }
if (supportedFormats.looksLikeWindowsImage(selected)) { if (supportedFormats.looksLikeWindowsImage(selected)) {
analytics.logEvent('Possibly Windows image', { image: selected });
this.setState({ this.setState({
warning: { warning: {
message: messages.warning.looksLikeWindowsImage(), message: messages.warning.looksLikeWindowsImage(),
@ -429,22 +470,25 @@ export class SourceSelector extends React.Component<
}, },
}); });
} }
source = await this.createSource(selected, SourceType, auth);
if (cancelled) {
return;
}
try { try {
// this will send an event down the ipcMain asking for metadata const innerSource = await source.getInnerSource();
// we'll get the response through an event if (cancelled) {
return;
// 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 this.getMetadata(innerSource, selected);
if (cancelled) {
return;
}
metadata.SourceType = SourceType;
metadata = await requestMetadata({ selected, SourceType, auth }); if (!metadata.hasMBR && this.state.warning === null) {
analytics.logEvent('Missing partition table', { metadata });
if (!metadata?.hasMBR && this.state.warning === null) {
this.setState({ this.setState({
warning: { warning: {
message: messages.warning.missingPartitionTable(), message: messages.warning.missingPartitionTable(),
@ -459,9 +503,16 @@ export class SourceSelector extends React.Component<
messages.error.openSource(sourcePath, error.message), messages.error.openSource(sourcePath, error.message),
error, error,
); );
} finally {
try {
await source.close();
} catch (error: any) {
// Noop
}
} }
} else { } else {
if (selected.partitionTableType === null) { if (selected.partitionTableType === null) {
analytics.logEvent('Missing partition table', { selected });
this.setState({ this.setState({
warning: { warning: {
message: messages.warning.driveMissingPartitionTable(), message: messages.warning.driveMissingPartitionTable(),
@ -474,15 +525,23 @@ export class SourceSelector extends React.Component<
displayName: selected.displayName, displayName: selected.displayName,
description: selected.displayName, description: selected.displayName,
size: selected.size as SourceMetadata['size'], size: selected.size as SourceMetadata['size'],
SourceType: 'BlockDevice', SourceType: sourceDestination.BlockDevice,
drive: selected, drive: selected,
}; };
} }
if (metadata !== undefined) { if (metadata !== undefined) {
metadata.auth = auth; metadata.auth = auth;
metadata.SourceType = SourceType;
selectionState.selectSource(metadata); 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),
},
});
} }
})(), })(),
}; };
@ -503,9 +562,30 @@ export class SourceSelector extends React.Component<
analytics.logException(error); analytics.logException(error);
return; return;
} }
analytics.logEvent(title, { path: sourcePath });
}
private async getMetadata(
source: sourceDestination.SourceDestination,
selected: string | DrivelistDrive,
) {
const metadata = (await source.getMetadata()) as SourceMetadata;
const partitionTable = await source.getPartitionTable();
if (partitionTable) {
metadata.hasMBR = true;
metadata.partitions = partitionTable.partitions;
} else {
metadata.hasMBR = false;
}
if (isString(selected)) {
metadata.extension = path.extname(selected).slice(1);
metadata.path = selected;
}
return metadata;
} }
private async openImageSelector() { private async openImageSelector() {
analytics.logEvent('Open image selector');
this.setState({ imageSelectorOpen: true }); this.setState({ imageSelectorOpen: true });
try { try {
@ -513,9 +593,10 @@ export class SourceSelector extends React.Component<
// Avoid analytics and selection state changes // Avoid analytics and selection state changes
// if no file was resolved from the dialog. // if no file was resolved from the dialog.
if (!imagePath) { if (!imagePath) {
analytics.logEvent('Image selector closed');
return; return;
} }
await this.selectSource(imagePath, 'File').promise; await this.selectSource(imagePath, sourceDestination.File).promise;
} catch (error: any) { } catch (error: any) {
exceptionReporter.report(error); exceptionReporter.report(error);
} finally { } finally {
@ -524,19 +605,23 @@ export class SourceSelector extends React.Component<
} }
private async onDrop(event: React.DragEvent<HTMLDivElement>) { private async onDrop(event: React.DragEvent<HTMLDivElement>) {
const file = event.dataTransfer.files.item(0); const [file] = event.dataTransfer.files;
if (file != null) { if (file) {
await this.selectSource(file.path, 'File').promise; await this.selectSource(file.path, sourceDestination.File).promise;
} }
} }
private openURLSelector() { private openURLSelector() {
analytics.logEvent('Open image URL selector');
this.setState({ this.setState({
showURLSelector: true, showURLSelector: true,
}); });
} }
private openDriveSelector() { private openDriveSelector() {
analytics.logEvent('Open drive selector');
this.setState({ this.setState({
showDriveSelector: true, showDriveSelector: true,
}); });
@ -553,6 +638,10 @@ export class SourceSelector extends React.Component<
} }
private showSelectedImageDetails() { private showSelectedImageDetails() {
analytics.logEvent('Show selected image tooltip', {
imagePath: selectionState.getImage()?.path,
});
this.setState({ this.setState({
showImageDetails: true, showImageDetails: true,
}); });
@ -578,7 +667,7 @@ export class SourceSelector extends React.Component<
imageLoading, imageLoading,
} = this.state; } = this.state;
const selectionImage = selectionState.getImage(); const selectionImage = selectionState.getImage();
let image = let image: SourceMetadata | DrivelistDrive =
selectionImage !== undefined ? selectionImage : ({} as SourceMetadata); selectionImage !== undefined ? selectionImage : ({} as SourceMetadata);
image = image.drive ?? image; image = image.drive ?? image;
@ -634,7 +723,7 @@ export class SourceSelector extends React.Component<
{i18next.t('cancel')} {i18next.t('cancel')}
</ChangeButton> </ChangeButton>
)} )}
{!isNil(imageSize) && !imageLoading && ( {!_.isNil(imageSize) && !imageLoading && (
<DetailsText>{prettyBytes(imageSize)}</DetailsText> <DetailsText>{prettyBytes(imageSize)}</DetailsText>
)} )}
</> </>
@ -681,7 +770,7 @@ export class SourceSelector extends React.Component<
style={{ style={{
boxShadow: '0 3px 7px rgba(0, 0, 0, 0.3)', boxShadow: '0 3px 7px rgba(0, 0, 0, 0.3)',
}} }}
title={ titleElement={
<span> <span>
<ExclamationTriangleSvg fill="#fca321" height="1em" />{' '} <ExclamationTriangleSvg fill="#fca321" height="1em" />{' '}
<span>{this.state.warning.title}</span> <span>{this.state.warning.title}</span>
@ -732,11 +821,13 @@ export class SourceSelector extends React.Component<
done={async (imageURL: string, auth?: Authentication) => { done={async (imageURL: string, auth?: Authentication) => {
// Avoid analytics and selection state changes // Avoid analytics and selection state changes
// if no file was resolved from the dialog. // if no file was resolved from the dialog.
if (imageURL) { if (!imageURL) {
analytics.logEvent('URL selector closed');
} else {
let promise; let promise;
({ promise, cancel: cancelURLSelection } = this.selectSource( ({ promise, cancel: cancelURLSelection } = this.selectSource(
imageURL, imageURL,
'Http', sourceDestination.Http,
auth, auth,
)); ));
await promise; await promise;
@ -759,7 +850,10 @@ export class SourceSelector extends React.Component<
if (originalList.length) { if (originalList.length) {
const originalSource = originalList[0]; const originalSource = originalList[0];
if (selectionImage?.drive?.device !== originalSource.device) { if (selectionImage?.drive?.device !== originalSource.device) {
this.selectSource(originalSource, 'BlockDevice'); this.selectSource(
originalSource,
sourceDestination.BlockDevice,
);
} }
} else { } else {
selectionState.deselectImage(); selectionState.deselectImage();
@ -774,7 +868,7 @@ export class SourceSelector extends React.Component<
) { ) {
return selectionState.deselectImage(); return selectionState.deselectImage();
} }
this.selectSource(drive, 'BlockDevice'); this.selectSource(drive, sourceDestination.BlockDevice);
} }
}} }}
/> />

View File

@ -14,15 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/triangle-exclamation.svg'; import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
import * as React from 'react'; import * as React from 'react';
import type { FlexProps } from 'rendition'; import { Flex, FlexProps, Txt } from 'rendition';
import { Flex, Txt } from 'rendition';
import type { DriveStatus } from '../../../../shared/drive-constraints'; import {
import { getDriveImageCompatibilityStatuses } from '../../../../shared/drive-constraints'; getDriveImageCompatibilityStatuses,
DriveStatus,
} from '../../../../shared/drive-constraints';
import { compatibility, warning } from '../../../../shared/messages'; import { compatibility, warning } from '../../../../shared/messages';
import prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import { getImage, getSelectedDrives } from '../../models/selection-state'; import { getImage, getSelectedDrives } from '../../models/selection-state';
import { import {
ChangeButton, ChangeButton,

View File

@ -17,9 +17,12 @@
import * as React from 'react'; import * as React from 'react';
import { Flex, Txt } from 'rendition'; import { Flex, Txt } from 'rendition';
import type { DriveSelectorProps } from '../drive-selector/drive-selector';
import { DriveSelector } from '../drive-selector/drive-selector';
import { import {
DriveSelector,
DriveSelectorProps,
} from '../drive-selector/drive-selector';
import {
isDriveSelected,
getImage, getImage,
getSelectedDrives, getSelectedDrives,
deselectDrive, deselectDrive,
@ -27,12 +30,13 @@ import {
deselectAllDrives, deselectAllDrives,
} from '../../models/selection-state'; } from '../../models/selection-state';
import { observe } from '../../models/store'; import { observe } from '../../models/store';
import * as analytics from '../../modules/analytics';
import { TargetSelectorButton } from './target-selector-button'; import { TargetSelectorButton } from './target-selector-button';
import TgtSvg from '../../../assets/tgt.svg'; import TgtSvg from '../../../assets/tgt.svg';
import DriveSvg from '../../../assets/drive.svg'; import DriveSvg from '../../../assets/drive.svg';
import { warning } from '../../../../shared/messages'; import { warning } from '../../../../shared/messages';
import type { DrivelistDrive } from '../../../../shared/drive-constraints'; import { DrivelistDrive } from '../../../../shared/drive-constraints';
import * as i18next from 'i18next'; import * as i18next from 'i18next';
export const getDriveListLabel = () => { export const getDriveListLabel = () => {
@ -75,10 +79,21 @@ export const selectAllTargets = (modalTargets: DrivelistDrive[]) => {
); );
// deselect drives // deselect drives
deselected.forEach((drive) => { deselected.forEach((drive) => {
analytics.logEvent('Toggle drive', {
drive,
previouslySelected: true,
});
deselectDrive(drive.device); deselectDrive(drive.device);
}); });
// select drives // select drives
modalTargets.forEach((drive) => { 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); selectDrive(drive.device);
}); });
}; };
@ -87,14 +102,12 @@ interface TargetSelectorProps {
disabled: boolean; disabled: boolean;
hasDrive: boolean; hasDrive: boolean;
flashing: boolean; flashing: boolean;
hideAnalyticsAlert: () => void;
} }
export const TargetSelector = ({ export const TargetSelector = ({
disabled, disabled,
hasDrive, hasDrive,
flashing, flashing,
hideAnalyticsAlert,
}: TargetSelectorProps) => { }: TargetSelectorProps) => {
// TODO: inject these from redux-connector // TODO: inject these from redux-connector
const [{ driveListLabel, targets }, setStateSlice] = React.useState( const [{ driveListLabel, targets }, setStateSlice] = React.useState(
@ -126,9 +139,9 @@ export const TargetSelector = ({
tooltip={driveListLabel} tooltip={driveListLabel}
openDriveSelector={() => { openDriveSelector={() => {
setShowTargetSelectorModal(true); setShowTargetSelectorModal(true);
hideAnalyticsAlert();
}} }}
reselectDrive={() => { reselectDrive={() => {
analytics.logEvent('Reselect drive');
setShowTargetSelectorModal(true); setShowTargetSelectorModal(true);
}} }}
flashing={flashing} flashing={flashing}

View File

@ -15,36 +15,36 @@
*/ */
@font-face { @font-face {
font-family: 'SourceSansPro'; font-family: "SourceSansPro";
src: url('./fonts/SourceSansPro-Regular.ttf') format('truetype'); src: url("./fonts/SourceSansPro-Regular.ttf") format("truetype");
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
} }
@font-face { @font-face {
font-family: 'SourceSansPro'; font-family: "SourceSansPro";
src: url('./fonts/SourceSansPro-SemiBold.ttf') format('truetype'); src: url("./fonts/SourceSansPro-SemiBold.ttf") format("truetype");
font-weight: 600; font-weight: 600;
font-style: normal; font-style: normal;
} }
html, html,
body { body {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
/* Prevent white flash when running application */ /* Prevent white flash when running application */
background-color: #4d5057; background-color: #4d5057;
/* Prevent WebView bounce effect in OS X */ /* Prevent WebView bounce effect in OS X */
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
/* Prevent text selection */ /* Prevent text selection */
body { body {
-webkit-user-select: none; -webkit-user-select: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* Prevent blue outline */ /* Prevent blue outline */
@ -52,15 +52,15 @@ a:focus,
input:focus, input:focus,
button:focus, button:focus,
[tabindex]:focus, [tabindex]:focus,
input[type='checkbox'] + div { input[type="checkbox"] + div {
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
.disabled { .disabled {
opacity: 0.4; opacity: 0.4;
} }
#rendition-tooltip-root > div { #rendition-tooltip-root > div {
font-family: 'SourceSansPro', sans-serif; font-family: "SourceSansPro", sans-serif;
} }

View File

@ -133,7 +133,8 @@ const translation = {
flashCompleted: 'Flash Completed!', flashCompleted: 'Flash Completed!',
}, },
settings: { settings: {
errorReporting: 'Anonymously report errors to balena.io', errorReporting:
'Anonymously report errors and usage statistics to balena.io',
autoUpdate: 'Auto-updates enabled', autoUpdate: 'Auto-updates enabled',
settings: 'Settings', settings: 'Settings',
systemInformation: 'System Information', systemInformation: 'System Information',

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>balenaEtcher</title>
<link rel="stylesheet" type="text/css" href="index.css">
</head>
<body>
<main id="main"></main>
<script src="http://localhost:3030/gui.js"></script>
</body>
</html>

View File

@ -3,8 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>balenaEtcher</title> <title>balenaEtcher</title>
<link rel="stylesheet" type="text/css" href="index.css">
</head> </head>
<body> <body>
<main id="main"></main> <main id="main"></main>
<script src="gui.js"></script>
</body> </body>
</html> </html>

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import type { DrivelistDrive } from '../../../shared/drive-constraints'; import { DrivelistDrive } from '../../../shared/drive-constraints';
import { Actions, store } from './store'; import { Actions, store } from './store';
export function hasAvailableDrives() { export function hasAvailableDrives() {

View File

@ -15,9 +15,9 @@
*/ */
import * as electron from 'electron'; import * as electron from 'electron';
import type * as sdk from 'etcher-sdk'; import * as sdk from 'etcher-sdk';
import * as _ from 'lodash'; import * as _ from 'lodash';
import type { DrivelistDrive } from '../../../shared/drive-constraints'; import { DrivelistDrive } from '../../../shared/drive-constraints';
import { bytesToMegabytes } from '../../../shared/units'; import { bytesToMegabytes } from '../../../shared/units';
import { Actions, store } from './store'; import { Actions, store } from './store';
@ -47,7 +47,13 @@ export function isFlashing(): boolean {
*/ */
export function setFlashingFlag() { export function setFlashingFlag() {
// see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods // see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods
electron.ipcRenderer.send('disable-screensaver'); try {
electron.ipcRenderer.invoke('disable-screensaver');
} catch (error) {
console.log(
"Can't disable-screensaver, we're probably not running on a balena-electron env",
);
}
store.dispatch({ store.dispatch({
type: Actions.SET_FLASHING_FLAG, type: Actions.SET_FLASHING_FLAG,
data: {}, data: {},
@ -70,8 +76,7 @@ export function unsetFlashingFlag(results: {
data: results, data: results,
}); });
// see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods // see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods
electron.ipcRenderer.invoke('enable-screensaver');
electron.ipcRenderer.send('enable-screensaver');
} }
export function setDevicePaths(devicePaths: string[]) { export function setDevicePaths(devicePaths: string[]) {

View File

@ -15,11 +15,12 @@
*/ */
import * as _ from 'lodash'; import * as _ from 'lodash';
import type { AnimationFunction, Color } from 'sys-class-rgb-led'; import { Animator, AnimationFunction, Color, RGBLed } from 'sys-class-rgb-led';
import { Animator, RGBLed } from 'sys-class-rgb-led';
import type { DrivelistDrive } from '../../../shared/drive-constraints'; import {
import { isSourceDrive } from '../../../shared/drive-constraints'; DrivelistDrive,
isSourceDrive,
} from '../../../shared/drive-constraints';
import { getDrives } from './available-drives'; import { getDrives } from './available-drives';
import { getSelectedDrives } from './selection-state'; import { getSelectedDrives } from './selection-state';
import * as settings from './settings'; import * as settings from './settings';
@ -42,7 +43,7 @@ function blink(t: number) {
return Math.floor(t) % 2; return Math.floor(t) % 2;
} }
function one() { function one(_t: number) {
return 1; return 1;
} }

View File

@ -1,4 +1,4 @@
import type { DrivelistDrive } from '../../../shared/drive-constraints'; import { DrivelistDrive } from '../../../shared/drive-constraints';
/* /*
* Copyright 2016 balena.io * Copyright 2016 balena.io
* *
@ -15,7 +15,7 @@ import type { DrivelistDrive } from '../../../shared/drive-constraints';
* limitations under the License. * limitations under the License.
*/ */
import type { SourceMetadata } from '../../../shared/typings/source-selector'; import { SourceMetadata } from '../components/source-selector/source-selector';
import * as availableDrives from './available-drives'; import * as availableDrives from './available-drives';
import { Actions, store } from './store'; import { Actions, store } from './store';

View File

@ -14,9 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
import { findLastIndex, once } from 'lodash'; import * as _ from 'lodash';
import { Client, createClient, createNoopClient } from 'analytics-client';
import * as SentryRenderer from '@sentry/electron/renderer'; import * as SentryRenderer from '@sentry/electron/renderer';
import * as settings from '../models/settings'; import * as settings from '../models/settings';
import { store } from '../models/store';
import * as packageJSON from '../../../../package.json';
type AnalyticsPayload = _.Dictionary<any>; type AnalyticsPayload = _.Dictionary<any>;
@ -49,7 +52,7 @@ export const anonymizeSentryData = (
return event; return event;
}; };
const extractPathRegex = /(.*)(^|\s)(file:\/\/)?(\w:)?([\\/].+)/; const extractPathRegex = /(.*)(^|\s)(file\:\/\/)?(\w\:)?([\\\/].+)/;
const etcherSegmentMarkers = ['app.asar', 'Resources']; const etcherSegmentMarkers = ['app.asar', 'Resources'];
export const anonymizePath = (input: string) => { export const anonymizePath = (input: string) => {
@ -69,7 +72,7 @@ export const anonymizePath = (input: string) => {
const segments = mainPart.split(sep); const segments = mainPart.split(sep);
// Moving from the end, find the first marker and cut the path from there. // Moving from the end, find the first marker and cut the path from there.
const startCutIndex = findLastIndex(segments, (segment) => const startCutIndex = _.findLastIndex(segments, (segment) =>
etcherSegmentMarkers.includes(segment), etcherSegmentMarkers.includes(segment),
); );
return ( return (
@ -111,19 +114,105 @@ export const anonymizeAnalyticsPayload = (
return data; return data;
}; };
let analyticsClient: Client;
/** /**
* @summary Init analytics configurations * @summary Init analytics configurations
*/ */
export const initAnalytics = once(() => { export const initAnalytics = _.once(() => {
const dsn = const dsn =
settings.getSync('analyticsSentryToken') || process.env.SENTRY_TOKEN; settings.getSync('analyticsSentryToken') ||
SentryRenderer.init({ _.get(packageJSON, ['analytics', 'sentry', 'token']);
dsn, SentryRenderer.init({ dsn, beforeSend: anonymizeSentryData });
beforeSend: anonymizeSentryData,
debug: process.env.ETCHER_SENTRY_DEBUG === 'true', const projectName =
}); settings.getSync('analyticsAmplitudeToken') ||
_.get(packageJSON, ['analytics', 'amplitude', 'token']);
const clientConfig = {
projectName,
endpoint: 'data.balena-cloud.com',
componentName: 'etcher',
componentVersion: packageJSON.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 (!obj.hasOwnProperty(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 (!flatObject.hasOwnProperty(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 * @summary Log an exception
* *

View File

@ -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 };

View File

@ -15,8 +15,8 @@
*/ */
import * as sdk from 'etcher-sdk'; import * as sdk from 'etcher-sdk';
import type { Adapter } from 'etcher-sdk/build/scanner/adapters';
import { import {
Adapter,
BlockDeviceAdapter, BlockDeviceAdapter,
UsbbootDeviceAdapter, UsbbootDeviceAdapter,
} from 'etcher-sdk/build/scanner/adapters'; } from 'etcher-sdk/build/scanner/adapters';
@ -30,13 +30,14 @@ const adapters: Adapter[] = [
// Can't use permissions.isElevated() here as it returns a promise and we need to set // Can't use permissions.isElevated() here as it returns a promise and we need to set
// module.exports = scanner right now. // module.exports = scanner right now.
if (platform !== 'linux' || (geteuid && geteuid() === 0)) { if (platform !== 'linux' || geteuid() === 0) {
adapters.push(new UsbbootDeviceAdapter()); adapters.push(new UsbbootDeviceAdapter());
} }
if (platform === 'win32') { if (platform === 'win32') {
const { const {
DriverlessDeviceAdapter: driverless, DriverlessDeviceAdapter: driverless,
// tslint:disable-next-line:no-var-requires
} = require('etcher-sdk/build/scanner/adapters/driverless'); } = require('etcher-sdk/build/scanner/adapters/driverless');
adapters.push(new driverless()); adapters.push(new driverless());
} }

View File

@ -14,17 +14,118 @@
* limitations under the License. * limitations under the License.
*/ */
import type { Drive as DrivelistDrive } from 'drivelist'; import { Drive as DrivelistDrive } from 'drivelist';
import type * as sdk from 'etcher-sdk'; import * as sdk from 'etcher-sdk';
import type { Dictionary } from 'lodash'; import { Dictionary } from 'lodash';
import * as errors from '../../../shared/errors'; import * as ipc from 'node-ipc';
import type { SourceMetadata } from '../../../shared/typings/source-selector'; import * as os from 'os';
import * as flashState from '../models/flash-state'; import * as path from 'path';
import * as settings from '../models/settings';
import * as windowProgress from '../os/window-progress'; import * as packageJSON from '../../../../package.json';
import { spawnChildAndConnect } from './api'; import * as errors from '../../../shared/errors';
import * as permissions from '../../../shared/permissions';
import { getAppPath } from '../../../shared/utils';
import { SourceMetadata } from '../components/source-selector/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';
const THREADS_PER_CPU = 16;
// There might be multiple Etcher instances running at
// the same time, therefore we must ensure each IPC
// server/client has a different name.
const IPC_SERVER_ID = `etcher-server-${process.pid}`;
const IPC_CLIENT_ID = `etcher-client-${process.pid}`;
ipc.config.id = IPC_SERVER_ID;
ipc.config.socketRoot = path.join(
process.env.XDG_RUNTIME_DIR || os.tmpdir(),
path.sep,
);
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
ipc.config.silent = true;
/**
* @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),
});
}
}
function 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.
// @ts-ignore (no Server.sockets in @types/node-ipc)
for (const socket of ipc.server.sockets) {
socket.destroy();
}
ipc.server.stop();
}
function writerArgv(): string[] {
let entryPoint = path.join(getAppPath(), 'generated', 'child-writer.js');
// 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 [process.argv[0], entryPoint];
}
}
function writerEnv() {
return {
IPC_SERVER_ID,
IPC_CLIENT_ID,
IPC_SOCKET_ROOT: ipc.config.socketRoot,
ELECTRON_RUN_AS_NODE: '1',
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),
};
}
let cancelEmitter: (type: string) => void | undefined;
interface FlashResults { interface FlashResults {
skip?: boolean; skip?: boolean;
cancelled?: boolean; cancelled?: boolean;
@ -43,62 +144,99 @@ async function performWrite(
drives: DrivelistDrive[], drives: DrivelistDrive[],
onProgress: sdk.multiWrite.OnProgressFunction, onProgress: sdk.multiWrite.OnProgressFunction,
): Promise<{ cancelled?: boolean }> { ): Promise<{ cancelled?: boolean }> {
let cancelled = false;
let skip = false;
ipc.serve();
const { autoBlockmapping, decompressFirst } = await settings.getAll(); 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) => { return await new Promise((resolve, reject) => {
// if the connection failed, reject the promise ipc.server.on('error', (error) => {
terminateServer();
const errorObject = errors.fromJSON(error);
reject(errorObject);
});
ipc.server.on('log', (message) => {
console.log(message);
});
const flashResults: FlashResults = {}; const flashResults: FlashResults = {};
const analyticsData = {
image,
drives,
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
flashInstanceUuid: flashState.getFlashUuid(),
};
const onFail = ({ device, error }: { device: any; error: any }) => { ipc.server.on('fail', ({ device, error }) => {
console.log('fail event');
console.log(device);
console.log(error);
if (device.devicePath) { if (device.devicePath) {
flashState.addFailedDeviceError({ device, error }); flashState.addFailedDeviceError({ device, error });
} }
finish(); handleErrorLogging(error, analyticsData);
}; });
const onDone = (payload: any) => { ipc.server.on('done', (event) => {
console.log('CHILD: flash done', payload); event.results.errors = event.results.errors.map(
payload.results.errors = payload.results.errors.map(
(data: Dictionary<any> & { message: string }) => { (data: Dictionary<any> & { message: string }) => {
return errors.fromJSON(data); return errors.fromJSON(data);
}, },
); );
flashResults.results = payload.results; flashResults.results = event.results;
finish(); });
};
const onAbort = () => { ipc.server.on('abort', () => {
console.log('CHILD: flash aborted'); terminateServer();
flashResults.cancelled = true; cancelled = true;
finish(); });
};
const onSkip = () => { ipc.server.on('skip', () => {
console.log('CHILD: validation skipped'); terminateServer();
flashResults.skip = true; skip = true;
finish(); });
};
const finish = () => { ipc.server.on('state', onProgress);
ipc.server.on('ready', (_data, socket) => {
ipc.server.emit(socket, 'write', {
image,
destinations: drives,
SourceType: image.SourceType.name,
autoBlockmapping,
decompressFirst,
});
});
const argv = writerArgv();
ipc.server.on('start', async () => {
console.log(`Elevating command: ${argv.join(' ')}`);
const env = writerEnv();
try {
const results = await permissions.elevateCommand(argv, {
applicationName: packageJSON.displayName,
environment: env,
});
flashResults.cancelled = cancelled || results.cancelled;
flashResults.skip = skip;
} catch (error: any) {
// This happens when the child is killed using SIGKILL
const SIGKILL_EXIT_CODE = 137;
if (error.code === SIGKILL_EXIT_CODE) {
error.code = 'ECHILDDIED';
}
reject(error);
} finally {
console.log('Terminating IPC server');
terminateServer();
}
console.log('Flash results', flashResults); console.log('Flash results', flashResults);
// The flash wasn't cancelled and we didn't get a 'done' event // The flash wasn't cancelled and we didn't get a 'done' event
// Catch unexpected situation
if ( if (
!flashResults.cancelled && !flashResults.cancelled &&
!flashResults.skip && !flashResults.skip &&
flashResults.results === undefined flashResults.results === undefined
) { ) {
console.log(flashResults);
reject( reject(
errors.createUserError({ errors.createUserError({
title: 'The writer process ended unexpectedly', title: 'The writer process ended unexpectedly',
@ -106,32 +244,15 @@ async function performWrite(
'Please try again, and contact the Etcher team if the problem persists', 'Please try again, and contact the Etcher team if the problem persists',
}), }),
); );
return;
} }
resolve(flashResults); resolve(flashResults);
}; });
registerHandler('state', onProgress); // Clear the update lock timer to prevent longer
registerHandler('fail', onFail); // flashing timing it out, and releasing the lock
registerHandler('done', onDone); ipc.server.start();
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
} }
/** /**
@ -148,39 +269,87 @@ export async function flash(
} }
await flashState.setFlashingFlag(); await flashState.setFlashingFlag();
flashState.setDevicePaths( flashState.setDevicePaths(
drives.map((d) => d.devicePath).filter((p) => p != null) as string[], drives.map((d) => d.devicePath).filter((p) => p != null) as string[],
); );
// start api and call the flasher const analyticsData = {
image,
drives,
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
status: 'started',
flashInstanceUuid: flashState.getFlashUuid(),
};
analytics.logEvent('Flash', analyticsData);
try { try {
const result = await write(image, drives, flashState.setProgressState); const result = await write(image, drives, flashState.setProgressState);
console.log('got results', result);
await flashState.unsetFlashingFlag(result); await flashState.unsetFlashingFlag(result);
console.log('removed flashing flag');
} catch (error: any) { } catch (error: any) {
await flashState.unsetFlashingFlag({ await flashState.unsetFlashingFlag({
cancelled: false, cancelled: false,
errorCode: error.code, errorCode: error.code,
}); });
windowProgress.clear(); 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; throw error;
} }
windowProgress.clear(); 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 * @summary Cancel write operation
* //TODO: find a better solution to handle cancellation
*/ */
export async function cancel(type: string) { export async function cancel(type: string) {
const status = type.toLowerCase(); 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) { // Re-enable lock release on inactivity
cancelEmitter(status);
try {
// @ts-ignore (no Server.sockets in @types/node-ipc)
const [socket] = ipc.server.sockets;
if (socket !== undefined) {
ipc.server.emit(socket, status);
}
} catch (error: any) {
analytics.logException(error);
} }
} }

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import * as i18next from 'i18next'; import * as i18next from 'i18next';
export interface FlashState { export interface FlashState {
@ -34,8 +34,6 @@ export function fromFlashState({
status: string; status: string;
position?: string; position?: string;
} { } {
console.log(i18next.t('progress.starting'));
if (type === undefined) { if (type === undefined) {
return { status: i18next.t('progress.starting') }; return { status: i18next.t('progress.starting') };
} else if (type === 'decompressing') { } else if (type === 'decompressing') {

View File

@ -16,6 +16,7 @@
import * as electron from 'electron'; import * as electron from 'electron';
import * as settings from '../../../models/settings'; import * as settings from '../../../models/settings';
import { logEvent } from '../../../modules/analytics';
/** /**
* @summary Open an external resource * @summary Open an external resource
@ -26,6 +27,8 @@ export async function open(url: string) {
return; return;
} }
logEvent('Open external link', { url });
if (url) { if (url) {
electron.shell.openExternal(url); electron.shell.openExternal(url);
} }

View File

@ -17,8 +17,7 @@
import * as remote from '@electron/remote'; import * as remote from '@electron/remote';
import { percentageToFloat } from '../../../shared/utils'; import { percentageToFloat } from '../../../shared/utils';
import type { FlashState } from '../modules/progress-status'; import { FlashState, titleFromFlashState } from '../modules/progress-status';
import { titleFromFlashState } from '../modules/progress-status';
/** /**
* @summary The title of the main window upon program launch * @summary The title of the main window upon program launch

View File

@ -27,6 +27,7 @@ import * as availableDrives from '../../models/available-drives';
import * as flashState from '../../models/flash-state'; import * as flashState from '../../models/flash-state';
import * as selection from '../../models/selection-state'; import * as selection from '../../models/selection-state';
import * as analytics from '../../modules/analytics'; import * as analytics from '../../modules/analytics';
import { scanner as driveScanner } from '../../modules/drive-scanner';
import * as imageWriter from '../../modules/image-writer'; import * as imageWriter from '../../modules/image-writer';
import * as notification from '../../os/notification'; import * as notification from '../../os/notification';
import { import {
@ -94,6 +95,10 @@ async function flashImageToDrive(
return ''; return '';
} }
// Stop scanning drives when flashing
// otherwise Windows throws EPERM
driveScanner.stop();
const iconPath = path.join('media', 'icon.png'); const iconPath = path.join('media', 'icon.png');
const basename = path.basename(image.path); const basename = path.basename(image.path);
try { try {
@ -105,7 +110,7 @@ async function flashImageToDrive(
cancelled, cancelled,
} = flashState.getFlashResults(); } = flashState.getFlashResults();
if (!skip && !cancelled) { if (!skip && !cancelled) {
if (results?.devices?.successful > 0) { if (results.devices.successful > 0) {
notifySuccess(iconPath, basename, drives, results.devices); notifySuccess(iconPath, basename, drives, results.devices);
} else { } else {
notifyFailure(iconPath, basename, drives); notifyFailure(iconPath, basename, drives);
@ -124,6 +129,7 @@ async function flashImageToDrive(
return errorMessage; return errorMessage;
} finally { } finally {
availableDrives.setDrives([]); availableDrives.setDrives([]);
driveScanner.start();
} }
return ''; return '';
@ -198,7 +204,9 @@ export class FlashStep extends React.PureComponent<
private handleFlashErrorResponse(shouldRetry: boolean) { private handleFlashErrorResponse(shouldRetry: boolean) {
this.setState({ errorMessage: '' }); this.setState({ errorMessage: '' });
flashState.resetState(); flashState.resetState();
if (!shouldRetry) { if (shouldRetry) {
analytics.logEvent('Restart after failure');
} else {
selection.clear(); selection.clear();
} }
} }

View File

@ -14,21 +14,22 @@
* limitations under the License. * limitations under the License.
*/ */
import CogSvg from '@fortawesome/fontawesome-free/svgs/solid/gear.svg'; import CogSvg from '@fortawesome/fontawesome-free/svgs/solid/cog.svg';
import CloseSvg from '@fortawesome/fontawesome-free/svgs/solid/x.svg'; import QuestionCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/question-circle.svg';
import QuestionCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle-question.svg';
import * as path from 'path'; import * as path from 'path';
import prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import * as React from 'react'; import * as React from 'react';
import { Alert, Flex, Link } from 'rendition'; import { Flex } from 'rendition';
import styled from 'styled-components'; import styled from 'styled-components';
import FinishPage from '../../components/finish/finish'; import FinishPage from '../../components/finish/finish';
import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/reduced-flashing-infos'; import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/reduced-flashing-infos';
import { SettingsModal } from '../../components/settings/settings'; import { SettingsModal } from '../../components/settings/settings';
import { SourceSelector } from '../../components/source-selector/source-selector'; import {
import type { SourceMetadata } from '../../../../shared/typings/source-selector'; SourceMetadata,
SourceSelector,
} from '../../components/source-selector/source-selector';
import * as flashState from '../../models/flash-state'; import * as flashState from '../../models/flash-state';
import * as selectionState from '../../models/selection-state'; import * as selectionState from '../../models/selection-state';
import * as settings from '../../models/settings'; import * as settings from '../../models/settings';
@ -36,7 +37,6 @@ import { observe } from '../../models/store';
import { open as openExternal } from '../../os/open-external/services/open-external'; import { open as openExternal } from '../../os/open-external/services/open-external';
import { import {
IconButton as BaseIcon, IconButton as BaseIcon,
IconButton,
ThemedProvider, ThemedProvider,
} from '../../styled-components'; } from '../../styled-components';
@ -48,7 +48,6 @@ import { FlashStep } from './Flash';
import EtcherSvg from '../../../assets/etcher.svg'; import EtcherSvg from '../../../assets/etcher.svg';
import { SafeWebview } from '../../components/safe-webview/safe-webview'; import { SafeWebview } from '../../components/safe-webview/safe-webview';
import { theme } from '../../theme';
const Icon = styled(BaseIcon)` const Icon = styled(BaseIcon)`
margin-right: 20px; margin-right: 20px;
@ -100,8 +99,6 @@ const StepBorder = styled.div<{
margin-left: ${(props) => (props.right ? '-120px' : undefined)}; margin-left: ${(props) => (props.right ? '-120px' : undefined)};
`; `;
const ANALYTICS_ALERT_VISIBILITY_KEY = 'analytics_alert_visible';
interface MainPageStateFromStore { interface MainPageStateFromStore {
isFlashing: boolean; isFlashing: boolean;
hasImage: boolean; hasImage: boolean;
@ -118,21 +115,18 @@ interface MainPageState {
isWebviewShowing: boolean; isWebviewShowing: boolean;
hideSettings: boolean; hideSettings: boolean;
featuredProjectURL?: string; featuredProjectURL?: string;
analyticsAlertIsVisible: boolean;
} }
export class MainPage extends React.Component< export class MainPage extends React.Component<
object, {},
MainPageState & MainPageStateFromStore MainPageState & MainPageStateFromStore
> { > {
constructor(props: object) { constructor(props: {}) {
super(props); super(props);
this.state = { this.state = {
current: 'main', current: 'main',
isWebviewShowing: false, isWebviewShowing: false,
hideSettings: true, hideSettings: true,
analyticsAlertIsVisible:
localStorage.getItem(ANALYTICS_ALERT_VISIBILITY_KEY) !== 'false',
...this.stateHelper(), ...this.stateHelper(),
}; };
} }
@ -161,13 +155,6 @@ export class MainPage extends React.Component<
return url.toString(); return url.toString();
} }
private hideAnalyticsAlert = () => {
if (this.state.analyticsAlertIsVisible) {
localStorage.setItem(ANALYTICS_ALERT_VISIBILITY_KEY, 'false');
this.setState({ analyticsAlertIsVisible: false });
}
};
public async componentDidMount() { public async componentDidMount() {
observe(() => { observe(() => {
this.setState(this.stateHelper()); this.setState(this.stateHelper());
@ -175,17 +162,6 @@ export class MainPage extends React.Component<
this.setState({ featuredProjectURL: await this.getFeaturedProjectURL() }); this.setState({ featuredProjectURL: await this.getFeaturedProjectURL() });
} }
public componentDidUpdate(
_prevProps: object,
prevState: Readonly<MainPageState & MainPageStateFromStore>,
) {
if (this.state.analyticsAlertIsVisible) {
if (prevState.hideSettings !== this.state.hideSettings) {
this.setState({ analyticsAlertIsVisible: false });
}
}
}
private renderMain() { private renderMain() {
const state = flashState.getFlashState(); const state = flashState.getFlashState();
const shouldDriveStepBeDisabled = !this.state.hasImage; const shouldDriveStepBeDisabled = !this.state.hasImage;
@ -195,127 +171,86 @@ export class MainPage extends React.Component<
!this.state.isFlashing || !this.state.isWebviewShowing; !this.state.isFlashing || !this.state.isWebviewShowing;
return ( return (
<Flex <Flex
m={`110px ${this.state.isWebviewShowing ? 35 : 55}px 18px ${this.state.isWebviewShowing ? 35 : 55}px`} m={`110px ${this.state.isWebviewShowing ? 35 : 55}px`}
flexDirection="column" justifyContent="space-between"
> >
<Flex {notFlashingOrSplitView && (
justifyContent="space-between" <>
mb={this.state.analyticsAlertIsVisible ? '0px' : '92px'} <SourceSelector flashing={this.state.isFlashing} />
> <Flex>
{notFlashingOrSplitView && ( <StepBorder disabled={shouldDriveStepBeDisabled} left />
<>
<SourceSelector
flashing={this.state.isFlashing}
hideAnalyticsAlert={this.hideAnalyticsAlert}
/>
<Flex>
<StepBorder disabled={shouldDriveStepBeDisabled} left />
</Flex>
<TargetSelector
disabled={shouldDriveStepBeDisabled}
hasDrive={this.state.hasDrive}
flashing={this.state.isFlashing}
hideAnalyticsAlert={this.hideAnalyticsAlert}
/>
<Flex>
<StepBorder disabled={shouldFlashStepBeDisabled} right />
</Flex>
</>
)}
{this.state.isFlashing && this.state.isWebviewShowing && (
<Flex
style={{
position: 'absolute',
top: 0,
left: 0,
width: '36.2vw',
height: '100vh',
zIndex: 1,
boxShadow: '0 2px 15px 0 rgba(0, 0, 0, 0.2)',
}}
>
<ReducedFlashingInfos
imageLogo={this.state.imageLogo}
imageName={this.state.imageName}
imageSize={
typeof this.state.imageSize === 'number'
? prettyBytes(this.state.imageSize)
: ''
}
driveTitle={this.state.driveTitle}
driveLabel={this.state.driveLabel}
style={{
position: 'absolute',
color: '#fff',
left: 35,
top: 72,
}}
/>
</Flex> </Flex>
)} <TargetSelector
{this.state.isFlashing && this.state.featuredProjectURL && ( disabled={shouldDriveStepBeDisabled}
<SafeWebview hasDrive={this.state.hasDrive}
src={this.state.featuredProjectURL} flashing={this.state.isFlashing}
onWebviewShow={(isWebviewShowing: boolean) => { />
this.setState({ isWebviewShowing }); <Flex>
}} <StepBorder disabled={shouldFlashStepBeDisabled} right />
</Flex>
</>
)}
{this.state.isFlashing && this.state.isWebviewShowing && (
<Flex
style={{
position: 'absolute',
top: 0,
left: 0,
width: '36.2vw',
height: '100vh',
zIndex: 1,
boxShadow: '0 2px 15px 0 rgba(0, 0, 0, 0.2)',
}}
>
<ReducedFlashingInfos
imageLogo={this.state.imageLogo}
imageName={this.state.imageName}
imageSize={
typeof this.state.imageSize === 'number'
? prettyBytes(this.state.imageSize)
: ''
}
driveTitle={this.state.driveTitle}
driveLabel={this.state.driveLabel}
style={{ style={{
position: 'absolute', position: 'absolute',
right: 0, color: '#fff',
bottom: 0, left: 35,
width: '63.8vw', top: 72,
height: '100vh',
}} }}
/> />
)} </Flex>
<FlashStep
width={this.state.isWebviewShowing ? '220px' : '200px'}
goToSuccess={() => this.setState({ current: 'success' })}
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
isFlashing={this.state.isFlashing}
step={state.type}
percentage={state.percentage}
position={state.position}
failed={state.failed}
speed={state.speed}
eta={state.eta}
style={{ zIndex: 1 }}
/>
</Flex>
{this.state.analyticsAlertIsVisible && (
<Alert mt="18px" style={{ boxShadow: 'none', fontSize: '12px' }}>
<Flex alignItems="center" justifyContent="space-between">
<Flex flexDirection="column">
<div>
Etcher collects a limited amount of anonymous data to help us
improve user experience. You can opt out in the{' '}
<Link onClick={() => this.setState({ hideSettings: false })}>
settings
</Link>
.
</div>
<div>
For more information about how we use this data, see our{' '}
<Link
onClick={(e) => {
e.stopPropagation();
openExternal('https://www.balena.io/privacy-policy');
}}
>
privacy policy
</Link>
.
</div>
</Flex>
{/* TODO: can we use onDismiss instead? */}
<IconButton onClick={this.hideAnalyticsAlert}>
<CloseSvg height="0.75rem" fill={theme.colors.text.main} />
</IconButton>
</Flex>
</Alert>
)} )}
{this.state.isFlashing && this.state.featuredProjectURL && (
<SafeWebview
src={this.state.featuredProjectURL}
onWebviewShow={(isWebviewShowing: boolean) => {
this.setState({ isWebviewShowing });
}}
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: '63.8vw',
height: '100vh',
}}
/>
)}
<FlashStep
width={this.state.isWebviewShowing ? '220px' : '200px'}
goToSuccess={() => this.setState({ current: 'success' })}
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
isFlashing={this.state.isFlashing}
step={state.type}
percentage={state.percentage}
position={state.position}
failed={state.failed}
speed={state.speed}
eta={state.eta}
style={{ zIndex: 1 }}
/>
</Flex> </Flex>
); );
} }

View File

@ -1,12 +0,0 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
import * as webapi from '../webapi';
declare global {
interface Window {
etcher: typeof webapi;
}
}
window['etcher'] = webapi;

View File

@ -6,4 +6,10 @@ import { ipcRenderer } from 'electron';
ipcRenderer.send('change-lng', langParser()); ipcRenderer.send('change-lng', langParser());
if (module.hot) {
module.hot.accept('./app', () => {
main();
});
}
main(); main();

View File

@ -14,19 +14,18 @@
* limitations under the License. * limitations under the License.
*/ */
import * as _ from 'lodash';
import * as React from 'react'; import * as React from 'react';
import type {
FlexProps,
ButtonProps,
TableProps as BaseTableProps,
} from 'rendition';
import { import {
Alert as AlertBase, Alert as AlertBase,
Flex, Flex,
FlexProps,
Button, Button,
ButtonProps,
Modal as ModalBase, Modal as ModalBase,
Provider, Provider,
Table as BaseTable, Table as BaseTable,
TableProps as BaseTableProps,
Txt, Txt,
} from 'rendition'; } from 'rendition';
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
@ -114,25 +113,14 @@ export const DetailsText = (props: FlexProps) => (
const modalFooterShadowCss = css` const modalFooterShadowCss = css`
overflow: auto; overflow: auto;
background: background: 0, linear-gradient(rgba(255, 255, 255, 0), white 70%) 0 100%, 0,
0,
linear-gradient(rgba(255, 255, 255, 0), white 70%) 0 100%,
0,
linear-gradient(rgba(255, 255, 255, 0), rgba(221, 225, 240, 0.5) 70%) 0 100%; linear-gradient(rgba(255, 255, 255, 0), rgba(221, 225, 240, 0.5) 70%) 0 100%;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: background-size: 100% 40px, 100% 40px, 100% 8px, 100% 8px;
100% 40px,
100% 40px,
100% 8px,
100% 8px;
background-repeat: no-repeat; background-repeat: no-repeat;
background-color: white; background-color: white;
background-size: background-size: 100% 40px, 100% 40px, 100% 8px, 100% 8px;
100% 40px,
100% 40px,
100% 8px,
100% 8px;
background-attachment: local, local, scroll, scroll; background-attachment: local, local, scroll, scroll;
`; `;
@ -248,15 +236,16 @@ export interface GenericTableProps<T> extends BaseTableProps<T> {
showWarnings?: boolean; showWarnings?: boolean;
} }
function GenericTable<T>( const GenericTable: <T>(
props: GenericTableProps<T>, props: GenericTableProps<T>,
): React.ReactElement<GenericTableProps<T>> { ) => React.ReactElement<GenericTableProps<T>> = <T extends {}>({
return ( refFn,
<div> ...props
<BaseTable<T> ref={props.refFn} {...props} /> }: GenericTableProps<T>) => (
</div> <div>
); <BaseTable<T> ref={refFn} {...props} />
} </div>
);
function StyledTable<T>() { function StyledTable<T>() {
return styled((props: GenericTableProps<T>) => ( return styled((props: GenericTableProps<T>) => (
@ -295,6 +284,7 @@ function StyledTable<T>() {
[data-display='table-body'] > [data-display='table-row'] { [data-display='table-body'] > [data-display='table-row'] {
> [data-display='table-cell']:first-child { > [data-display='table-cell']:first-child {
padding-left: 15px; padding-left: 15px;
width: 6%;
} }
> [data-display='table-cell']:last-child { > [data-display='table-cell']:last-child {
@ -329,7 +319,7 @@ function StyledTable<T>() {
`; `;
} }
export const Table = <T extends object>(props: GenericTableProps<T>) => { export const Table = <T extends {}>(props: GenericTableProps<T>) => {
const TypedStyledFunctional = StyledTable<T>(); const TypedStyledFunctional = StyledTable<T>();
return <TypedStyledFunctional {...props} />; return <TypedStyledFunctional {...props} />;
}; };

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import type { Dictionary } from 'lodash'; import { Dictionary } from 'lodash';
type BalenaTag = { type BalenaTag = {
id: number; id: number;

View File

@ -14,12 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;
import * as electron from 'electron'; import * as electron from 'electron';
import * as remoteMain from '@electron/remote/main'; import * as remoteMain from '@electron/remote/main';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
@ -27,7 +21,7 @@ import { promises as fs } from 'fs';
import { platform } from 'os'; import { platform } from 'os';
import * as path from 'path'; import * as path from 'path';
import * as semver from 'semver'; import * as semver from 'semver';
import { once } from 'lodash'; import * as lodash from 'lodash';
import './app/i18n'; import './app/i18n';
@ -37,10 +31,9 @@ import * as settings from './app/models/settings';
import { buildWindowMenu } from './menu'; import { buildWindowMenu } from './menu';
import * as i18n from 'i18next'; import * as i18n from 'i18next';
import * as SentryMain from '@sentry/electron/main'; import * as SentryMain from '@sentry/electron/main';
import * as packageJSON from '../../package.json';
import { anonymizeSentryData } from './app/modules/analytics'; import { anonymizeSentryData } from './app/modules/analytics';
import { delay } from '../shared/utils';
const customProtocol = 'etcher'; const customProtocol = 'etcher';
const scheme = `${customProtocol}://`; const scheme = `${customProtocol}://`;
const updatablePackageTypes = ['appimage', 'nsis', 'dmg']; const updatablePackageTypes = ['appimage', 'nsis', 'dmg'];
@ -114,16 +107,12 @@ async function getCommandLineURL(argv: string[]): Promise<string | undefined> {
} }
} }
const initSentryMain = once(() => { const initSentryMain = lodash.once(() => {
const dsn = const dsn =
settings.getSync('analyticsSentryToken') || process.env.SENTRY_TOKEN; settings.getSync('analyticsSentryToken') ||
lodash.get(packageJSON, ['analytics', 'sentry', 'token']);
SentryMain.init({ SentryMain.init({ dsn, beforeSend: anonymizeSentryData });
dsn,
beforeSend: anonymizeSentryData,
debug: process.env.ETCHER_SENTRY_DEBUG === 'true',
});
console.log(SentryMain.getCurrentScope());
}); });
const sourceSelectorReady = new Promise((resolve) => { const sourceSelectorReady = new Promise((resolve) => {
@ -149,6 +138,14 @@ electron.app.on('open-url', async (event, data) => {
await selectImageURL(data); await selectImageURL(data);
}); });
interface AutoUpdaterConfig {
autoDownload?: boolean;
autoInstallOnAppQuit?: boolean;
allowPrerelease?: boolean;
fullChangelog?: boolean;
allowDowngrade?: boolean;
}
async function createMainWindow() { async function createMainWindow() {
const fullscreen = Boolean(await settings.get('fullscreen')); const fullscreen = Boolean(await settings.get('fullscreen'));
const defaultWidth = settings.DEFAULT_WIDTH; const defaultWidth = settings.DEFAULT_WIDTH;
@ -179,13 +176,12 @@ async function createMainWindow() {
contextIsolation: false, contextIsolation: false,
webviewTag: true, webviewTag: true,
zoomFactor: width / defaultWidth, zoomFactor: width / defaultWidth,
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
}, },
}); });
electron.app.setAsDefaultProtocolClient(customProtocol); electron.app.setAsDefaultProtocolClient(customProtocol);
// mainWindow.setFullScreen(true); mainWindow.setFullScreen(true);
// Prevent flash of white when starting the application // Prevent flash of white when starting the application
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
@ -199,11 +195,17 @@ async function createMainWindow() {
// Prevent external resources from being loaded (like images) // Prevent external resources from being loaded (like images)
// when dropping them on the WebView. // when dropping them on the WebView.
// See https://github.com/electron/electron/issues/5919 // See https://github.com/electron/electron/issues/5919
mainWindow.webContents.on('will-navigate', (event: any) => { mainWindow.webContents.on('will-navigate', (event) => {
event.preventDefault(); event.preventDefault();
}); });
mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); mainWindow.loadURL(
`file://${path.join(
'/',
...__dirname.split(path.sep).map(encodeURIComponent),
'index.html',
)}`,
);
const page = mainWindow.webContents; const page = mainWindow.webContents;
remoteMain.enable(page); remoteMain.enable(page);
@ -239,20 +241,6 @@ electron.app.on('before-quit', () => {
process.exit(EXIT_CODES.SUCCESS); process.exit(EXIT_CODES.SUCCESS);
}); });
// this is replaced at build-time with the path to helper binary,
// relative to the app resources directory.
declare const ETCHER_UTIL_BIN_PATH: string;
electron.ipcMain.handle('get-util-path', () => {
if (process.env.NODE_ENV === 'development') {
// In development there is no "app bundle" and we're working directly with
// artifacts from the "out" directory, where this value point to.
return ETCHER_UTIL_BIN_PATH;
}
// In any other case, resolve the helper relative to resources path.
return path.resolve(process.resourcesPath, ETCHER_UTIL_BIN_PATH);
});
async function main(): Promise<void> { async function main(): Promise<void> {
if (!electron.app.requestSingleInstanceLock()) { if (!electron.app.requestSingleInstanceLock()) {
electron.app.quit(); electron.app.quit();
@ -284,7 +272,7 @@ async function main(): Promise<void> {
const webview = electron.webContents.fromId(id); const webview = electron.webContents.fromId(id);
// Open link in browser if it's opened as a 'foreground-tab' // Open link in browser if it's opened as a 'foreground-tab'
webview!.setWindowOpenHandler((event) => { webview.setWindowOpenHandler((event) => {
const url = new URL(event.url); const url = new URL(event.url);
if ( if (
(url.protocol === 'http:' || url.protocol === 'https:') && (url.protocol === 'http:' || url.protocol === 'https:') &&
@ -299,13 +287,6 @@ async function main(): Promise<void> {
}); });
} }
} }
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
// tslint:disable-next-line:no-var-requires
if (require('electron-squirrel-startup')) {
electron.app.quit();
}
main(); main();
console.time('ready-to-show'); console.time('ready-to-show');

View File

@ -0,0 +1,333 @@
/*
* 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 { Drive as DrivelistDrive } from 'drivelist';
import {
BlockDevice,
File,
Http,
Metadata,
SourceDestination,
} from 'etcher-sdk/build/source-destination';
import {
MultiDestinationProgress,
OnProgressFunction,
OnFailFunction,
decompressThenFlash,
DECOMPRESSED_IMAGE_PREFIX,
} from 'etcher-sdk/build/multi-write';
import { cleanupTmpFiles } from 'etcher-sdk/build/tmp';
import * as ipc from 'node-ipc';
import { totalmem } from 'os';
import { toJSON } from '../../shared/errors';
import { GENERAL_ERROR, SUCCESS } from '../../shared/exit-codes';
import { delay, isJson } from '../../shared/utils';
import { SourceMetadata } from '../app/components/source-selector/source-selector';
import axios from 'axios';
import * as _ from 'lodash';
ipc.config.id = process.env.IPC_CLIENT_ID as string;
ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string;
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
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 this process as well.
// @ts-ignore (0 is a valid value for stopRetrying and is not the same as false)
ipc.config.stopRetrying = 0;
const DISCONNECT_DELAY = 100;
const IPC_SERVER_ID = process.env.IPC_SERVER_ID as string;
/**
* @summary Send a log debug message to the IPC server
*/
function log(message: string) {
ipc.of[IPC_SERVER_ID].emit('log', message);
}
/**
* @summary Terminate the child writer process
*/
async function terminate(exitCode: number) {
ipc.disconnect(IPC_SERVER_ID);
await cleanupTmpFiles(Date.now(), DECOMPRESSED_IMAGE_PREFIX);
process.nextTick(() => {
process.exit(exitCode || SUCCESS);
});
}
/**
* @summary Handle a child writer error
*/
async function handleError(error: Error) {
ipc.of[IPC_SERVER_ID].emit('error', toJSON(error));
await delay(DISCONNECT_DELAY);
await terminate(GENERAL_ERROR);
}
export interface FlashError extends Error {
description: string;
device: string;
code: string;
}
export interface WriteResult {
bytesWritten?: number;
devices?: {
failed: number;
successful: number;
};
errors: FlashError[];
sourceMetadata?: Metadata;
}
export interface FlashResults extends WriteResult {
skip?: boolean;
cancelled?: boolean;
}
/**
* @summary writes the source to the destinations and validates the writes
* @param {SourceDestination} source - source
* @param {SourceDestination[]} destinations - destinations
* @param {Boolean} verify - whether to validate the writes or not
* @param {Boolean} autoBlockmapping - whether to trim ext partitions before writing
* @param {Function} onProgress - function to call on progress
* @param {Function} onFail - function to call on fail
* @returns {Promise<{ bytesWritten, devices, errors} >}
*/
async function writeAndValidate({
source,
destinations,
verify,
autoBlockmapping,
decompressFirst,
onProgress,
onFail,
}: {
source: SourceDestination;
destinations: BlockDevice[];
verify: boolean;
autoBlockmapping: boolean;
decompressFirst: boolean;
onProgress: OnProgressFunction;
onFail: OnFailFunction;
}): Promise<WriteResult> {
const { sourceMetadata, failures, bytesWritten } = await decompressThenFlash({
source,
destinations,
onFail,
onProgress,
verify,
trim: autoBlockmapping,
numBuffers: Math.min(
2 + (destinations.length - 1) * 32,
256,
Math.floor(totalmem() / 1024 ** 2 / 8),
),
decompressFirst,
});
const result: WriteResult = {
bytesWritten,
devices: {
failed: failures.size,
successful: destinations.length - failures.size,
},
errors: [],
sourceMetadata,
};
for (const [destination, error] of failures) {
const err = error as FlashError;
const drive = destination as BlockDevice;
err.device = drive.device;
err.description = drive.description;
result.errors.push(err);
}
return result;
}
interface WriteOptions {
image: SourceMetadata;
destinations: DrivelistDrive[];
autoBlockmapping: boolean;
decompressFirst: boolean;
SourceType: string;
httpRequest?: any;
}
ipc.connectTo(IPC_SERVER_ID, () => {
// Remove leftover tmp files older than 1 hour
cleanupTmpFiles(Date.now() - 60 * 60 * 1000);
process.once('uncaughtException', handleError);
// Gracefully exit on the following cases. If the parent
// process detects that child exit successfully but
// no flashing information is available, then it will
// assume that the child died halfway through.
process.once('SIGINT', async () => {
await terminate(SUCCESS);
});
process.once('SIGTERM', async () => {
await terminate(SUCCESS);
});
// The IPC server failed. Abort.
ipc.of[IPC_SERVER_ID].on('error', async () => {
await terminate(SUCCESS);
});
// The IPC server was disconnected. Abort.
ipc.of[IPC_SERVER_ID].on('disconnect', async () => {
await terminate(SUCCESS);
});
ipc.of[IPC_SERVER_ID].on('write', async (options: WriteOptions) => {
/**
* @summary Progress handler
* @param {Object} state - progress state
* @example
* writer.on('progress', onProgress)
*/
const onProgress = (state: MultiDestinationProgress) => {
ipc.of[IPC_SERVER_ID].emit('state', state);
};
let exitCode = SUCCESS;
/**
* @summary Abort handler
* @example
* writer.on('abort', onAbort)
*/
const onAbort = async () => {
log('Abort');
ipc.of[IPC_SERVER_ID].emit('abort');
await delay(DISCONNECT_DELAY);
await terminate(exitCode);
};
const onSkip = async () => {
log('Skip validation');
ipc.of[IPC_SERVER_ID].emit('skip');
await delay(DISCONNECT_DELAY);
await terminate(exitCode);
};
ipc.of[IPC_SERVER_ID].on('cancel', onAbort);
ipc.of[IPC_SERVER_ID].on('skip', onSkip);
/**
* @summary Failure handler (non-fatal errors)
* @param {SourceDestination} destination - destination
* @param {Error} error - error
* @example
* writer.on('fail', onFail)
*/
const onFail = (destination: SourceDestination, error: Error) => {
ipc.of[IPC_SERVER_ID].emit('fail', {
// TODO: device should be destination
// @ts-ignore (destination.drive is private)
device: destination.drive,
error: toJSON(error),
});
};
const destinations = options.destinations.map((d) => d.device);
const imagePath = options.image.path;
log(`Image: ${imagePath}`);
log(`Devices: ${destinations.join(', ')}`);
log(`Auto blockmapping: ${options.autoBlockmapping}`);
log(`Decompress first: ${options.decompressFirst}`);
const dests = options.destinations.map((destination) => {
return new BlockDevice({
drive: destination,
unmountOnSuccess: true,
write: true,
direct: true,
});
});
const { SourceType } = options;
try {
let source;
if (options.image.drive) {
source = new BlockDevice({
drive: options.image.drive,
direct: !options.autoBlockmapping,
});
} else {
if (SourceType === File.name) {
source = new File({
path: imagePath,
});
} else {
const decodedImagePath = decodeURIComponent(imagePath);
if (isJson(decodedImagePath)) {
const imagePathObject = JSON.parse(decodedImagePath);
source = new Http({
url: imagePathObject.url,
avoidRandomAccess: true,
axiosInstance: axios.create(_.omit(imagePathObject, ['url'])),
auth: options.image.auth,
});
} else {
source = new Http({
url: imagePath,
avoidRandomAccess: true,
auth: options.image.auth,
});
}
}
}
const results = await writeAndValidate({
source,
destinations: dests,
verify: true,
autoBlockmapping: options.autoBlockmapping,
decompressFirst: options.decompressFirst,
onProgress,
onFail,
});
log(`Finish: ${results.bytesWritten}`);
results.errors = results.errors.map((error) => {
return toJSON(error);
});
ipc.of[IPC_SERVER_ID].emit('done', { results });
await delay(DISCONNECT_DELAY);
await terminate(exitCode);
} catch (error: any) {
exitCode = GENERAL_ERROR;
ipc.of[IPC_SERVER_ID].emit('error', toJSON(error));
}
});
ipc.of[IPC_SERVER_ID].on('connect', () => {
log(
`Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`,
);
ipc.of[IPC_SERVER_ID].emit('ready', {});
});
});

View File

@ -1,15 +0,0 @@
//
// Anything exported from this module will become available to the
// renderer process via preload. They're accessible as `window.etcher.foo()`.
//
import { ipcRenderer } from 'electron';
// FIXME: this is a workaround for the renderer to be able to find the etcher-util
// binary. We should instead export a function that asks the main process to launch
// the binary itself.
export async function getEtcherUtilPath(): Promise<string> {
const utilPath = await ipcRenderer.invoke('get-util-path');
console.log(utilPath);
return utilPath;
}

View File

@ -0,0 +1,72 @@
/*
* 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 { execFile } from 'child_process';
import { join } from 'path';
import { env } from 'process';
import { promisify } from 'util';
import { getAppPath } from '../utils';
import { supportedLocales } from '../../gui/app/i18n';
const execFileAsync = promisify(execFile);
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`;
export async function sudo(
command: string,
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
try {
let lang = Intl.DateTimeFormat().resolvedOptions().locale;
lang = lang.substr(0, 2);
if (supportedLocales.indexOf(lang) > -1) {
// language should be present
} else {
// fallback to eng
lang = 'en';
}
const { stdout, stderr } = await execFileAsync(
'sudo',
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`],
{
encoding: 'utf8',
env: {
PATH: env.PATH,
SUDO_ASKPASS: join(
getAppPath(),
__dirname,
`sudo-askpass.osascript-${lang}.js`,
),
},
},
);
return {
cancelled: false,
stdout: stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length),
stderr,
};
} catch (error: any) {
if (error.code === 1) {
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {
return { cancelled: true };
}
error.stdout = error.stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length);
}
throw error;
}
}

View File

@ -14,12 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
import type { Drive } from 'drivelist'; import { Drive } from 'drivelist';
import { isNil } from 'lodash'; import * as _ from 'lodash';
import * as pathIsInside from 'path-is-inside'; import * as pathIsInside from 'path-is-inside';
import * as messages from './messages'; import * as messages from './messages';
import type { SourceMetadata } from './typings/source-selector'; import { SourceMetadata } from '../gui/app/components/source-selector/source-selector';
/** /**
* @summary The default unknown size for things such as images and drives * @summary The default unknown size for things such as images and drives
@ -210,8 +210,8 @@ export function getDriveImageCompatibilityStatuses(
}); });
} }
if ( if (
!isNil(drive) && !_.isNil(drive) &&
!isNil(drive.size) && !_.isNil(drive.size) &&
!isDriveLargeEnough(drive, image) !isDriveLargeEnough(drive, image)
) { ) {
statusList.push(statuses.small); statusList.push(statuses.small);
@ -229,7 +229,7 @@ export function getDriveImageCompatibilityStatuses(
if ( if (
image !== undefined && image !== undefined &&
!isNil(drive) && !_.isNil(drive) &&
!isDriveSizeRecommended(drive, image) !isDriveSizeRecommended(drive, image)
) { ) {
statusList.push(statuses.sizeNotRecommended); statusList.push(statuses.sizeNotRecommended);

View File

@ -14,9 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import type { Dictionary } from 'lodash'; import { Dictionary } from 'lodash';
import { outdent } from 'outdent'; import { outdent } from 'outdent';
import prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import '../gui/app/i18n'; import '../gui/app/i18n';
import * as i18next from 'i18next'; import * as i18next from 'i18next';
@ -164,11 +164,11 @@ export const error = {
? i18next.t('message.toDrive', { ? i18next.t('message.toDrive', {
description: drives[0].description, description: drives[0].description,
name: drives[0].displayName, name: drives[0].displayName,
}) })
: i18next.t('message.toTarget', { : i18next.t('message.toTarget', {
count: drives.length, count: drives.length,
num: drives.length, num: drives.length,
}); });
return i18next.t('message.flashError', { return i18next.t('message.flashError', {
image: imageBasename, image: imageBasename,
targets: target, targets: target,

View File

@ -14,27 +14,41 @@
* limitations under the License. * limitations under the License.
*/ */
/** import * as childProcess from 'child_process';
* TODO:
* This is convoluted and needlessly complex. It should be simplified and modernized.
* The environment variable setting and escaping should be greatly simplified by letting {linux|catalina}-sudo handle that.
* We shouldn't need to write a script to a file and then execute it. We should be able to forwatd the command to the sudo code directly.
*/
import { spawn, exec } from 'child_process';
import { withTmpFile } from 'etcher-sdk/build/tmp'; import { withTmpFile } from 'etcher-sdk/build/tmp';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { promisify } from 'util';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as os from 'os'; import * as os from 'os';
import * as semver from 'semver'; import * as semver from 'semver';
import * as sudoPrompt from '@balena/sudo-prompt';
import { promisify } from 'util';
import { sudo as darwinSudo } from './sudo/darwin'; import { sudo as catalinaSudo } from './catalina-sudo/sudo';
import { sudo as linuxSudo } from './sudo/linux';
import { sudo as winSudo } from './sudo/windows';
import * as errors from './errors'; import * as errors from './errors';
const execAsync = promisify(exec); const execAsync = promisify(childProcess.exec);
const execFileAsync = promisify(childProcess.execFile);
type Std = string | Buffer | undefined;
function sudoExecAsync(
cmd: string,
options: { name: string },
): Promise<{ stdout: Std; stderr: Std }> {
return new Promise((resolve, reject) => {
sudoPrompt.exec(
cmd,
options,
(error: Error | undefined, stdout: Std, stderr: Std) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
},
);
});
}
/** /**
* @summary The user id of the UNIX "superuser" * @summary The user id of the UNIX "superuser"
@ -56,14 +70,14 @@ export async function isElevated(): Promise<boolean> {
} }
return true; return true;
} }
return process.geteuid!() === UNIX_SUPERUSER_USER_ID; return process.geteuid() === UNIX_SUPERUSER_USER_ID;
} }
/** /**
* @summary Check if the current process is running with elevated permissions * @summary Check if the current process is running with elevated permissions
*/ */
export function isElevatedUnixSync(): boolean { export function isElevatedUnixSync(): boolean {
return process.geteuid!() === UNIX_SUPERUSER_USER_ID; return process.geteuid() === UNIX_SUPERUSER_USER_ID;
} }
function escapeSh(value: any): string { function escapeSh(value: any): string {
@ -111,11 +125,10 @@ export function createLaunchScript(
async function elevateScriptWindows( async function elevateScriptWindows(
path: string, path: string,
name: string, name: string,
env: any,
): Promise<{ cancelled: false }> { ): Promise<{ cancelled: false }> {
// '&' needs to be escaped here (but not when written to a .cmd file) // '&' needs to be escaped here (but not when written to a .cmd file)
const cmd = ['cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&')].join(' '); const cmd = ['cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&')].join(' ');
await winSudo(cmd, name, env); await sudoExecAsync(cmd, { name });
return { cancelled: false }; return { cancelled: false };
} }
@ -124,7 +137,7 @@ async function elevateScriptUnix(
name: string, name: string,
): Promise<{ cancelled: boolean }> { ): Promise<{ cancelled: boolean }> {
const cmd = ['bash', escapeSh(path)].join(' '); const cmd = ['bash', escapeSh(path)].join(' ');
await linuxSudo(cmd, { name }); await sudoExecAsync(cmd, { name });
return { cancelled: false }; return { cancelled: false };
} }
@ -133,7 +146,7 @@ async function elevateScriptCatalina(
): Promise<{ cancelled: boolean }> { ): Promise<{ cancelled: boolean }> {
const cmd = ['bash', escapeSh(path)].join(' '); const cmd = ['bash', escapeSh(path)].join(' ');
try { try {
const { cancelled } = await darwinSudo(cmd); const { cancelled } = await catalinaSudo(cmd);
return { cancelled }; return { cancelled };
} catch (error: any) { } catch (error: any) {
throw errors.createError({ title: error.stderr }); throw errors.createError({ title: error.stderr });
@ -143,13 +156,13 @@ async function elevateScriptCatalina(
export async function elevateCommand( export async function elevateCommand(
command: string[], command: string[],
options: { options: {
env: _.Dictionary<string | undefined>; environment: _.Dictionary<string | undefined>;
applicationName: string; applicationName: string;
}, },
): Promise<{ cancelled: boolean }> { ): Promise<{ cancelled: boolean }> {
if (await isElevated()) { if (await isElevated()) {
spawn(command[0], command.slice(1), { await execFileAsync(command[0], command.slice(1), {
env: options.env, env: options.environment,
}); });
return { cancelled: false }; return { cancelled: false };
} }
@ -157,7 +170,7 @@ export async function elevateCommand(
const launchScript = createLaunchScript( const launchScript = createLaunchScript(
command[0], command[0],
command.slice(1), command.slice(1),
options.env, options.environment,
); );
return await withTmpFile( return await withTmpFile(
{ {
@ -168,7 +181,7 @@ export async function elevateCommand(
async ({ path }) => { async ({ path }) => {
await fs.writeFile(path, launchScript); await fs.writeFile(path, launchScript);
if (isWindows) { if (isWindows) {
return elevateScriptWindows(path, options.applicationName, options.env); return elevateScriptWindows(path, options.applicationName);
} }
if ( if (
os.platform() === 'darwin' && os.platform() === 'darwin' &&
@ -178,7 +191,7 @@ export async function elevateCommand(
return elevateScriptCatalina(path); return elevateScriptCatalina(path);
} }
try { try {
return elevateScriptUnix(path, options.applicationName); return await elevateScriptUnix(path, options.applicationName);
} catch (error: any) { } catch (error: any) {
// We're hardcoding internal error messages declared by `sudo-prompt`. // We're hardcoding internal error messages declared by `sudo-prompt`.
// There doesn't seem to be a better way to handle these errors, so // There doesn't seem to be a better way to handle these errors, so

View File

@ -1,102 +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 { spawn } from 'child_process';
import { join } from 'path';
import { env } from 'process';
// import { promisify } from "util";
import { supportedLocales } from '../../gui/app/i18n';
// const execFileAsync = promisify(execFile);
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`;
function getAskPassScriptPath(lang: string): string {
if (process.env.NODE_ENV === 'development') {
// Force webpack's hand to bundle the script.
return require.resolve(`./sudo-askpass.osascript-${lang}.js`);
}
// Otherwise resolve the script relative to resources path.
return join(process.resourcesPath, `sudo-askpass.osascript-${lang}.js`);
}
export async function sudo(
command: string,
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
try {
let lang = Intl.DateTimeFormat().resolvedOptions().locale;
lang = lang.substr(0, 2);
if (supportedLocales.indexOf(lang) > -1) {
// language should be present
} else {
// fallback to eng
lang = 'en';
}
const elevateProcess = spawn(
'sudo',
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`],
{
// encoding: "utf8",
env: {
PATH: env.PATH,
SUDO_ASKPASS: getAskPassScriptPath(lang),
},
},
);
let elevated = 'pending';
elevateProcess.stdout.on('data', (data) => {
if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
// if the first data comming out of the sudo command is the expected marker we resolve the promise
elevated = 'granted';
} else {
// if the first data comming out of the sudo command is not the expected marker we reject the promise
elevated = 'rejected';
}
});
// we don't spawn or read stdout in the promise otherwise resolving stop the process
return new Promise((resolve, reject) => {
const checkElevation = setInterval(() => {
if (elevated === 'granted') {
clearInterval(checkElevation);
resolve({ cancelled: false });
} else if (elevated === 'rejected') {
clearInterval(checkElevation);
resolve({ cancelled: true });
}
}, 300);
// if the elevation didn't occured in 30 seconds we reject the promise
setTimeout(() => {
clearInterval(checkElevation);
reject(new Error('Elevation timeout'));
}, 30000);
});
} catch (error: any) {
if (error.code === 1) {
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {
return { cancelled: true };
}
error.stdout = error.stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length);
}
throw error;
}
}

View File

@ -1,142 +0,0 @@
/*
* This is heavily inspired (read: a ripof) https://github.com/balena-io-modules/sudo-prompt
* Which was a fork of https://github.com/jorangreef/sudo-prompt
*
* This and the original code was released under The MIT License (MIT)
*
* Copyright (c) 2015 Joran Dirk Greef
* Copyright (c) 2024 Balena
*
The MIT License (MIT)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { spawn } from 'child_process';
import { access, constants } from 'fs/promises';
import { env } from 'process';
// const execFileAsync = promisify(execFile);
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
/** Check for kdesudo or pkexec */
function checkLinuxBinary() {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
// We used to prefer gksudo over pkexec since it enabled a better prompt.
// However, gksudo cannot run multiple commands concurrently.
const paths = ['/usr/bin/kdesudo', '/usr/bin/pkexec'];
for (const path of paths) {
try {
// check if the file exist and is executable
await access(path, constants.X_OK);
resolve(path);
} catch (error: any) {
continue;
}
}
reject('Unable to find pkexec or kdesudo.');
});
}
function escapeDoubleQuotes(escapeString: string) {
return escapeString.replace(/"/g, '\\"');
}
export async function sudo(
command: string,
{ name }: { name: string },
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
const linuxBinary: string = (await checkLinuxBinary()) as string;
if (!linuxBinary) {
throw new Error('Unable to find pkexec or kdesudo.');
}
const parameters = [];
if (/kdesudo/i.test(linuxBinary)) {
parameters.push(
'--comment',
`"${name} wants to make changes.
Enter your password to allow this."`,
);
parameters.push('-d'); // Do not show the command to be run in the dialog.
parameters.push('--');
} else if (/pkexec/i.test(linuxBinary)) {
parameters.push('--disable-internal-agent');
}
parameters.push('/bin/bash');
parameters.push('-c');
parameters.push(
`echo ${SUCCESSFUL_AUTH_MARKER} && ${escapeDoubleQuotes(command)}`,
);
const elevateProcess = spawn(linuxBinary, parameters, {
// encoding: "utf8",
env: {
PATH: env.PATH,
},
});
let elevated = '';
elevateProcess.stdout.on('data', (data) => {
// console.log(`stdout: ${data.toString()}`);
if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
// if the first data comming out of the sudo command is the expected marker we resolve the promise
elevated = 'granted';
} else {
// if the first data comming out of the sudo command is not the expected marker we reject the promise
elevated = 'refused';
}
});
// elevateProcess.stderr.on('data', (data) => {
// // console.log(`stderr: ${data.toString()}`);
// // if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
// // // if the first data comming out of the sudo command is the expected marker we resolve the promise
// // elevated = 'granted';
// // } else {
// // // if the first data comming out of the sudo command is not the expected marker we reject the promise
// // elevated = 'refused';
// // }
// });
// we don't spawn or read stdout in the promise otherwise resolving stop the process
return new Promise((resolve, reject) => {
const checkElevation = setInterval(() => {
if (elevated === 'granted') {
clearInterval(checkElevation);
resolve({ cancelled: false });
} else if (elevated === 'refused') {
clearInterval(checkElevation);
resolve({ cancelled: true });
}
}, 300);
// if the elevation didn't occured in 30 seconds we reject the promise
setTimeout(() => {
clearInterval(checkElevation);
reject(new Error('Elevation timeout'));
}, 30000);
});
}

View File

@ -1,218 +0,0 @@
/*
* This is heavily inspired (read: a ripof) https://github.com/balena-io-modules/sudo-prompt
* Which was a fork of https://github.com/jorangreef/sudo-prompt
*
* This and the original code was released under The MIT License (MIT)
*
* Copyright (c) 2015 Joran Dirk Greef
* Copyright (c) 2024 Balena
*
The MIT License (MIT)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { spawn } from 'child_process';
// import { env } from 'process';
import { tmpdir } from 'os';
import { v4 as uuidv4 } from 'uuid';
import { join, sep } from 'path';
import { mkdir, writeFile, copyFile, readFile } from 'fs/promises';
/**
* TODO:
* Migrate, modernize and clenup the windows elevation code from the old @balena/sudo-prompt package in a similar way to linux-sudo.ts and catalina-sudo files.
*/
export async function sudo(
command: string,
_name: string,
env: any,
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
const uuid = uuidv4();
const temp = tmpdir();
if (!temp) {
throw new Error('os.tmpdir() not defined.');
}
const tmpFolder = join(temp, uuid);
if (/"/.test(tmpFolder)) {
// We expect double quotes to be reserved on Windows.
// Even so, we test for this and abort if they are present.
throw new Error('instance.path cannot contain double-quotes.');
}
const executeScriptPath = join(tmpFolder, 'execute.bat');
const commandScriptPath = join(tmpFolder, 'command.bat');
const stdoutPath = join(tmpFolder, 'stdout');
const stderrPath = join(tmpFolder, 'stderr');
const statusPath = join(tmpFolder, 'status');
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
try {
await mkdir(tmpFolder);
// WindowsWriteExecuteScript(instance, end)
const executeScript = `
@echo off\r\n
call "${commandScriptPath}" > "${stdoutPath}" 2> "${stderrPath}"\r\n
(echo %ERRORLEVEL%) > "${statusPath}"
`;
await writeFile(executeScriptPath, executeScript, 'utf-8');
// WindowsWriteCommandScript(instance, end)
const cwd = process.cwd();
if (/"/.test(cwd)) {
// We expect double quotes to be reserved on Windows.
// Even so, we test for this and abort if they are present.
throw new Error('process.cwd() cannot contain double-quotes.');
}
const commandScriptArray = [];
commandScriptArray.push('@echo off');
// Set code page to UTF-8:
commandScriptArray.push('chcp 65001>nul');
// Preserve current working directory:
// We pass /d as an option in case the cwd is on another drive (issue 70).
commandScriptArray.push(`cd /d "${cwd}"`);
// Export environment variables:
for (const key in env) {
// "The characters <, >, |, &, ^ are special command shell characters, and
// they must be preceded by the escape character (^) or enclosed in
// quotation marks. If you use quotation marks to enclose a string that
// contains one of the special characters, the quotation marks are set as
// part of the environment variable value."
// In other words, Windows assigns everything that follows the equals sign
// to the value of the variable, whereas Unix systems ignore double quotes.
if (Object.prototype.hasOwnProperty.call(env, key)) {
const value = env[key];
commandScriptArray.push(
`set ${key}=${value!.replace(/([<>\\|&^])/g, '^$1')}`,
);
}
}
commandScriptArray.push(`echo ${SUCCESSFUL_AUTH_MARKER}`);
commandScriptArray.push(command);
await writeFile(
commandScriptPath,
commandScriptArray.join('\r\n'),
'utf-8',
);
// WindowsCopyCmd(instance, end)
if (windowsNeedsCopyCmd(tmpFolder)) {
// Work around https://github.com/jorangreef/sudo-prompt/issues/97
// Powershell can't properly escape amperstands in paths.
// We work around this by copying cmd.exe in our temporary folder and running
// it from here (see WindowsElevate below).
// That way, we don't have to pass the path containing the amperstand at all.
// A symlink would probably work too but you have to be an administrator in
// order to create symlinks on Windows.
await copyFile(
join(process.env.SystemRoot!, 'System32', 'cmd.exe'),
join(tmpFolder, 'cmd.exe'),
);
}
// WindowsElevate(instance, end)
// We used to use this for executing elevate.vbs:
// var command = 'cscript.exe //NoLogo "' + instance.pathElevate + '"';
const spawnCommand = [];
// spawnCommand.push("powershell.exe") // as we use spawn this one is out of the array
spawnCommand.push('Start-Process');
spawnCommand.push('-FilePath');
const options: any = { encoding: 'utf8' };
if (windowsNeedsCopyCmd(tmpFolder)) {
// Node.path.join('.', 'cmd.exe') would return 'cmd.exe'
spawnCommand.push(['.', 'cmd.exe'].join(sep));
spawnCommand.push('-ArgumentList');
spawnCommand.push('"/C","execute.bat"');
options.cwd = tmpFolder;
} else {
// Escape characters for cmd using double quotes:
// Escape characters for PowerShell using single quotes:
// Escape single quotes for PowerShell using backtick:
// See: https://ss64.com/ps/syntax-esc.html
spawnCommand.push(`'${executeScriptPath.replace(/'/g, "`'")}'`);
}
spawnCommand.push('-WindowStyle hidden');
spawnCommand.push('-Verb runAs');
spawn('powershell.exe', spawnCommand);
// setTimeout(() => {elevated = "granted"}, 5000)
// we don't spawn or read stdout in the promise otherwise resolving stop the process
return new Promise((resolve, reject) => {
const checkElevation = setInterval(async () => {
try {
const result = await readFile(stdoutPath, 'utf-8');
const error = await readFile(stderrPath, 'utf-8');
if (error && error !== '') {
throw new Error(error);
}
// TODO: should track something more generic
if (result.includes(SUCCESSFUL_AUTH_MARKER)) {
clearInterval(checkElevation);
resolve({ cancelled: false });
}
} catch (error) {
console.log(
'Error while reading flasher elevation script output',
error,
);
}
}, 1000);
// if the elevation didn't occured in 30 seconds we reject the promise
setTimeout(() => {
clearInterval(checkElevation);
reject(new Error('Elevation timeout'));
}, 30000);
});
// WindowsWaitForStatus(instance, end)
// WindowsResult(instance, end)
} catch (error) {
throw new Error(`Can't elevate process ${error}`);
} finally {
// TODO: cleanup
// // Remove(instance.path, function (errorRemove) {
// // if (error) return callback(error)
// // if (errorRemove) return callback(errorRemove)
// // callback(undefined, stdout, stderr)
}
}
function windowsNeedsCopyCmd(path: string) {
const specialChars = ['&', '`', "'", '"', '<', '>', '|', '^'];
for (const specialChar of specialChars) {
if (path.includes(specialChar)) {
return true;
}
}
return false;
}

View File

@ -1,23 +0,0 @@
import type { GPTPartition, MBRPartition } from 'partitioninfo';
import type { sourceDestination } from 'etcher-sdk';
import type { DrivelistDrive } from '../drive-constraints';
export type Source = 'File' | 'BlockDevice' | 'Http';
export interface SourceMetadata extends sourceDestination.Metadata {
hasMBR?: boolean;
partitions?: MBRPartition[] | GPTPartition[];
path: string;
displayName: string;
description: string;
SourceType: Source;
drive?: DrivelistDrive;
extension?: string;
archiveExtension?: string;
auth?: Authentication;
}
export interface Authentication {
username: string;
password: string;
}

View File

@ -14,6 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import axios from 'axios';
import { Dictionary } from 'lodash';
import * as errors from './errors'; import * as errors from './errors';
export function isValidPercentage(percentage: any): boolean { export function isValidPercentage(percentage: any): boolean {
@ -35,6 +38,19 @@ export async function delay(duration: number): Promise<void> {
}); });
} }
export function getAppPath(): string {
return (
(require('electron').app || require('@electron/remote').app)
.getAppPath()
// With macOS universal builds, getAppPath() returns the path to an app.asar file containing an index.js file which will
// include the app-x64 or app-arm64 folder depending on the arch.
// We don't care about the app.asar file, we want the actual folder.
.replace(/\.asar$/, () =>
process.platform === 'darwin' ? '-' + process.arch : '',
)
);
}
export function isJson(jsonString: string) { export function isJson(jsonString: string) {
try { try {
JSON.parse(jsonString); JSON.parse(jsonString);

View File

@ -1,292 +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 { WebSocketServer } from 'ws';
import type { Dictionary } from 'lodash';
import { values } from 'lodash';
import type { MultiDestinationProgress } from 'etcher-sdk/build/multi-write';
import { toJSON } from '../shared/errors';
import { GENERAL_ERROR, SUCCESS } from '../shared/exit-codes';
import type { WriteOptions } from './types/types';
import { write, cleanup } from './child-writer';
import { startScanning } from './scanner';
import { getSourceMetadata } from './source-metadata';
import type { DrivelistDrive } from '../shared/drive-constraints';
import type { SourceMetadata } from '../shared/typings/source-selector';
const ETCHER_SERVER_ADDRESS = process.env.ETCHER_SERVER_ADDRESS as string;
const ETCHER_SERVER_PORT = process.env.ETCHER_SERVER_PORT as string;
// const ETCHER_SERVER_ID = process.env.ETCHER_SERVER_ID as string;
const ETCHER_TERMINATE_TIMEOUT: number = parseInt(
process.env.ETCHER_TERMINATE_TIMEOUT ?? '10000',
10,
);
const host = ETCHER_SERVER_ADDRESS ?? '127.0.0.1';
const port = parseInt(ETCHER_SERVER_PORT || '3434', 10);
// const path = ETCHER_SERVER_ID || "etcher";
// TODO: use the path as cheap authentication
const wss = new WebSocketServer({ host, port });
// hold emit functions
let emitLog: (message: string) => void | undefined;
let emitState: (state: MultiDestinationProgress) => void | undefined;
let emitFail: (data: any) => void | undefined;
let emitDrives: (drives: Dictionary<DrivelistDrive>) => void | undefined;
let emitSourceMetadata: (
sourceMetadata: SourceMetadata | Record<string, never>,
) => void | undefined; // Record<string, never> means an empty object
// Terminate the child process
async function terminate(exitCode?: number) {
await cleanup(Date.now());
process.nextTick(() => {
process.exit(exitCode || SUCCESS);
});
}
// kill the process if no initila connections or heartbeat for X sec (default 10)
function setTerminateTimeout() {
if (ETCHER_TERMINATE_TIMEOUT > 0) {
return setTimeout(() => {
console.log(
`no connections or heartbeat for ${ETCHER_TERMINATE_TIMEOUT} ms, terminating`,
);
terminate();
}, ETCHER_TERMINATE_TIMEOUT);
} else {
return null;
}
}
// terminate the process cleanly on SIGINT
process.once('SIGINT', async () => {
await terminate(SUCCESS);
});
// terminate the process cleanly on SIGTERM
process.once('SIGTERM', async () => {
await terminate(SUCCESS);
});
let terminateInterval = setTerminateTimeout();
interface EmitLog {
emit: (channel: string, message: object | string) => void;
log: (message: string) => void;
}
function setup(): Promise<EmitLog> {
return new Promise((resolve, reject) => {
wss.on('connection', (ws) => {
console.log('connection established... setting up');
/**
* @summary Send a message to the IPC server
*/
function emit(type: string, payload?: object | string) {
ws.send(JSON.stringify({ type, payload }));
// ipc.of[IPC_SERVER_ID].emit("message", { type, payload });
}
/**
* @summary Print logs and send them back to client
*/
function log(message: string) {
console.log(message);
emit('log', message);
}
/**
* @summary Handle `errors`
*/
async function handleError(error: Error) {
emit('error', toJSON(error));
await terminate(GENERAL_ERROR);
}
/**
* @summary Handle `abort` from client
*/
const onAbort = async (exitCode: number) => {
log('Abort');
emit('abort');
await terminate(exitCode);
};
/**
* @summary Handle `skip` from client; skip validation
*/
const onSkip = async (exitCode: number) => {
log('Skip validation');
emit('skip');
await terminate(exitCode);
};
/**
* @summary Handle `write` from client; start writing to the drives
*/
const onWrite = async (options: WriteOptions) => {
log('write requested');
// Remove leftover tmp files older than 1 hour
cleanup(Date.now() - 60 * 60 * 1000);
let exitCode = SUCCESS;
// Write to the drives
const results = await write(options);
// handle potential errors from the write process
if (results.errors.length > 0) {
results.errors = results.errors.map(toJSON);
exitCode = GENERAL_ERROR;
}
// send the results back to the client
emit('done', { results });
// terminate this process
await terminate(exitCode);
};
/**
* @summary Handle `sourceMetadata` from client; get source metadata
*/
const onSourceMetadata = async (params: any) => {
log('sourceMetadata requested');
const { selected, SourceType, auth } = JSON.parse(params);
try {
const sourceMatadata = await getSourceMetadata(
selected,
SourceType,
auth,
);
emitSourceMetadata(sourceMatadata);
} catch (error: any) {
emitFail(error);
}
};
// handle uncaught exceptions
process.once('uncaughtException', handleError);
// terminate the process if the connection is closed
ws.on('error', async () => {
await terminate(SUCCESS);
});
// route messages from the client by `type`
const messagesHandler: any = {
// terminate the process
terminate: () => terminate(SUCCESS),
/*
receive a `heartbeat`, reset the terminate timeout
this mechanism ensure the process will be terminated if the client is disconnected
*/
heartbeat: () => {
if (terminateInterval) {
clearTimeout(terminateInterval);
}
terminateInterval = setTerminateTimeout();
},
// resolve the setup promise when the client is ready
ready: () => {
log('Ready ...');
resolve({ emit, log });
},
// start scanning for drives
scan: () => {
log('Scan requested');
startScanning();
},
// route `cancel` from client
cancel: () => onAbort(GENERAL_ERROR),
// route `skip` from client
skip: () => onSkip(GENERAL_ERROR),
// route `write` from client
write: async (options: WriteOptions) => onWrite(options),
// route `sourceMetadata` from client
sourceMetadata: async (params: any) => onSourceMetadata(params),
};
// message handler, parse and route messages coming on WS
ws.on('message', async (jsonData: any) => {
const data = JSON.parse(jsonData);
const message = messagesHandler[data.type];
if (message) {
await message(data.payload);
} else {
throw new Error(`Unknown message type: ${data.type}`);
}
});
// inform the client that the server is ready to receive messages
emit('ready', {});
ws.on('error', (error) => {
reject(error);
});
});
});
}
// setTimeout(() => console.log('wss', wss.address()), 1000);
console.log('waiting for connection...');
setup().then(({ emit, log }: EmitLog) => {
// connection is established, clear initial terminate timeout
if (terminateInterval) {
clearInterval(terminateInterval);
}
console.log('waiting for instruction...');
// set the exportable emit functions
emitLog = (message) => {
log(message);
};
emitState = (state) => {
emit('state', state);
};
emitFail = (data) => {
emit('fail', data);
};
emitDrives = (drives) => {
emit('drives', JSON.stringify(values(drives)));
};
emitSourceMetadata = (sourceMetadata) => {
emit('sourceMetadata', JSON.stringify(sourceMetadata));
};
});
export { emitLog, emitState, emitFail, emitDrives, emitSourceMetadata };

View File

@ -1,199 +0,0 @@
/*
* Copyright 2023 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.
*
* This file handles the writer process.
*/
import type {
OnProgressFunction,
OnFailFunction,
MultiDestinationProgress,
} from 'etcher-sdk/build/multi-write';
import {
decompressThenFlash,
DECOMPRESSED_IMAGE_PREFIX,
} from 'etcher-sdk/build/multi-write';
import { totalmem } from 'os';
import { cleanupTmpFiles } from 'etcher-sdk/build/tmp';
import type { SourceDestination } from 'etcher-sdk/build/source-destination';
import { File, Http, BlockDevice } from 'etcher-sdk/build/source-destination';
import type { WriteResult, FlashError, WriteOptions } from './types/types';
import { isJson } from '../shared/utils';
import { toJSON } from '../shared/errors';
import axios from 'axios';
import { omit } from 'lodash';
import { emitLog, emitState, emitFail } from './api';
async function write(options: WriteOptions) {
/**
* @summary Failure handler (non-fatal errors)
* @param {SourceDestination} destination - destination
* @param {Error} error - error
*/
const onFail = (destination: SourceDestination, error: Error) => {
emitFail({
// TODO: device should be destination
// @ts-ignore (destination.drive is private)
device: destination.drive,
error: toJSON(error),
});
};
/**
* @summary Progress handler
* @param {Object} state - progress state
* @example
* writer.on('progress', onProgress)
*/
const onProgress = (state: MultiDestinationProgress) => {
emitState(state);
};
// Write the image to the destinations
const destinations = options.destinations.map((d) => d.device);
const imagePath = options.image.path;
emitLog(`Image: ${imagePath}`);
emitLog(`Devices: ${destinations.join(', ')}`);
emitLog(`Auto blockmapping: ${options.autoBlockmapping}`);
emitLog(`Decompress first: ${options.decompressFirst}`);
const dests = options.destinations.map((destination) => {
return new BlockDevice({
drive: destination,
unmountOnSuccess: true,
write: true,
direct: true,
});
});
const { SourceType } = options;
try {
let source;
if (options.image.drive) {
source = new BlockDevice({
drive: options.image.drive,
direct: !options.autoBlockmapping,
});
} else {
if (SourceType === File.name) {
source = new File({
path: imagePath,
});
} else {
const decodedImagePath = decodeURIComponent(imagePath);
if (isJson(decodedImagePath)) {
const imagePathObject = JSON.parse(decodedImagePath);
source = new Http({
url: imagePathObject.url,
avoidRandomAccess: true,
axiosInstance: axios.create(omit(imagePathObject, ['url'])),
auth: options.image.auth,
});
} else {
source = new Http({
url: imagePath,
avoidRandomAccess: true,
auth: options.image.auth,
});
}
}
}
const results = await writeAndValidate({
source,
destinations: dests,
verify: true,
autoBlockmapping: options.autoBlockmapping,
decompressFirst: options.decompressFirst,
onProgress,
onFail,
});
return results;
} catch (error: any) {
return { errors: [error] };
}
}
/** @summary clean up tmp files */
export async function cleanup(until: number) {
await cleanupTmpFiles(until, DECOMPRESSED_IMAGE_PREFIX);
}
/**
* @summary writes the source to the destinations and validates the writes
* @param {SourceDestination} source - source
* @param {SourceDestination[]} destinations - destinations
* @param {Boolean} verify - whether to validate the writes or not
* @param {Boolean} autoBlockmapping - whether to trim ext partitions before writing
* @param {Function} onProgress - function to call on progress
* @param {Function} onFail - function to call on fail
* @returns {Promise<{ bytesWritten, devices, errors }>}
*/
async function writeAndValidate({
source,
destinations,
verify,
autoBlockmapping,
decompressFirst,
onProgress,
onFail,
}: {
source: SourceDestination;
destinations: BlockDevice[];
verify: boolean;
autoBlockmapping: boolean;
decompressFirst: boolean;
onProgress: OnProgressFunction;
onFail: OnFailFunction;
}): Promise<WriteResult> {
const { sourceMetadata, failures, bytesWritten } = await decompressThenFlash({
source,
destinations,
onFail,
onProgress,
verify,
trim: autoBlockmapping,
numBuffers: Math.min(
2 + (destinations.length - 1) * 32,
256,
Math.floor(totalmem() / 1024 ** 2 / 8),
),
decompressFirst,
});
const result: WriteResult = {
bytesWritten,
devices: {
failed: failures.size,
successful: destinations.length - failures.size,
},
errors: [],
sourceMetadata,
};
for (const [destination, error] of failures) {
const err = error as FlashError;
const drive = destination as BlockDevice;
err.device = drive.device;
err.description = drive.description;
result.errors.push(err);
}
return result;
}
export { write };

View File

@ -1,184 +0,0 @@
import { scanner as driveScanner } from './drive-scanner';
import * as sdk from 'etcher-sdk';
import type { DrivelistDrive } from '../shared/drive-constraints';
import outdent from 'outdent';
import type { Dictionary } from 'lodash';
import { values, keyBy, padStart } from 'lodash';
import { emitDrives } from './api';
let availableDrives: DrivelistDrive[] = [];
export function hasAvailableDrives() {
return availableDrives.length > 0;
}
driveScanner.on('error', (error) => {
// Stop the drive scanning loop in case of errors,
// otherwise we risk presenting the same error over
// and over again to the user, while also heavily
// spamming our error reporting service.
driveScanner.stop();
console.log('scanner error', error);
});
function setDrives(drives: Dictionary<DrivelistDrive>) {
availableDrives = values(drives);
emitDrives(drives);
}
function getDrives() {
return keyBy(availableDrives, 'device');
}
async function addDrive(drive: Drive) {
const preparedDrive = prepareDrive(drive);
if (!(await driveIsAllowed(preparedDrive))) {
return;
}
const drives = getDrives();
drives[preparedDrive.device] = preparedDrive;
setDrives(drives);
}
function removeDrive(drive: Drive) {
const preparedDrive = prepareDrive(drive);
const drives = getDrives();
delete drives[preparedDrive.device];
setDrives(drives);
}
async function driveIsAllowed(drive: {
devicePath: string;
device: string;
raw: string;
}) {
// const driveBlacklist = (await settings.get("driveBlacklist")) || [];
const driveBlacklist: any[] = [];
return !(
driveBlacklist.includes(drive.devicePath) ||
driveBlacklist.includes(drive.device) ||
driveBlacklist.includes(drive.raw)
);
}
type Drive =
| sdk.sourceDestination.BlockDevice
| sdk.sourceDestination.UsbbootDrive
| sdk.sourceDestination.DriverlessDevice;
function prepareDrive(drive: Drive) {
if (drive instanceof sdk.sourceDestination.BlockDevice) {
// @ts-ignore (BlockDevice.drive is private)
return drive.drive;
} else if (drive instanceof sdk.sourceDestination.UsbbootDrive) {
// This is a workaround etcher expecting a device string and a size
// @ts-ignore
drive.device = drive.usbDevice.portId;
drive.size = null;
// @ts-ignore
drive.progress = 0;
drive.disabled = true;
drive.on('progress', (progress) => {
updateDriveProgress(drive, progress);
});
return drive;
} else if (drive instanceof sdk.sourceDestination.DriverlessDevice) {
const description =
COMPUTE_MODULE_DESCRIPTIONS[
drive.deviceDescriptor.idProduct.toString()
] || 'Compute Module';
return {
device: `${usbIdToString(
drive.deviceDescriptor.idVendor,
)}:${usbIdToString(drive.deviceDescriptor.idProduct)}`,
displayName: 'Missing drivers',
description,
mountpoints: [],
isReadOnly: false,
isSystem: false,
disabled: true,
icon: 'warning',
size: null,
link: 'https://www.raspberrypi.com/documentation/computers/compute-module.html#flashing-the-compute-module-emmc',
linkCTA: 'Install',
linkTitle: 'Install missing drivers',
linkMessage: outdent`
Would you like to download the necessary drivers from the Raspberry Pi Foundation?
This will open your browser.
Once opened, download and run the installer from the "Windows Installer" section to install the drivers
`,
};
}
}
/**
* @summary The radix used by USB ID numbers
*/
const USB_ID_RADIX = 16;
/**
* @summary The expected length of a USB ID number
*/
const USB_ID_LENGTH = 4;
/**
* @summary Convert a USB id (e.g. product/vendor) to a string
*
* @example
* console.log(usbIdToString(2652))
* > '0x0a5c'
*/
function usbIdToString(id: number): string {
return `0x${padStart(id.toString(USB_ID_RADIX), USB_ID_LENGTH, '0')}`;
}
function updateDriveProgress(
drive: sdk.sourceDestination.UsbbootDrive,
progress: number,
) {
const drives = getDrives();
// @ts-ignore
const driveInMap = drives[drive.device];
if (driveInMap) {
// @ts-ignore
drives[drive.device] = { ...driveInMap, progress };
setDrives(drives);
}
}
/**
* @summary Product ID of BCM2708
*/
const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763;
/**
* @summary Product ID of BCM2710
*/
const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764;
/**
* @summary Compute module descriptions
*/
const COMPUTE_MODULE_DESCRIPTIONS: Dictionary<string> = {
[USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1',
[USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3',
};
const startScanning = () => {
driveScanner.on('attach', (drive) => addDrive(drive));
driveScanner.on('detach', (drive) => removeDrive(drive));
driveScanner.start();
};
const stopScanning = () => {
driveScanner.stop();
};
export { startScanning, stopScanning };

View File

@ -1,94 +0,0 @@
/** Get metadata for a source */
import { sourceDestination } from 'etcher-sdk';
import { replaceWindowsNetworkDriveLetter } from '../gui/app/os/windows-network-drives';
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { isJson } from '../shared/utils';
import * as path from 'path';
import type {
SourceMetadata,
Authentication,
Source,
} from '../shared/typings/source-selector';
import type { DrivelistDrive } from '../shared/drive-constraints';
import { omit } from 'lodash';
function isString(value: any): value is string {
return typeof value === 'string';
}
async function createSource(
selected: string,
SourceType: Source,
auth?: Authentication,
) {
try {
selected = await replaceWindowsNetworkDriveLetter(selected);
} catch (error: any) {
// TODO: analytics.logException(error);
}
if (isJson(decodeURIComponent(selected))) {
const config: AxiosRequestConfig = JSON.parse(decodeURIComponent(selected));
return new sourceDestination.Http({
url: config.url!,
axiosInstance: axios.create(omit(config, ['url'])),
});
}
if (SourceType === 'File') {
return new sourceDestination.File({
path: selected,
});
}
return new sourceDestination.Http({ url: selected, auth });
}
async function getMetadata(
source: sourceDestination.SourceDestination,
selected: string | DrivelistDrive,
) {
const metadata = (await source.getMetadata()) as SourceMetadata;
const partitionTable = await source.getPartitionTable();
if (partitionTable) {
metadata.hasMBR = true;
metadata.partitions = partitionTable.partitions;
} else {
metadata.hasMBR = false;
}
if (isString(selected)) {
metadata.extension = path.extname(selected).slice(1);
metadata.path = selected;
}
return metadata;
}
async function getSourceMetadata(
selected: string | DrivelistDrive,
SourceType: Source,
auth?: Authentication,
): Promise<SourceMetadata | Record<string, never>> {
// `Record<string, never>` means an empty object
if (isString(selected)) {
const source = await createSource(selected, SourceType, auth);
try {
const innerSource = await source.getInnerSource();
const metadata = await getMetadata(innerSource, selected);
return metadata;
} catch (error: any) {
// TODO: handle error
return {};
} finally {
await source.close();
}
} else {
return {};
}
}
export { getSourceMetadata };

View File

@ -1,33 +0,0 @@
import type { Metadata } from 'etcher-sdk/build/source-destination';
import type { SourceMetadata } from '../../shared/typings/source-selector';
import type { Drive as DrivelistDrive } from 'drivelist';
export interface WriteResult {
bytesWritten?: number;
devices?: {
failed: number;
successful: number;
};
errors: FlashError[];
sourceMetadata?: Metadata;
}
export interface FlashError extends Error {
description: string;
device: string;
code: string;
}
export interface FlashResults extends WriteResult {
skip?: boolean;
cancelled?: boolean;
}
interface WriteOptions {
image: SourceMetadata;
destinations: DrivelistDrive[];
autoBlockmapping: boolean;
decompressFirst: boolean;
SourceType: string;
httpRequest?: any;
}

28035
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

36145
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,10 +2,9 @@
"name": "balena-etcher", "name": "balena-etcher",
"private": true, "private": true,
"displayName": "balenaEtcher", "displayName": "balenaEtcher",
"productName": "balenaEtcher", "version": "1.18.12",
"version": "2.1.3",
"packageType": "local", "packageType": "local",
"main": ".webpack/main", "main": "generated/etcher.js",
"description": "Flash OS images to SD cards and USB drives, safely and easily.", "description": "Flash OS images to SD cards and USB drives, safely and easily.",
"productDescription": "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.", "productDescription": "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.",
"homepage": "https://github.com/balena-io/etcher", "homepage": "https://github.com/balena-io/etcher",
@ -14,140 +13,119 @@
"url": "git@github.com:balena-io/etcher.git" "url": "git@github.com:balena-io/etcher.git"
}, },
"scripts": { "scripts": {
"prettify": "prettier --write lib/**/*.css && balena-lint --fix --typescript typings lib tests forge.config.ts forge.sidecar.ts webpack.config.ts", "build": "npm run webpack",
"lint": "npm run prettify && catch-uncommitted", "flowzone-preinstall-linux": "sudo apt-get update && sudo apt-get install -y xvfb libudev-dev && cat < electron-builder.yml | yq e .deb.depends[] - | xargs -L1 echo | sed 's/|//g' | xargs -L1 sudo apt-get --ignore-missing install || true",
"test": "echo 'Only use custom tests; if you want to test locally, use `npm run wdio`' && exit 0", "flowzone-preinstall-macos": "true",
"package": "electron-forge package", "flowzone-preinstall-windows": "npx node-gyp install",
"start": "electron-forge start", "flowzone-preinstall": "npm run flowzone-preinstall-linux",
"make": "electron-forge make", "lint-css": "prettier --write lib/**/*.css",
"wdio": "xvfb-maybe wdio run ./wdio.conf.ts" "lint-ts": "balena-lint --fix --typescript typings lib tests scripts/clean-shrinkwrap.ts webpack.config.ts",
"lint": "npm run lint-ts && npm run lint-css",
"postinstall": "electron-rebuild -t prod,dev,optional",
"sanity-checks": "bash scripts/ci/ensure-all-file-extensions-in-gitattributes.sh",
"start": "./node_modules/.bin/electron .",
"test-gui": "electron-mocha --recursive --reporter spec --window-config tests/gui/window-config.json --require ts-node/register/transpile-only --require-main tests/gui/allow-renderer-process-reuse.ts --full-trace --no-sandbox --renderer tests/gui/**/*.ts",
"test-shared": "electron-mocha --recursive --reporter spec --require ts-node/register/transpile-only --require-main tests/gui/allow-renderer-process-reuse.ts --full-trace --no-sandbox tests/shared/**/*.ts",
"test-macos": "npm run lint && npm run test-gui && npm run test-shared && npm run sanity-checks",
"test-linux": "npm run lint && xvfb-run --auto-servernum npm run test-gui && xvfb-run --auto-servernum npm run test-shared && npm run sanity-checks",
"test-windows": "npm run lint && npm run test-gui && npm run test-shared && npm run sanity-checks",
"test": "echo npm run test-{linux,windows,macos}",
"watch": "webpack serve --no-optimization-minimize --config ./webpack.dev.config.ts",
"webpack": "webpack"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
"pre-commit": "npm run prettify" "pre-commit": "lint-staged"
} }
}, },
"lint-staged": {
"./**/*.{ts,tsx}": [
"npm run lint-ts"
],
"./**/*.css": [
"npm run lint-css"
]
},
"author": "Balena Ltd. <hello@balena.io>", "author": "Balena Ltd. <hello@balena.io>",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "devDependencies": {
"@electron/remote": "^2.1.2", "@balena/lint": "5.4.2",
"@fortawesome/fontawesome-free": "^6.5.2", "@balena/sudo-prompt": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
"@ronomon/direct-io": "^3.0.1", "@electron/remote": "^2.0.9",
"@sentry/electron": "^4.24.0", "@fortawesome/fontawesome-free": "5.15.4",
"axios": "^1.6.8", "@sentry/electron": "^4.1.2",
"@svgr/webpack": "5.5.0",
"@types/chai": "4.3.4",
"@types/copy-webpack-plugin": "6.4.3",
"@types/mime-types": "2.1.1",
"@types/mini-css-extract-plugin": "1.4.3",
"@types/mocha": "^9.1.1",
"@types/node": "^16.18.12",
"@types/node-ipc": "9.2.0",
"@types/react": "16.14.34",
"@types/react-dom": "16.9.17",
"@types/semver": "7.3.13",
"@types/sinon": "9.0.11",
"@types/terser-webpack-plugin": "5.0.4",
"@types/tmp": "0.2.3",
"@types/webpack-node-externals": "2.5.3",
"analytics-client": "^2.0.1",
"axios": "^0.27.2",
"chai": "4.3.7",
"copy-webpack-plugin": "7.0.0",
"css-loader": "5.2.7",
"d3": "4.13.0",
"debug": "4.3.4", "debug": "4.3.4",
"drivelist": "^12.0.2", "electron": "^19.1.9",
"electron-squirrel-startup": "^1.0.0", "electron-builder": "^23.6.0",
"electron-updater": "6.1.8", "electron-mocha": "^11.0.2",
"etcher-sdk": "9.1.2", "electron-notarize": "1.2.2",
"i18next": "23.11.2", "electron-rebuild": "^3.2.9",
"electron-updater": "5.3.0",
"esbuild-loader": "2.20.0",
"etcher-sdk": "8.3.1",
"file-loader": "6.2.0",
"husky": "4.3.8",
"i18next": "21.10.0",
"immutable": "3.8.2", "immutable": "3.8.2",
"lint-staged": "10.5.4",
"lodash": "4.17.21", "lodash": "4.17.21",
"mini-css-extract-plugin": "1.6.2",
"mocha": "^9.1.1",
"native-addon-loader": "2.0.1",
"node-ipc": "9.2.1",
"omit-deep-lodash": "1.1.7",
"outdent": "0.8.0", "outdent": "0.8.0",
"path-is-inside": "1.0.2", "path-is-inside": "1.0.2",
"pretty-bytes": "6.1.1", "pnp-webpack-plugin": "1.7.0",
"react": "17.0.2", "pretty-bytes": "5.6.0",
"react-dom": "17.0.2", "react": "16.8.5",
"react-i18next": "13.5.0", "react-dom": "16.8.5",
"redux": "4.2.1", "react-i18next": "11.18.6",
"rendition": "35.2.0", "redux": "4.2.0",
"semver": "7.6.0", "rendition": "19.3.2",
"semver": "7.3.8",
"simple-progress-webpack-plugin": "1.1.2",
"sinon": "9.2.4",
"string-replace-loader": "3.1.0",
"style-loader": "2.0.0",
"styled-components": "5.3.6", "styled-components": "5.3.6",
"sys-class-rgb-led": "3.0.1", "sys-class-rgb-led": "3.0.1",
"uuid": "9.0.1", "terser-webpack-plugin": "5.3.6",
"ws": "^8.16.0" "ts-loader": "8.4.0",
}, "ts-node": "9.1.1",
"devDependencies": { "tslib": "2.4.1",
"@balena/lint": "8.0.2", "typescript": "4.4.4",
"@electron-forge/cli": "7.4.0",
"@electron-forge/maker-deb": "7.4.0",
"@electron-forge/maker-dmg": "7.4.0",
"@electron-forge/maker-rpm": "7.4.0",
"@electron-forge/maker-squirrel": "7.4.0",
"@electron-forge/maker-zip": "7.4.0",
"@electron-forge/plugin-auto-unpack-natives": "7.4.0",
"@electron-forge/plugin-webpack": "7.4.0",
"@reforged/maker-appimage": "3.3.2",
"@svgr/webpack": "8.1.0",
"@types/chai": "4.3.14",
"@types/debug": "^4.1.12",
"@types/mime-types": "2.1.4",
"@types/node": "^20.11.6",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.2",
"@types/semver": "7.5.8",
"@types/sinon": "17.0.3",
"@types/tmp": "0.2.6",
"@vercel/webpack-asset-relocator-loader": "1.7.3",
"@wdio/cli": "^8.36.1",
"@wdio/local-runner": "^8.36.1",
"@wdio/mocha-framework": "^8.36.1",
"@wdio/spec-reporter": "^8.36.1",
"@yao-pkg/pkg": "^5.11.5",
"catch-uncommitted": "^2.0.0",
"chai": "4.3.10",
"css-loader": "5.2.7",
"electron": "30.0.1",
"file-loader": "6.2.0",
"husky": "8.0.3",
"native-addon-loader": "2.0.1",
"node-loader": "^2.0.0",
"sinon": "^17.0.1",
"string-replace-loader": "3.1.0",
"style-loader": "3.3.3",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tslib": "2.6.2",
"typescript": "^5.3.3",
"url-loader": "4.1.1", "url-loader": "4.1.1",
"wdio-electron-service": "^6.4.1", "uuid": "8.3.2",
"xvfb-maybe": "^0.2.1" "webpack": "5.75.0",
}, "webpack-cli": "4.10.0",
"hostDependencies": { "webpack-dev-server": "4.11.1"
"debian": [
"libasound2",
"libatk1.0-0",
"libc6",
"libcairo2",
"libcups2",
"libdbus-1-3",
"libexpat1",
"libfontconfig1",
"libfreetype6",
"libgbm1",
"libgcc1",
"libgdk-pixbuf2.0-0",
"libglib2.0-0",
"libgtk-3-0",
"liblzma5",
"libnotify4",
"libnspr4",
"libnss3",
"libpango1.0-0 | libpango-1.0-0",
"libstdc++6",
"libx11-6",
"libxcomposite1",
"libxcursor1",
"libxdamage1",
"libxext6",
"libxfixes3",
"libxi6",
"libxrandr2",
"libxrender1",
"libxss1",
"libxtst6",
"polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1"
]
}, },
"engines": { "engines": {
"node": ">=20 <21" "node": ">=16"
}, },
"versionist": { "versionist": {
"publishedAt": "2025-05-15T18:09:56.320Z" "publishedAt": "2023-07-19T10:24:23.055Z"
},
"optionalDependencies": {
"bufferutil": "^4.0.8",
"utf-8-validate": "^5.0.10",
"winusb-driver-generator": "2.1.2"
} }
} }

View File

@ -1,12 +0,0 @@
{
"assets": [
"node_modules/usb/**",
"node_modules/lzma-native/**",
"node_modules/drivelist/**",
"node_modules/mountutils/**",
"node_modules/winusb-driver-generator/**",
"node_modules/node-raspberrypi-usbboot/**",
"node_modules/xxhash-addon/**",
"node_modules/axios/**"
]
}

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
awscli==1.27.28
shyaml==0.6.2

View File

@ -0,0 +1,52 @@
#!/bin/bash
###
# 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.
###
set -u
set -e
# Read list of wildcards from .gitattributes
wildcards=()
while IFS='' read -r line || [[ -n "$line" ]]; do
if [[ -n "$line" ]]; then
if [[ ! "$line" =~ "^#" ]]; then
filetype=$(echo "$line" | cut -d ' ' -f 2)
if [[ "$filetype" == "text" ]] || [[ "$filetype" == "binary" ]]; then
wildcards+=("$(echo "$line" | cut -d ' ' -f 1)")
fi
fi
fi
done < .gitattributes
# Verify those wildcards against all files stored in the repo
git ls-tree -r HEAD | while IFS='' read line; do
if [[ "$(echo $line | cut -d ' ' -f 2)" == "blob" ]]; then
# the cut delimiter in the line below is actually a tab character, not a space
filename=$(basename $(echo "$line" | cut -d ' ' -f 2))
found_match=0
for wildcard in "${wildcards[@]}"; do
if [[ "$filename" = $wildcard ]]; then
found_match=1
break
fi
done
if [[ $found_match -eq 0 ]]; then
echo "No wildcards match $filename"
exit 1
fi
fi
done

View File

@ -0,0 +1,52 @@
/**
* This script is in charge of cleaning the `shrinkwrap` file.
*
* `npm shrinkwrap` has a bug where it will add optional dependencies
* to `npm-shrinkwrap.json`, therefore causing errors if these optional
* dependendencies are platform dependent and you then try to build
* the project in another platform.
*
* As a workaround, we keep a list of platform dependent dependencies in
* the `platformSpecificDependencies` property of `package.json`,
* and manually remove them from `npm-shrinkwrap.json` if they exist.
*
* See: https://github.com/npm/npm/issues/2679
*/
import { writeFile } from 'fs';
import * as omit from 'omit-deep-lodash';
import * as path from 'path';
import { promisify } from 'util';
import * as shrinkwrap from '../npm-shrinkwrap.json';
import * as packageInfo from '../package.json';
const writeFileAsync = promisify(writeFile);
const JSON_INDENT = 2;
const SHRINKWRAP_FILENAME = path.join(__dirname, '..', 'npm-shrinkwrap.json');
async function main() {
try {
const cleaned = omit(shrinkwrap, packageInfo.platformSpecificDependencies);
for (const item of Object.values(cleaned.dependencies)) {
// @ts-ignore
item.dev = true;
}
await writeFileAsync(
SHRINKWRAP_FILENAME,
JSON.stringify(cleaned, null, JSON_INDENT),
);
} catch (error: any) {
console.log(`[ERROR] Couldn't write shrinkwrap file: ${error.stack}`);
process.exitCode = 1;
}
console.log(
`[OK] Wrote shrinkwrap file to ${path.relative(
__dirname,
SHRINKWRAP_FILENAME,
)}`,
);
}
main();

1
scripts/resin Submodule

@ -0,0 +1 @@
Subproject commit 8dfa21cfc23b1dbc0eaa22b5dbdf1f5c796b0c2c

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,8 @@
// tslint:disable-next-line:no-var-requires
const { app } = require('electron'); const { app } = require('electron');
if (app !== undefined) { if (app !== undefined) {
// tslint:disable-next-line:no-var-requires
const remoteMain = require('@electron/remote/main'); const remoteMain = require('@electron/remote/main');
remoteMain.initialize(); remoteMain.initialize();

View File

@ -15,6 +15,7 @@
*/ */
import { expect } from 'chai'; import { expect } from 'chai';
import { File } from 'etcher-sdk/build/source-destination';
import * as path from 'path'; import * as path from 'path';
import * as availableDrives from '../../../lib/gui/app/models/available-drives'; import * as availableDrives from '../../../lib/gui/app/models/available-drives';
@ -164,7 +165,7 @@ describe('Model: availableDrives', function () {
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
isSizeEstimated: false, isSizeEstimated: false,
SourceType: 'File', SourceType: File,
recommendedDriveSize: 2000000000, recommendedDriveSize: 2000000000,
}); });
}); });

View File

@ -15,12 +15,13 @@
*/ */
import { expect } from 'chai'; import { expect } from 'chai';
import { File } from 'etcher-sdk/build/source-destination';
import * as path from 'path'; import * as path from 'path';
import type { SourceMetadata } from '../../../lib/shared/typings/source-selector'; import { SourceMetadata } from '../../../lib/gui/app/components/source-selector/source-selector';
import * as availableDrives from '../../../lib/gui/app/models/available-drives'; import * as availableDrives from '../../../lib/gui/app/models/available-drives';
import * as selectionState from '../../../lib/gui/app/models/selection-state'; import * as selectionState from '../../../lib/gui/app/models/selection-state';
import type { DrivelistDrive } from '../../../lib/shared/drive-constraints'; import { DrivelistDrive } from '../../../lib/shared/drive-constraints';
describe('Model: selectionState', function () { describe('Model: selectionState', function () {
describe('given a clean state', function () { describe('given a clean state', function () {
@ -374,7 +375,7 @@ describe('Model: selectionState', function () {
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
isSizeEstimated: false, isSizeEstimated: false,
SourceType: 'File', SourceType: File,
}); });
const imagePath = selectionState.getImage()?.path; const imagePath = selectionState.getImage()?.path;
@ -407,7 +408,7 @@ describe('Model: selectionState', function () {
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
isSizeEstimated: false, isSizeEstimated: false,
SourceType: 'File', SourceType: File,
recommendedDriveSize: 2000000000, recommendedDriveSize: 2000000000,
}; };
@ -580,7 +581,7 @@ describe('Model: selectionState', function () {
path: 'foo.img', path: 'foo.img',
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
SourceType: 'File', SourceType: File,
isSizeEstimated: false, isSizeEstimated: false,
}; };
@ -669,7 +670,7 @@ describe('Model: selectionState', function () {
path: 'foo.img', path: 'foo.img',
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
SourceType: 'File', SourceType: File,
isSizeEstimated: false, isSizeEstimated: false,
}; };

View File

@ -15,6 +15,7 @@
*/ */
import { expect } from 'chai'; import { expect } from 'chai';
import * as _ from 'lodash';
import { stub } from 'sinon'; import { stub } from 'sinon';
import * as settings from '../../../lib/gui/app/models/settings'; import * as settings from '../../../lib/gui/app/models/settings';
@ -46,8 +47,8 @@ describe('Browser: settings', () => {
const writeConfigFileStub = stub(); const writeConfigFileStub = stub();
writeConfigFileStub.returns(Promise.reject(new Error('settings error'))); writeConfigFileStub.returns(Promise.reject(new Error('settings error')));
const promise = settings.set('foo', 'baz', writeConfigFileStub); const p = settings.set('foo', 'baz', writeConfigFileStub);
await checkError(promise, async (error) => { await checkError(p, async (error) => {
expect(error).to.be.an.instanceof(Error); expect(error).to.be.an.instanceof(Error);
expect(error.message).to.equal('settings error'); expect(error.message).to.equal('settings error');
expect(await settings.get('foo')).to.equal('bar'); expect(await settings.get('foo')).to.equal('bar');

View File

@ -0,0 +1,26 @@
/*
* 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 { expect } from 'chai';
import * as ipc from 'node-ipc';
import('../../../lib/gui/modules/child-writer');
describe('Browser: childWriter', function () {
it('should have the ipc config set to silent', function () {
expect(ipc.config.silent).to.be.true;
});
});

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