Compare commits

..

16 Commits

Author SHA1 Message Date
Lorenzo Alberto Maria Ambrosi
f6ce9a217d Merge branch 'save-url-image-2' of github.com:balena-io/etcher into save-url-image-2 2020-10-19 12:54:34 +02:00
Lorenzo Alberto Maria Ambrosi
fce2d94df7 Rework system & large drives handling logic
Change-type: patch
Changelog-entry: Rework system & large drives handling logic
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-10-19 12:22:17 +02:00
Lorenzo Alberto Maria Ambrosi
3feb22ee66 Add primary colors to default flow
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-10-14 13:12:11 +02:00
Lorenzo Alberto Maria Ambrosi
b80a6b2feb Add UI option to save images flashed from URLs
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-10-14 13:12:11 +02:00
Lorenzo Alberto Maria Ambrosi
b4e6970119 Rework system & large drives handling logic
Change-type: patch
Changelog-entry: Rework system & large drives handling logic
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-10-14 13:12:11 +02:00
Lorenzo Alberto Maria Ambrosi
2e3978b3c9 Add more typings & refactor code accordingly
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-10-14 13:04:35 +02:00
Lorenzo Alberto Maria Ambrosi
c6cd421f17 Fix URL not being selected with custom protocol
Change-type: patch
Changelog-entry: Fix URL not being selected with custom protocol
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-10-14 12:30:55 +02:00
Lorenzo Alberto Maria Ambrosi
c3296eed54 Add dash on table when selecting only some rows
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-10-01 14:52:42 +02:00
Lorenzo Alberto Maria Ambrosi
153e37b9dc Fix settings spacing
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-09-25 11:34:06 +02:00
Lorenzo Alberto Maria Ambrosi
78aca6a19f Use drive-selector's table for flash errors table
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-09-25 11:34:06 +02:00
Lorenzo Alberto Maria Ambrosi
27695babfd Update rendition to v18.8.3
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-09-18 10:50:44 +02:00
Lorenzo Alberto Maria Ambrosi
06a96db72d Fix zoomFactor in webviews
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-09-18 09:45:31 +02:00
Lorenzo Alberto Maria Ambrosi
6584cef774 Add retry button to the errors modal in success screen
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-09-18 09:45:31 +02:00
Lorenzo Alberto Maria Ambrosi
3c77800b1d Cleanup after child-process is terminated
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-09-18 09:45:31 +02:00
Lorenzo Alberto Maria Ambrosi
74a78076cf Add skip function to validation
Change-type: patch
Changelog-entry: Add skip function to validation
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-09-18 09:45:31 +02:00
Lorenzo Alberto Maria Ambrosi
8ff8b02f37 Rework success screen
Change-type: patch
Changelog-entry: Rework success screen
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-09-18 09:45:31 +02:00
113 changed files with 18972 additions and 53761 deletions

View File

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

8
.gitattributes vendored
View File

@@ -1,6 +1,3 @@
# default
* text
# Javascript files must retain LF line-endings (to keep eslint happy)
*.js text eol=lf
*.jsx text eol=lf
@@ -30,7 +27,6 @@ Makefile text
*.yml text
*.patch text
*.txt text
*.tpl text
CODEOWNERS text
*.plist text
@@ -62,7 +58,3 @@ CODEOWNERS text
*.ttf binary diff=hex
xz-without-extension binary diff=hex
wmic-output.txt binary diff=hex
# gitsecret
*.secret binary
.gitsecret/** binary

View File

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

View File

@@ -1,162 +0,0 @@
---
name: package and publish GitHub (draft) release
# https://github.com/product-os/flowzone/tree/master/.github/actions
inputs:
json:
description: "JSON stringified object containing all the inputs from the calling workflow"
required: true
secrets:
description: "JSON stringified object containing all the secrets from the calling workflow"
required: true
# --- custom environment
XCODE_APP_LOADER_EMAIL:
type: string
default: "accounts+apple@balena.io"
NODE_VERSION:
type: string
default: "16.x"
VERBOSE:
type: string
default: "true"
runs:
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
using: "composite"
steps:
- name: Download custom source artifact
uses: actions/download-artifact@v3
with:
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}
path: ${{ runner.temp }}
- name: Extract custom source artifact
shell: pwsh
working-directory: .
run: tar -xf ${{ runner.temp }}/custom.tgz
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ inputs.NODE_VERSION }}
cache: npm
- name: Install yq
shell: bash --noprofile --norc -eo pipefail -x {0}
run: choco install yq
if: runner.os == 'Windows'
# https://www.electron.build/code-signing.html
# https://github.com/Apple-Actions/import-codesign-certs
- name: Import Apple code signing certificate
if: runner.os == 'macOS'
uses: apple-actions/import-codesign-certs@v1
with:
p12-file-base64: ${{ fromJSON(inputs.secrets).APPLE_SIGNING }}
p12-password: ${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}
- name: Import Windows code signing certificate
if: runner.os == 'Windows'
shell: powershell
run: |
Set-Content -Path ${{ runner.temp }}/certificate.base64 -Value $env:WINDOWS_CERTIFICATE
certutil -decode ${{ runner.temp }}/certificate.base64 ${{ runner.temp }}/certificate.pfx
Remove-Item -path ${{ runner.temp }} -include certificate.base64
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:
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
id: package_release
shell: bash --noprofile --norc -eo pipefail -x {0}
run: |
set -ea
[[ '${{ 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)"
ARCHITECTURE_FLAGS="--${ELECTRON_BUILDER_ARCHITECTURE}"
if [[ $runner_os =~ linux ]]; then
ELECTRON_BUILDER_OS='--linux'
TARGETS="$(yq e .linux.target[] electron-builder.yml)"
elif [[ $runner_os =~ darwin|macos|osx ]]; then
CSC_KEY_PASSWORD=${{ fromJSON(inputs.secrets).APPLE_SIGNING_PASSWORD }}
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|win ]]; then
ARCHITECTURE_FLAGS="--ia32 ${ARCHITECTURE_FLAGS}"
CSC_KEY_PASSWORD=${{ fromJSON(inputs.secrets).WINDOWS_SIGNING_PASSWORD }}
CSC_LINK=${{ fromJSON(inputs.secrets).WINDOWS_SIGNING }}
ELECTRON_BUILDER_OS='--win'
TARGETS="$(yq e .win.target[] electron-builder.yml)"
else
exit 1
fi
npm link electron-builder
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
env:
# Apple notarization (afterSignHook.js)
XCODE_APP_LOADER_EMAIL: ${{ inputs.XCODE_APP_LOADER_EMAIL }}
XCODE_APP_LOADER_PASSWORD: ${{ fromJSON(inputs.secrets).XCODE_APP_LOADER_PASSWORD }}
# https://github.blog/2020-08-03-github-actions-improvements-for-fork-and-pull-request-workflows/#improvements-for-public-repository-forks
# https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
CSC_FOR_PULL_REQUEST: true
# https://www.electron.build/auto-update.html#staged-rollouts
- name: Configure staged rollout(s)
shell: bash --noprofile --norc -eo pipefail -x {0}
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
uses: actions/upload-artifact@v3
with:
name: gh-release-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}
path: dist
retention-days: 1

View File

@@ -1,58 +0,0 @@
---
name: test release
# https://github.com/product-os/flowzone/tree/master/.github/actions
inputs:
json:
description: "JSON stringified object containing all the inputs from the calling workflow"
required: true
secrets:
description: "JSON stringified object containing all the secrets from the calling workflow"
required: true
# --- custom environment
NODE_VERSION:
type: string
default: "16.x"
VERBOSE:
type: string
default: "true"
runs:
# https://docs.github.com/en/actions/creating-actions/creating-a-composite-action
using: "composite"
steps:
# https://github.com/actions/setup-node#caching-global-packages-data
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ inputs.NODE_VERSION }}
cache: npm
- name: Test release
shell: bash --noprofile --norc -eo pipefail -x {0}
run: |
set -ea
[[ '${{ 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 run build
npm run test-${runner_os}
env:
# https://www.electronjs.org/docs/latest/api/environment-variables
ELECTRON_NO_ATTACH_CONSOLE: true
- name: Compress custom source
shell: pwsh
run: tar -acf ${{ runner.temp }}/custom.tgz .
- name: Upload custom artifact
uses: actions/upload-artifact@v3
with:
name: custom-${{ github.event.pull_request.head.sha || github.event.head_commit.id }}-${{ runner.os }}
path: ${{ runner.temp }}/custom.tgz
retention-days: 1

View File

@@ -1,30 +0,0 @@
name: Flowzone
on:
pull_request:
types: [opened, synchronize, closed]
branches: [main, master]
# allow external contributions to use secrets within trusted code
pull_request_target:
types: [opened, synchronize, closed]
branches: [main, master]
jobs:
flowzone:
name: Flowzone
uses: product-os/flowzone/.github/workflows/flowzone.yml@master
# prevent duplicate workflows and only allow one `pull_request` or `pull_request_target` for
# internal or external contributions respectively
if: |
(github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request') ||
(github.event.pull_request.head.repo.full_name != github.repository && github.event_name == 'pull_request_target')
secrets: inherit
with:
tests_run_on: '["ubuntu-20.04","macos-latest","windows-2019"]'
restrict_custom_actions: false
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"

View File

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

6
.gitignore vendored
View File

@@ -51,9 +51,3 @@ node_modules
# VSCode files
.vscode
.gitsecret/keys/random_seed
!*.secret
secrets/APPLE_SIGNING_PASSWORD.txt
secrets/WINDOWS_SIGNING_PASSWORD.txt
secrets/XCODE_APP_LOADER_PASSWORD.txt
secrets/WINDOWS_SIGNING.pfx

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +0,0 @@
secrets/APPLE_SIGNING_PASSWORD.txt:5c9cfeb1ea5142b547bc842cc6e0b4a932641ae9811ee47abe2c3953f2a4de5d
secrets/WINDOWS_SIGNING_PASSWORD.txt:852e431628494f2559793c39cf09c34e9406dd79bb15b90c9f88194020470568
secrets/XCODE_APP_LOADER_PASSWORD.txt:005eb9a3c7035c77232973c9355468fc396b94e62783fb8e6dce16bce95b94a1
secrets/WINDOWS_SIGNING.pfx:929f401db38733ffc41572539de7c0d938023af51ed06c205a72a71c1f815714
secrets/APPLE_SIGNING.p12:61abf7b4ff2eec76ce889d71bcdd568b99a6a719b4947ac20f03966265b0946a

1
.nvmrc
View File

@@ -1 +0,0 @@
16

74
.resinci.json Normal file
View File

@@ -0,0 +1,74 @@
{
"electron": {
"npm_version": "6.14.5",
"dependencies": {
"linux": [
"libudev-dev",
"libusb-1.0-0-dev",
"libyaml-dev",
"libgtk-3-0",
"libatk-bridge2.0-0",
"libdbus-1-3",
"libgbm1",
"libc6"
]
},
"builder": {
"appId": "io.balena.etcher",
"copyright": "Copyright 2016-2020 Balena Ltd",
"productName": "balenaEtcher",
"nodeGypRebuild": false,
"afterPack": "./afterPack.js",
"asar": false,
"files": [
"generated",
"lib/shared/catalina-sudo/sudo-askpass.osascript.js"
],
"beforeBuild": "./beforeBuild.js",
"afterSign": "./afterSignHook.js",
"mac": {
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
"entitlements": "entitlements.mac.plist",
"entitlementsInherit": "entitlements.mac.plist"
},
"dmg": {
"iconSize": 110,
"contents": [
{
"x": 140,
"y": 245
},
{
"x": 415,
"y": 245,
"type": "link",
"path": "/Applications"
}
],
"window": {
"width": 544,
"height": 407
}
},
"linux": {
"category": "Utility",
"packageCategory": "utils",
"synopsis": "balenaEtcher is a powerful OS image flasher built with web technologies to ensure flashing an SDCard or USB drive is a pleasant and safe experience. It protects you from accidentally writing to your hard-drives, ensures every byte of data was written correctly and much more."
},
"deb": {
"compression": "bzip2",
"priority": "optional",
"depends": [
"polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1"
]
},
"protocols": {
"name": "etcher",
"schemes": [
"etcher"
]
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2
CODEOWNERS Normal file
View File

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

View File

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

View File

@@ -3,7 +3,7 @@
# ---------------------------------------------------------------------
RESIN_SCRIPTS ?= ./scripts/resin
export NPM_VERSION ?= 6.14.8
export NPM_VERSION ?= 6.14.5
S3_BUCKET = artifacts.ci.balena-cloud.com
# This directory will be completely deleted by the `clean` rule
@@ -66,9 +66,6 @@ else
ifeq ($(shell uname -m),x86_64)
HOST_ARCH = x64
endif
ifeq ($(shell uname -m),arm64)
HOST_ARCH = aarch64
endif
endif
endif
@@ -89,9 +86,11 @@ TARGET_ARCH ?= $(HOST_ARCH)
# Electron
# ---------------------------------------------------------------------
electron-develop:
git submodule update --init && \
npm ci && \
npm run webpack
$(RESIN_SCRIPTS)/electron/install.sh \
-b $(shell pwd) \
-r $(TARGET_ARCH) \
-s $(PLATFORM) \
-m $(NPM_VERSION)
electron-test:
$(RESIN_SCRIPTS)/electron/test.sh \
@@ -126,7 +125,7 @@ TARGETS = \
.PHONY: $(TARGETS)
lint:
lint:
npm run lint
test:

184
README.md
View File

@@ -5,15 +5,16 @@
Etcher is a powerful OS image flasher built with web technologies to ensure
flashing an SDCard or USB drive is a pleasant and safe experience. It protects
you from accidentally writing to your hard-drives, ensures every byte of data
was written correctly, and much more. It can also directly flash Raspberry Pi devices that support [USB device boot mode](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#usb-device-boot-mode).
was written correctly and much more. It can also flash directly Raspberry Pi devices that support the usbboot protocol
[![Current Release](https://img.shields.io/github/release/balena-io/etcher.svg?style=flat-square)](https://balena.io/etcher)
[![License](https://img.shields.io/github/license/balena-io/etcher.svg?style=flat-square)](https://github.com/balena-io/etcher/blob/master/LICENSE)
[![Dependency status](https://img.shields.io/david/balena-io/etcher.svg?style=flat-square)](https://david-dm.org/balena-io/etcher)
[![Balena.io Forums](https://img.shields.io/discourse/https/forums.balena.io/topics.svg?style=flat-square&label=balena.io%20forums)](https://forums.balena.io/c/etcher)
---
***
[**Download**][etcher] | [**Support**][support] | [**Documentation**][user-documentation] | [**Contributing**][contributing] | [**Roadmap**][milestones]
[**Download**][etcher] | [**Support**][SUPPORT] | [**Documentation**][USER-DOCUMENTATION] | [**Contributing**][CONTRIBUTING] | [**Roadmap**][milestones]
## Supported Operating Systems
@@ -21,7 +22,7 @@ was written correctly, and much more. It can also directly flash Raspberry Pi de
- macOS 10.10 (Yosemite) and later
- Microsoft Windows 7 and later
**Note**: Etcher will run on any platform officially supported by
Note that Etcher will run on any platform officially supported by
[Electron][electron]. Read more in their
[documentation][electron-supported-platforms].
@@ -30,122 +31,81 @@ was written correctly, and much more. It can also directly flash Raspberry Pi de
Refer to the [downloads page][etcher] for the latest pre-made
installers for all supported operating systems.
## Packages
> [![Hosted By: Cloudsmith](https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith&style=for-the-badge)](https://cloudsmith.com) \
Package repository hosting is graciously provided by [Cloudsmith](https://cloudsmith.com).
Cloudsmith is the only fully hosted, cloud-native, universal package management solution, that
enables your organization to create, store and share packages in any format, to any place, with total
confidence.
#### Debian and Ubuntu based Package Repository (GNU/Linux x86/x64)
> Detailed or alternative steps in the [instructions by Cloudsmith](https://cloudsmith.io/~balena/repos/etcher/setup/#formats-deb)
1. Add Etcher debian repository:
1. Add Etcher Debian repository:
```sh
echo "deb https://deb.etcher.io stable etcher" | sudo tee /etc/apt/sources.list.d/balena-etcher.list
```
```sh
curl -1sLf \
'https://dl.cloudsmith.io/public/balena/etcher/setup.deb.sh' \
| sudo -E bash
```
2. Trust Bintray.com's GPG key:
2. Update and install:
```sh
sudo apt-key adv --keyserver hkps://keyserver.ubuntu.com:443 --recv-keys 379CE192D401AB61
```
```sh
sudo apt-get update #you can use apt instead of apt-get as well
sudo apt-get install balena-etcher-electron
```
>Note: after v1.7.9 the package name changed to `balena-etcher` (no electron at the end)
3. Update and install:
```sh
sudo apt-get update
sudo apt-get install balena-etcher-electron
```
##### Uninstall
```sh
sudo apt-get remove balena-etcher-electron
rm /etc/apt/sources.list.d/balena-etcher.list
apt-get clean
rm -rf /var/lib/apt/lists/*
apt-get update
sudo rm /etc/apt/sources.list.d/balena-etcher.list
sudo apt-get update
```
#### Redhat (RHEL) and Fedora-based Package Repository (GNU/Linux x86/x64)
> Detailed or alternative steps in the [instructions by Cloudsmith](https://cloudsmith.io/~balena/repos/etcher/setup/#formats-rpm)
##### DNF
1. Add Etcher rpm repository:
```sh
curl -1sLf \
'https://dl.cloudsmith.io/public/balena/etcher/setup.rpm.sh' \
| sudo -E bash
```
2. Update and install:
```sh
sudo dnf install -y balena-etcher-electron
```
>Note: after v1.7.9 the package name changed to `balena-etcher` (no electron at the end)
###### Uninstall
##### OpenSUSE LEAP & Tumbleweed install
```sh
rm /etc/yum.repos.d/balena-etcher.repo
rm /etc/yum.repos.d/balena-etcher-source.repo
sudo zypper ar https://balena.io/etcher/static/etcher-rpm.repo
sudo zypper ref
sudo zypper in balena-etcher-electron
```
##### Yum
1. Add Etcher rpm repository:
```sh
curl -1sLf \
'https://dl.cloudsmith.io/public/balena/etcher/setup.rpm.sh' \
| sudo -E bash
```
2. Update and install:
```sh
sudo yum install -y balena-etcher-electron
```
>Note: after v1.7.9 the package name changed to `balena-etcher` (no electron at the end)
###### Uninstall
```sh
sudo yum remove -y balena-etcher-electron
rm /etc/yum.repos.d/balena-etcher.repo
rm /etc/yum.repos.d/balena-etcher-source.repo
```
#### OpenSUSE LEAP & Tumbleweed install (zypper)
1. Add the repo
```sh
curl -1sLf \
'https://dl.cloudsmith.io/public/balena/etcher/setup.rpm.sh' \
| sudo -E bash
```
2. Update and install
```sh
sudo zypper up
sudo zypper install balena-etcher-electron
```
>Note: after v1.7.9 the package name changed to `balena-etcher` (no electron at the end)
##### Uninstall
```sh
sudo zypper rm balena-etcher-electron
# remove the repo
sudo zypper rr balena-etcher
sudo zypper rr balena-etcher-source
```
#### Redhat (RHEL) and Fedora based Package Repository (GNU/Linux x86/x64)
1. Add Etcher rpm repository:
```sh
sudo wget https://balena.io/etcher/static/etcher-rpm.repo -O /etc/yum.repos.d/etcher-rpm.repo
```
2. Update and install:
```sh
sudo yum install -y balena-etcher-electron
```
or
```sh
sudo dnf install -y balena-etcher-electron
```
##### Uninstall
```sh
sudo yum remove -y balena-etcher-electron
sudo rm /etc/yum.repos.d/etcher-rpm.repo
sudo yum clean all
sudo yum makecache fast
```
or
```sh
sudo dnf remove -y balena-etcher-electron
sudo rm /etc/yum.repos.d/etcher-rpm.repo
sudo dnf clean all
sudo dnf makecache
```
#### Solus (GNU/Linux x64)
@@ -160,10 +120,11 @@ sudo eopkg it etcher
sudo eopkg rm etcher
```
#### Arch/Manjaro Linux (GNU/Linux x64)
#### Arch Linux / Manjaro (GNU/Linux x64)
Etcher is offered through the Arch User Repository and can be installed on both Manjaro and Arch systems. You can compile it from the source code in this repository using [`balena-etcher`](https://aur.archlinux.org/packages/balena-etcher/). The following example uses a common AUR helper to install the latest release:
```sh
yay -S balena-etcher
```
@@ -173,18 +134,21 @@ yay -S balena-etcher
```sh
yay -R balena-etcher
```
#### WinGet (Windows)
This package is updated by [gh-action](https://github.com/vedantmgoyal2009/winget-releaser), and is kept up to date automatically.
#### Brew Cask (macOS)
Note that the Etcher Cask has to be updated manually to point to new versions,
so it might not refer to the latest version immediately after an Etcher
release.
```sh
winget install balenaEtcher #or Balena.Etcher
brew cask install balenaetcher
```
##### Uninstall
```sh
winget uninstall balenaEtcher
brew cask uninstall balenaetcher
```
#### Chocolatey (Windows)
@@ -204,22 +168,20 @@ choco uninstall etcher
## Support
If you're having any problem, please [raise an issue][newissue] on GitHub, and
If you're having any problem, please [raise an issue][newissue] on GitHub and
the balena.io team will be happy to help.
## License
Etcher is free software and may be redistributed under the terms specified in
Etcher is free software, and may be redistributed under the terms specified in
the [license].
[etcher]: https://balena.io/etcher
[electron]: https://electronjs.org/
[electron-supported-platforms]: https://electronjs.org/docs/tutorial/support#supported-platforms
[support]: https://github.com/balena-io/etcher/blob/master/SUPPORT.md
[contributing]: https://github.com/balena-io/etcher/blob/master/docs/CONTRIBUTING.md
[user-documentation]: https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
[SUPPORT]: https://github.com/balena-io/etcher/blob/master/SUPPORT.md
[CONTRIBUTING]: https://github.com/balena-io/etcher/blob/master/docs/CONTRIBUTING.md
[USER-DOCUMENTATION]: https://github.com/balena-io/etcher/blob/master/docs/USER-DOCUMENTATION.md
[milestones]: https://github.com/balena-io/etcher/milestones
[newissue]: https://github.com/balena-io/etcher/issues/new
[license]: https://github.com/balena-io/etcher/blob/master/LICENSE

View File

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

View File

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

View File

@@ -10,15 +10,13 @@ async function main(context) {
}
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
const appleId = 'accounts+apple@balena.io'
// https://github.com/electron/notarize/blob/main/README.md
await notarize({
appBundleId: 'io.balena.etcher',
appPath: `${appOutDir}/${appName}.app`,
appleId,
appleIdPassword
appleIdPassword: `@keychain:Application Loader: ${appleId}`
})
}

Binary file not shown.

26
beforeBuild.js Normal file
View File

@@ -0,0 +1,26 @@
'use strict'
const cp = require('child_process');
const rimraf = require('rimraf');
const process = require('process');
// Rebuild native modules for ia32 and run webpack again for the ia32 part of windows packages
exports.default = function(context) {
if (context.platform.name === 'windows') {
cp.execFileSync(
'bash',
['./node_modules/.bin/electron-rebuild', '--types', 'dev', '--arch', context.arch],
);
rimraf.sync('generated');
cp.execFileSync(
'bash',
['./node_modules/.bin/webpack'],
{
env: {
...process.env,
npm_config_target_arch: context.arch,
},
},
);
}
}

View File

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

View File

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

View File

@@ -8,14 +8,10 @@ Releasing
### Release Types
- **draft**: A continues snapshot of current master, made by the CI services
- **pre-release** (default): A continues snapshot of current master, made by the CI services
- **release**: Full releases
Draft release is created from each PR, tagged with the branch name.
All merged PR will generate a new tag/version as a *pre-release*.
Mark the pre-release as final when it is necessary, then distribute the packages in alternative channels as necessary.
- **snapshot** (default): A continues snapshot of current master, made by the CI services
- **production**: Full releases
### Flight Plan
#### Preparation
@@ -35,10 +31,11 @@ Mark the pre-release as final when it is necessary, then distribute the packages
- [Post release note to forums](https://forums.balena.io/c/etcher)
- [Submit Windows binaries to Symantec for whitelisting](#submitting-binaries-to-symantec)
- [Update the website](https://github.com/balena-io/etcher-homepage)
- Wait 2-3 hours for analytics (Sentry, Amplitude) to trickle in and check for elevated error rates, or regressions
- Wait 2-3 hours for analytics (Sentry, Mixpanel) to trickle in and check for elevated error rates, or regressions
- If regressions arise; pull the release, and release a patched version, else:
- [Upload deb & rpm packages to Cloudfront](#uploading-packages-to-cloudfront)
- Post changelog with `#release-notes` tag on internal chat
- [Upload deb & rpm packages to Bintray](#uploading-packages-to-bintray)
- [Upload build artifacts to Amazon S3](#uploading-binaries-to-amazon-s3)
- Post changelog with `#release-notes` tag on Flowdock
- If this release packs noteworthy major changes:
- Write a blog post about it, and / or
- Write about it to the Etcher mailing list
@@ -51,7 +48,7 @@ Make sure to set the analytics tokens when generating production release binarie
```bash
export ANALYTICS_SENTRY_TOKEN="xxxxxx"
export ANALYTICS_AMPLITUDE_TOKEN="xxxxxx"
export ANALYTICS_MIXPANEL_TOKEN="xxxxxx"
```
#### Linux
@@ -60,16 +57,46 @@ export ANALYTICS_AMPLITUDE_TOKEN="xxxxxx"
**NOTE:** Make sure to adjust the path as necessary (here the Etcher repository has been cloned to `/home/$USER/code/etcher`)
```bash
./scripts/build/docker/run-command.sh -r x64 -s . -c "make distclean"
```
##### Generating artifacts
The artifacts are generated by the CI and published as draft-release or pre-release.
`electron-builder` is used to create the packaged application.
```bash
# x64
# Build Debian packages
./scripts/build/docker/run-command.sh -r x64 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-debian"
# Build RPM packages
./scripts/build/docker/run-command.sh -r x64 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-redhat"
# Build AppImages
./scripts/build/docker/run-command.sh -r x64 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-appimage"
# x86
# Build Debian packages
./scripts/build/docker/run-command.sh -r x86 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-debian"
# Build RPM packages
./scripts/build/docker/run-command.sh -r x86 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-redhat"
# Build AppImages
./scripts/build/docker/run-command.sh -r x86 -s . -c "make electron-develop && make RELEASE_TYPE=production electron-installer-appimage"
```
#### Mac OS
**ATTENTION:** For production releases you'll need the code-signing key,
and set `CSC_NAME` to generate signed binaries on Mac OS.
```bash
make electron-develop
# Build the zip
make RELEASE_TYPE=production electron-installer-app-zip
# Build the dmg
make RELEASE_TYPE=production electron-installer-dmg
```
#### Windows
**ATTENTION:** For production releases you'll need the code-signing key,
@@ -78,10 +105,38 @@ and set `CSC_LINK`, and `CSC_KEY_PASSWORD` to generate signed binaries on Window
**NOTE:**
- Keep in mind to also generate artifacts for x86, with `TARGET_ARCH=x86`.
```bash
make electron-develop
### Uploading packages to Cloudfront
# Build the Portable version
make RELEASE_TYPE=production electron-installer-portable
# Build the Installer
make RELEASE_TYPE=production electron-installer-nsis
```
Log in to cloudfront and upload the `rpm` and `deb` files.
### Uploading packages to Bintray
```bash
export BINTRAY_USER="username@account"
export BINTRAY_API_KEY="youruserapikey"
```
```bash
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "debian" -y "debian" -r "x64" -f "dist/etcher-electron_1.2.1_amd64.deb"
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "debian" -y "debian" -r "x86" -f "dist/etcher-electron_1.2.1_i386.deb"
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "redhat" -y "redhat" -r "x64" -f "dist/etcher-electron-1.2.1.x86_64.rpm"
./scripts/publish/bintray.sh -c "etcher" -t "production" -v "1.2.1" -o "etcher" -p "redhat" -y "redhat" -r "x86" -f "dist/etcher-electron-1.2.1.i686.rpm"
```
### Uploading binaries to Amazon S3
```bash
export S3_KEY="..."
```
```bash
./scripts/publish/aws-s3.sh -b "balena-production-downloads" -v "1.2.1" -p "etcher" -f "dist/<filename>"
```
### Dealing with a Problematic Release

View File

@@ -112,4 +112,4 @@ 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**
F5), and check that initial events are not sent to Mixpanel**

View File

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

View File

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

View File

@@ -1,23 +1,21 @@
# https://www.electron.build/configuration/configuration
appId: io.balena.etcher
copyright: Copyright 2016-2023 Balena Ltd
copyright: Copyright 2016-2020 Balena Ltd
productName: balenaEtcher
afterPack: ./afterPack.js
afterSign: ./afterSignHook.js
npmRebuild: true
nodeGypRebuild: false
publish: null
beforeBuild: "./beforeBuild.js"
afterPack: "./afterPack.js"
asar: false
files:
- generated
- lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js
- lib/shared/catalina-sudo/sudo-askpass.osascript-en.js
- lib/shared/catalina-sudo/sudo-askpass.osascript.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
@@ -34,10 +32,6 @@ dmg:
height: 405
win:
icon: assets/icon.ico
target:
- zip
- nsis
- portable
nsis:
oneClick: true
runAfterFinish: true
@@ -50,23 +44,17 @@ portable:
artifactName: "${productName}-Portable-${version}.${ext}"
requestExecutionLevel: user
linux:
icon: assets/iconset
target:
- AppImage
- rpm
- deb
category: Utility
packageCategory: utils
executableName: balena-etcher
executableName: balena-etcher-electron
synopsis: balenaEtcher is a powerful OS image flasher built with web technologies to ensure flashing an SDCard or USB drive is a pleasant and safe experience. It protects you from accidentally writing to your hard-drives, ensures every byte of data was written correctly and much more.
appImage:
artifactName: ${productName}-${version}-${env.ELECTRON_BUILDER_ARCHITECTURE}.${ext}
icon: assets/iconset
deb:
priority: optional
compression: bzip2
depends:
- gconf-service
- gconf2
- gconf-service
- libappindicator1
- libasound2
- libatk1.0-0
- libc6
@@ -100,7 +88,6 @@ deb:
- libxss1
- libxtst6
- polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1
afterInstall: "./after-install.tpl"
rpm:
depends:
- util-linux

View File

@@ -15,7 +15,6 @@
*/
import * as electron from 'electron';
import * as remote from '@electron/remote';
import * as sdk from 'etcher-sdk';
import * as _ from 'lodash';
import outdent from 'outdent';
@@ -24,12 +23,17 @@ import * as ReactDOM from 'react-dom';
import { v4 as uuidV4 } from 'uuid';
import * as packageJSON from '../../../package.json';
import { DrivelistDrive, isSourceDrive } from '../../shared/drive-constraints';
import {
DrivelistDrive,
isDriveValid,
isSourceDrive,
} from '../../shared/drive-constraints';
import * as EXIT_CODES from '../../shared/exit-codes';
import * as messages from '../../shared/messages';
import * as availableDrives from './models/available-drives';
import * as flashState from './models/flash-state';
import { deselectImage, getImage } from './models/selection-state';
import { init as ledsInit } from './models/leds';
import { deselectImage, getImage, selectDrive } from './models/selection-state';
import * as settings from './models/settings';
import { Actions, observe, store } from './models/store';
import * as analytics from './modules/analytics';
@@ -38,8 +42,6 @@ import * as exceptionReporter from './modules/exception-reporter';
import * as osDialog from './os/dialog';
import * as windowProgress from './os/window-progress';
import MainPage from './pages/main/MainPage';
import './css/main.css';
import * as i18next from 'i18next';
window.addEventListener(
'unhandledrejection',
@@ -218,7 +220,8 @@ function prepareDrive(drive: Drive) {
disabled: true,
icon: 'warning',
size: null,
link: 'https://www.raspberrypi.com/documentation/computers/compute-module.html#flashing-the-compute-module-emmc',
link:
'https://www.raspberrypi.org/documentation/hardware/computemodule/cm-emmc-flashing.md',
linkCTA: 'Install',
linkTitle: 'Install missing drivers',
linkMessage: outdent`
@@ -248,6 +251,14 @@ async function addDrive(drive: Drive) {
const drives = getDrives();
drives[preparedDrive.device] = preparedDrive;
setDrives(drives);
if (
(await settings.get('autoSelectAllDrives')) &&
drive instanceof sdk.sourceDestination.BlockDevice &&
// @ts-ignore BlockDevice.drive is private
isDriveValid(drive.drive, getImage())
) {
selectDrive(drive.device);
}
}
function removeDrive(drive: Drive) {
@@ -297,8 +308,6 @@ driveScanner.start();
let popupExists = false;
analytics.initAnalytics();
window.addEventListener('beforeunload', async (event) => {
if (!flashState.isFlashing() || popupExists) {
analytics.logEvent('Close application', {
@@ -317,9 +326,9 @@ window.addEventListener('beforeunload', async (event) => {
try {
const confirmed = await osDialog.showWarning({
confirmationLabel: i18next.t('yesExit'),
rejectionLabel: i18next.t('cancel'),
title: i18next.t('reallyExit'),
confirmationLabel: 'Yes, quit',
rejectionLabel: 'Cancel',
title: 'Are you sure you want to close Etcher?',
description: messages.warning.exitWhileFlashing(),
});
if (confirmed) {
@@ -328,8 +337,8 @@ window.addEventListener('beforeunload', async (event) => {
});
// This circumvents the 'beforeunload' event unlike
// remote.app.quit() which does not.
remote.process.exit(EXIT_CODES.SUCCESS);
// electron.remote.app.quit() which does not.
electron.remote.process.exit(EXIT_CODES.SUCCESS);
}
analytics.logEvent('Close rejected while flashing', {
@@ -337,19 +346,13 @@ window.addEventListener('beforeunload', async (event) => {
flashingWorkflowUuid,
});
popupExists = false;
} catch (error: any) {
} catch (error) {
exceptionReporter.report(error);
}
});
export async function main() {
try {
const { init: ledsInit } = require('./models/leds');
await ledsInit();
} catch (error: any) {
exceptionReporter.report(error);
}
async function main() {
await ledsInit();
ReactDOM.render(
React.createElement(MainPage),
document.getElementById('main'),
@@ -365,3 +368,5 @@ export async function main() {
},
);
}
main();

View File

@@ -42,9 +42,8 @@ import {
Table,
} from '../../styled-components';
import DriveSVGIcon from '../../../assets/tgt.svg';
import { SourceMetadata } from '../source-selector/source-selector';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import * as i18next from 'i18next';
interface UsbbootDrive extends sourceDestination.UsbbootDrive {
progress: number;
@@ -138,18 +137,15 @@ const InitProgress = styled(
`;
export interface DriveSelectorProps
extends Omit<ModalProps, 'done' | 'cancel' | 'onSelect'> {
write: boolean;
extends Omit<ModalProps, 'done' | 'cancel'> {
multipleSelection: boolean;
showWarnings?: boolean;
cancel: (drives: DrivelistDrive[]) => void;
cancel: () => void;
done: (drives: DrivelistDrive[]) => void;
titleLabel: string;
emptyListLabel: string;
emptyListIcon: JSX.Element;
selectedList?: DrivelistDrive[];
updateSelectedList?: () => DrivelistDrive[];
onSelect?: (drive: DrivelistDrive) => void;
}
interface DriveSelectorState {
@@ -170,14 +166,12 @@ export class DriveSelector extends React.Component<
> {
private unsubscribe: (() => void) | undefined;
tableColumns: Array<TableColumn<Drive>>;
originalList: DrivelistDrive[];
constructor(props: DriveSelectorProps) {
super(props);
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
const selectedList = this.props.selectedList || [];
this.originalList = [...(this.props.selectedList || [])];
this.state = {
drives: getDrives(),
@@ -190,7 +184,7 @@ export class DriveSelector extends React.Component<
this.tableColumns = [
{
field: 'description',
label: i18next.t('drives.name'),
label: 'Name',
render: (description: string, drive: Drive) => {
if (isDrivelistDrive(drive)) {
const isLargeDrive = isDriveSizeLarge(drive);
@@ -204,9 +198,7 @@ export class DriveSelector extends React.Component<
fill={drive.isSystem ? '#fca321' : '#8f9297'}
/>
)}
<Txt ml={(hasWarnings && 8) || 0}>
{middleEllipsis(description, 32)}
</Txt>
<Txt ml={(hasWarnings && 8) || 0}>{description}</Txt>
</Flex>
);
}
@@ -216,7 +208,7 @@ export class DriveSelector extends React.Component<
{
field: 'description',
key: 'size',
label: i18next.t('drives.size'),
label: 'Size',
render: (_description: string, drive: Drive) => {
if (isDrivelistDrive(drive) && drive.size !== null) {
return prettyBytes(drive.size);
@@ -226,7 +218,7 @@ export class DriveSelector extends React.Component<
{
field: 'description',
key: 'link',
label: i18next.t('drives.location'),
label: 'Location',
render: (_description: string, drive: Drive) => {
return (
<Txt>
@@ -266,8 +258,7 @@ export class DriveSelector extends React.Component<
return (
isUsbbootDrive(drive) ||
isDriverlessDrive(drive) ||
!isDriveValid(drive, image, this.props.write) ||
(this.props.write && drive.isReadOnly)
!isDriveValid(drive, image)
);
}
@@ -310,9 +301,9 @@ export class DriveSelector extends React.Component<
case compatibility.system():
return warning.systemDrive();
case compatibility.tooSmall():
const size =
const recommendedDriveSize =
this.state.image?.recommendedDriveSize || this.state.image?.size || 0;
return warning.tooSmall({ size }, drive);
return warning.unrecommendedDriveSize({ recommendedDriveSize }, drive);
}
}
@@ -320,7 +311,6 @@ export class DriveSelector extends React.Component<
const statuses: DriveStatus[] = getDriveImageCompatibilityStatuses(
drive,
this.state.image,
this.props.write,
).slice(0, 2);
return (
// the column render fn expects a single Element
@@ -355,6 +345,16 @@ export class DriveSelector extends React.Component<
}
}
private deselectingAll(rows: DrivelistDrive[]) {
return (
rows.length > 0 &&
rows.length === this.state.selectedList.length &&
this.state.selectedList.every(
(d) => rows.findIndex((r) => d.device === r.device) > -1,
)
);
}
componentDidMount() {
this.unsubscribe = store.subscribe(() => {
const drives = getDrives();
@@ -380,8 +380,8 @@ export class DriveSelector extends React.Component<
const displayedDrives = this.getDisplayedDrives(drives);
const disabledDrives = this.getDisabledDrives(drives, image);
const numberOfSystemDrives = drives.filter(isSystemDrive).length;
const numberOfDisplayedSystemDrives =
displayedDrives.filter(isSystemDrive).length;
const numberOfDisplayedSystemDrives = displayedDrives.filter(isSystemDrive)
.length;
const numberOfHiddenSystemDrives =
numberOfSystemDrives - numberOfDisplayedSystemDrives;
const hasSystemDrives = selectedList.filter(isSystemDrive).length;
@@ -400,14 +400,14 @@ export class DriveSelector extends React.Component<
color="#5b82a7"
style={{ fontWeight: 600 }}
>
{i18next.t('drives.find', { length: drives.length })}
{drives.length} found
</Txt>
</Flex>
}
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
cancel={() => cancel(this.originalList)}
cancel={cancel}
done={() => done(selectedList)}
action={i18next.t('drives.select', { select: selectedList.length })}
action={`Select (${selectedList.length})`}
primaryButtonProps={{
primary: !showWarnings,
warning: showWarnings,
@@ -422,7 +422,7 @@ export class DriveSelector extends React.Component<
alignItems="center"
width="100%"
>
{this.props.emptyListIcon}
<DriveSVGIcon width="40px" height="90px" />
<b>{this.props.emptyListLabel}</b>
</Flex>
) : (
@@ -445,34 +445,14 @@ export class DriveSelector extends React.Component<
onCheck={(rows: Drive[]) => {
let newSelection = rows.filter(isDrivelistDrive);
if (this.props.multipleSelection) {
if (rows.length === 0) {
if (this.deselectingAll(newSelection)) {
newSelection = [];
}
const deselecting = selectedList.filter(
(selected) =>
newSelection.filter(
(row) => row.device === selected.device,
).length === 0,
);
const selecting = newSelection.filter(
(row) =>
selectedList.filter(
(selected) => row.device === selected.device,
).length === 0,
);
deselecting.concat(selecting).forEach((row) => {
if (this.props.onSelect) {
this.props.onSelect(row);
}
});
this.setState({
selectedList: newSelection,
});
return;
}
if (this.props.onSelect) {
this.props.onSelect(newSelection[newSelection.length - 1]);
}
this.setState({
selectedList: newSelection.slice(newSelection.length - 1),
});
@@ -484,9 +464,6 @@ export class DriveSelector extends React.Component<
) {
return;
}
if (this.props.onSelect) {
this.props.onSelect(row);
}
const index = selectedList.findIndex(
(d) => d.device === row.device,
);
@@ -513,11 +490,7 @@ export class DriveSelector extends React.Component<
>
<Flex alignItems="center">
<ChevronDownSvg height="1em" fill="currentColor" />
<Txt ml={8}>
{i18next.t('drives.showHidden', {
num: numberOfHiddenSystemDrives,
})}
</Txt>
<Txt ml={8}>Show {numberOfHiddenSystemDrives} hidden</Txt>
</Flex>
</Link>
)}
@@ -525,7 +498,7 @@ export class DriveSelector extends React.Component<
)}
{this.props.showWarnings && hasSystemDrives ? (
<Alert className="system-drive-alert" style={{ width: '67%' }}>
{i18next.t('drives.systemDriveDanger')}
Selecting your system drive is dangerous and will erase your drive!
</Alert>
) : null}
@@ -539,21 +512,19 @@ export class DriveSelector extends React.Component<
if (missingDriversModal.drive !== undefined) {
openExternal(missingDriversModal.drive.link);
}
} catch (error: any) {
} catch (error) {
logException(error);
} finally {
this.setState({ missingDriversModal: {} });
}
}}
action={i18next.t('yesContinue')}
action="Yes, continue"
cancelButtonProps={{
children: i18next.t('cancel'),
children: 'Cancel',
}}
children={
missingDriversModal.drive.linkMessage ||
i18next.t('drives.openInBrowser', {
link: missingDriversModal.drive.link,
})
`Etcher will open ${missingDriversModal.drive.link} in your browser`
}
/>
)}

View File

@@ -7,7 +7,6 @@ import { middleEllipsis } from '../../utils/middle-ellipsis';
import * as prettyBytes from 'pretty-bytes';
import { DriveWithWarnings } from '../../pages/main/Flash';
import * as i18next from 'i18next';
const DriveStatusWarningModal = ({
done,
@@ -18,12 +17,12 @@ const DriveStatusWarningModal = ({
isSystem: boolean;
drivesWithWarnings: DriveWithWarnings[];
}) => {
let warningSubtitle = i18next.t('drives.largeDriveWarning');
let warningCta = i18next.t('drives.largeDriveWarningMsg');
let warningSubtitle = 'You are about to erase an unusually large drive';
let warningCta = 'Are you sure the selected drive is not a storage drive?';
if (isSystem) {
warningSubtitle = i18next.t('drives.systemDriveWarning');
warningCta = i18next.t('drives.systemDriveWarningMsg');
warningSubtitle = "You are about to erase your computer's drives";
warningCta = 'Are you sure you want to flash your system drive?';
}
return (
<Modal
@@ -34,9 +33,9 @@ const DriveStatusWarningModal = ({
cancelButtonProps={{
primary: false,
warning: true,
children: i18next.t('drives.changeTarget'),
children: 'Change target',
}}
action={i18next.t('sure')}
action={"Yes, I'm sure"}
primaryButtonProps={{
primary: false,
outline: true,
@@ -51,7 +50,7 @@ const DriveStatusWarningModal = ({
<Flex flexDirection="column">
<ExclamationTriangleSvg height="2em" fill="#fca321" />
<Txt fontSize="24px" color="#fca321">
{i18next.t('warning')}
WARNING!
</Txt>
</Flex>
<Txt fontSize="24px">{warningSubtitle}</Txt>

View File

@@ -20,7 +20,6 @@ import { v4 as uuidV4 } from 'uuid';
import * as flashState from '../../models/flash-state';
import * as selectionState from '../../models/selection-state';
import * as settings from '../../models/settings';
import { Actions, store } from '../../models/store';
import * as analytics from '../../modules/analytics';
import { FlashAnother } from '../flash-another/flash-another';
@@ -40,27 +39,24 @@ function restart(goToMain: () => void) {
goToMain();
}
async function getSuccessBannerURL() {
return (
(await settings.get('successBannerURL')) ??
'https://efp.balena.io/success-banner?borderTop=false&darkBackground=true'
);
}
function FinishPage({ goToMain }: { goToMain: () => void }) {
const [webviewShowing, setWebviewShowing] = React.useState(false);
const [successBannerURL, setSuccessBannerURL] = React.useState('');
(async () => {
setSuccessBannerURL(await getSuccessBannerURL());
})();
const flashResults = flashState.getFlashResults();
const errors: FlashError[] = (
store.getState().toJS().failedDeviceErrors || []
).map(([, error]: [string, FlashError]) => ({
...error,
}));
const { averageSpeed, blockmappedSize, bytesWritten, failed, size } =
flashState.getFlashState();
let errors: FlashError[] = flashResults.results?.errors;
if (errors === undefined) {
errors = (store.getState().toJS().failedDevicePaths || []).map(
([, error]: [string, FlashError]) => ({
...error,
}),
);
}
const {
averageSpeed,
blockmappedSize,
bytesWritten,
failed,
size,
} = flashState.getFlashState();
const {
skip,
results = {
@@ -89,7 +85,7 @@ function FinishPage({ goToMain }: { goToMain: () => void }) {
}}
>
<FlashResults
image={selectionState.getImage()?.name}
image={selectionState.getImageName()}
results={results}
skip={skip}
errors={errors}
@@ -103,20 +99,18 @@ function FinishPage({ goToMain }: { goToMain: () => void }) {
}}
/>
</Flex>
{successBannerURL.length && (
<SafeWebview
src={successBannerURL}
onWebviewShow={setWebviewShowing}
style={{
display: webviewShowing ? 'flex' : 'none',
position: 'absolute',
right: 0,
bottom: 0,
width: '63.8vw',
height: '100vh',
}}
/>
)}
<SafeWebview
src="https://www.balena.io/etcher/success-banner?borderTop=false&darkBackground=true"
onWebviewShow={setWebviewShowing}
style={{
display: webviewShowing ? 'flex' : 'none',
position: 'absolute',
right: 0,
bottom: 0,
width: '63.8vw',
height: '100vh',
}}
/>
</Flex>
);
}

View File

@@ -17,7 +17,6 @@
import * as React from 'react';
import { BaseButton } from '../../styled-components';
import * as i18next from 'i18next';
export interface FlashAnotherProps {
onClick: () => void;
@@ -26,7 +25,7 @@ export interface FlashAnotherProps {
export const FlashAnother = (props: FlashAnotherProps) => {
return (
<BaseButton primary onClick={props.onClick}>
{i18next.t('flash.another')}
Flash another
</BaseButton>
);
};

View File

@@ -17,6 +17,7 @@
import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg';
import CheckCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/check-circle.svg';
import TimesCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/times-circle.svg';
import * as _ from 'lodash';
import outdent from 'outdent';
import * as React from 'react';
import { Flex, FlexProps, Link, TableColumn, Txt } from 'rendition';
@@ -26,48 +27,49 @@ import { progress } from '../../../../shared/messages';
import { bytesToMegabytes } from '../../../../shared/units';
import FlashSvg from '../../../assets/flash.svg';
import { getDrives } from '../../models/available-drives';
import { resetState } from '../../models/flash-state';
import * as selection from '../../models/selection-state';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import { Modal, Table } from '../../styled-components';
import * as i18next from 'i18next';
const ErrorsTable = styled((props) => <Table<FlashError> {...props} />)`
&&& [data-display='table-head'],
&&& [data-display='table-body'] {
> [data-display='table-row'] {
> [data-display='table-cell'] {
&:first-child {
width: 30%;
}
[data-display='table-head'],
[data-display='table-body'] {
[data-display='table-cell'] {
&:first-child {
width: 30%;
}
&:nth-child(2) {
width: 20%;
}
&:nth-child(2) {
width: 20%;
}
&:last-child {
width: 50%;
}
&:last-child {
width: 50%;
}
}
}
`;
const DoneIcon = (props: {
skipped: boolean;
color: string;
allFailed: boolean;
someFailed: boolean;
}) => {
const { allFailed, someFailed } = props;
const someOrAllFailed = allFailed || someFailed;
const svgProps = {
width: '28px',
fill: props.color,
width: '24px',
fill: someOrAllFailed ? '#c6c8c9' : '#1ac135',
style: {
width: '28px',
height: '28px',
marginTop: '-25px',
marginLeft: '13px',
zIndex: 1,
color: someOrAllFailed ? '#c6c8c9' : '#1ac135',
},
};
return props.allFailed && !props.skipped ? (
return allFailed && !props.skipped ? (
<TimesCircleSvg {...svgProps} />
) : (
<CheckCircleSvg {...svgProps} />
@@ -89,34 +91,21 @@ function formattedErrors(errors: FlashError[]) {
const columns: Array<TableColumn<FlashError>> = [
{
field: 'description',
label: i18next.t('flash.target'),
label: 'Target',
},
{
field: 'device',
label: i18next.t('flash.location'),
label: 'Location',
},
{
field: 'message',
label: i18next.t('flash.error'),
label: 'Error',
render: (message: string, { code }: FlashError) => {
return message ?? code;
},
},
];
function getEffectiveSpeed(results: {
sourceMetadata: {
size: number;
blockmappedSize?: number;
};
averageFlashingSpeed: number;
}) {
const flashedSize =
results.sourceMetadata.blockmappedSize ?? results.sourceMetadata.size;
const timeSpent = flashedSize / results.averageFlashingSpeed;
return results.sourceMetadata.size / timeSpent;
}
export function FlashResults({
goToMain,
image = '',
@@ -130,18 +119,22 @@ export function FlashResults({
errors: FlashError[];
skip: boolean;
results: {
bytesWritten: number;
sourceMetadata: {
size: number;
blockmappedSize?: number;
blockmappedSize: number;
};
averageFlashingSpeed: number;
devices: { failed: number; successful: number };
};
} & FlexProps) {
const [showErrorsInfo, setShowErrorsInfo] = React.useState(false);
const allFailed = !skip && results.devices.successful === 0;
const someFailed = results.devices.failed !== 0 || errors.length !== 0;
const effectiveSpeed = bytesToMegabytes(getEffectiveSpeed(results)).toFixed(
const allFailed = results.devices.successful === 0;
const effectiveSpeed = _.round(
bytesToMegabytes(
results.sourceMetadata.size /
(results.bytesWritten / results.averageFlashingSpeed),
),
1,
);
return (
@@ -158,43 +151,42 @@ export function FlashResults({
<DoneIcon
skipped={skip}
allFailed={allFailed}
color={allFailed || someFailed ? '#c6c8c9' : '#1ac135'}
someFailed={results.devices.failed !== 0}
/>
<Txt>{middleEllipsis(image, 24)}</Txt>
</Flex>
<Txt fontSize={24} color="#fff" mb="17px">
{allFailed
? i18next.t('flash.flashFailed')
: i18next.t('flash.flashCompleted')}
Flash Complete!
</Txt>
{skip ? <Txt color="#7e8085">{i18next.t('flash.skip')}</Txt> : null}
{skip ? <Flex color="#7e8085">Validation has been skipped</Flex> : null}
</Flex>
<Flex flexDirection="column" color="#7e8085">
{results.devices.successful !== 0 ? (
<Flex alignItems="center">
<CircleSvg width="14px" fill="#1ac135" />
<Txt ml="10px" color="#fff">
{results.devices.successful}
</Txt>
<Txt ml="10px">
{progress.successful(results.devices.successful)}
</Txt>
</Flex>
) : null}
{errors.length !== 0 ? (
<Flex alignItems="center">
<CircleSvg width="14px" fill="#ff4444" />
<Txt ml="10px" color="#fff">
{errors.length}
</Txt>
<Txt ml="10px" tooltip={formattedErrors(errors)}>
{progress.failed(errors.length)}
</Txt>
<Link ml="10px" onClick={() => setShowErrorsInfo(true)}>
{i18next.t('flash.moreInfo')}
</Link>
</Flex>
) : null}
{Object.entries(results.devices).map(([type, quantity]) => {
const failedTargets = type === 'failed';
return quantity ? (
<Flex alignItems="center">
<CircleSvg
width="14px"
fill={type === 'failed' ? '#ff4444' : '#1ac135'}
color={failedTargets ? '#ff4444' : '#1ac135'}
/>
<Txt ml="10px" color="#fff">
{quantity}
</Txt>
<Txt
ml="10px"
tooltip={failedTargets ? formattedErrors(errors) : undefined}
>
{progress[type](quantity)}
</Txt>
{failedTargets && (
<Link ml="10px" onClick={() => setShowErrorsInfo(true)}>
more info
</Link>
)}
</Flex>
) : null;
})}
{!allFailed && (
<Txt
fontSize="10px"
@@ -202,9 +194,12 @@ export function FlashResults({
fontWeight: 500,
textAlign: 'center',
}}
tooltip={i18next.t('flash.speedTip')}
tooltip={outdent({ newline: ' ' })`
The speed is calculated by dividing the image size by the flashing time.
Disk images with ext partitions flash faster as we are able to skip unused parts.
`}
>
{i18next.t('flash.speed', { speed: effectiveSpeed })}
Effective speed: {effectiveSpeed} MB/s
</Txt>
)}
</Flex>
@@ -214,24 +209,21 @@ export function FlashResults({
titleElement={
<Flex alignItems="baseline" mb={18}>
<Txt fontSize={24} align="left">
{i18next.t('failedTarget')}
Failed targets
</Txt>
</Flex>
}
action={i18next.t('failedRetry')}
action="Retry failed targets"
cancel={() => setShowErrorsInfo(false)}
done={() => {
setShowErrorsInfo(false);
resetState();
getDrives()
.map((drive) => {
selection.deselectDrive(drive.device);
return drive.device;
})
.filter((driveDevice) =>
errors.some((error) => error.device === driveDevice),
selection
.getSelectedDrives()
.filter((drive) =>
errors.every((error) => error.device !== drive.device),
)
.forEach((driveDevice) => selection.selectDrive(driveDevice));
.forEach((drive) => selection.deselectDrive(drive.device));
goToMain();
}}
>

View File

@@ -18,24 +18,22 @@ import * as React from 'react';
import { Flex, Button, ProgressBar, Txt } from 'rendition';
import { default as styled } from 'styled-components';
import { fromFlashState } from '../../modules/progress-status';
import { fromFlashState, FlashState } from '../../modules/progress-status';
import { StepButton } from '../../styled-components';
import * as i18next from 'i18next';
const FlashProgressBar = styled(ProgressBar)`
> div {
width: 100%;
width: 220px;
height: 12px;
color: white !important;
text-shadow: none !important;
transition-duration: 0s;
> div {
transition-duration: 0s;
}
}
width: 100%;
width: 220px;
height: 12px;
margin-bottom: 6px;
border-radius: 14px;
@@ -46,7 +44,7 @@ const FlashProgressBar = styled(ProgressBar)`
`;
interface ProgressButtonProps {
type: 'decompressing' | 'flashing' | 'verifying';
type: FlashState['type'];
active: boolean;
percentage: number;
position: number;
@@ -60,10 +58,12 @@ const colors = {
decompressing: '#00aeef',
flashing: '#da60ff',
verifying: '#1ac135',
downloading: '#00aeef',
default: '#00aeef',
} as const;
const CancelButton = styled(({ type, onClick, ...props }) => {
const status = type === 'verifying' ? i18next.t('skip') : i18next.t('cancel');
const status = type === 'verifying' ? 'Skip' : 'Cancel';
return (
<Button plain onClick={() => onClick(status)} {...props}>
{status}
@@ -71,7 +71,6 @@ const CancelButton = styled(({ type, onClick, ...props }) => {
);
})`
font-weight: 600;
&&& {
width: auto;
height: auto;
@@ -81,6 +80,7 @@ const CancelButton = styled(({ type, onClick, ...props }) => {
export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
public render() {
const type = this.props.type || 'default';
const percentage = this.props.percentage;
const warning = this.props.warning;
const { status, position } = fromFlashState({
@@ -88,7 +88,6 @@ export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
percentage,
position: this.props.position,
});
const type = this.props.type || 'default';
if (this.props.active) {
return (
<>
@@ -129,7 +128,7 @@ export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
marginTop: 30,
}}
>
{i18next.t('flash.flashNow')}
Flash!
</StepButton>
);
}

View File

@@ -31,7 +31,9 @@ interface ReducedFlashingInfosProps {
style?: React.CSSProperties;
}
export class ReducedFlashingInfos extends React.Component<ReducedFlashingInfosProps> {
export class ReducedFlashingInfos extends React.Component<
ReducedFlashingInfosProps
> {
constructor(props: ReducedFlashingInfosProps) {
super(props);
this.state = {};

View File

@@ -15,8 +15,6 @@
*/
import * as electron from 'electron';
import * as remote from '@electron/remote';
import * as _ from 'lodash';
import * as React from 'react';
import * as packageJSON from '../../../../../package.json';
@@ -95,10 +93,10 @@ export class SafeWebview extends React.PureComponent<
);
this.entryHref = url.href;
// Events steal 'this'
this.didFailLoad = _.bind(this.didFailLoad, this);
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this);
this.didFailLoad = this.didFailLoad.bind(this);
this.didGetResponseDetails = this.didGetResponseDetails.bind(this);
// Make a persistent electron session for the webview
this.session = remote.session.fromPartition(ELECTRON_SESSION, {
this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, {
// Disable the cache for the session such that new content shows up when refreshing
cache: false,
});
@@ -184,10 +182,7 @@ export class SafeWebview extends React.PureComponent<
// only care about this event if it's a request for the main frame
if (event.resourceType === 'mainFrame') {
const HTTP_OK = 200;
const { webContents, ...webviewEvent } = event;
analytics.logEvent('SafeWebview loaded', {
...webviewEvent,
});
analytics.logEvent('SafeWebview loaded', { event });
this.setState({
shouldShow: event.statusCode === HTTP_OK,
});

View File

@@ -16,55 +16,60 @@
import GithubSvg from '@fortawesome/fontawesome-free/svgs/brands/github.svg';
import * as _ from 'lodash';
import * as os from 'os';
import * as React from 'react';
import { Box, Checkbox, Flex, TextWithCopy, Txt } from 'rendition';
import { Flex, Checkbox, Txt } from 'rendition';
import { version, packageType } from '../../../../../package.json';
import * as settings from '../../models/settings';
import * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external';
import { Modal } from '../../styled-components';
import * as i18next from 'i18next';
import { etcherProInfo } from '../../utils/etcher-pro-specific';
const platform = os.platform();
interface Setting {
name: string;
label: string | JSX.Element;
options?: {
description: string;
confirmLabel: string;
};
hide?: boolean;
}
async function getSettingsList(): Promise<Setting[]> {
const list: Setting[] = [
return [
{
name: 'errorReporting',
label: i18next.t('settings.errorReporting'),
label: 'Anonymously report errors and usage statistics to balena.io',
},
{
name: 'autoBlockmapping',
label: i18next.t('settings.trimExtPartitions'),
name: 'unmountOnSuccess',
/**
* On Windows, "Unmounting" basically means "ejecting".
* On top of that, Windows users are usually not even
* familiar with the meaning of "unmount", which comes
* from the UNIX world.
*/
label: `${platform === 'win32' ? 'Eject' : 'Auto-unmount'} on success`,
},
{
name: 'validateWriteOnSuccess',
label: 'Validate write on success',
},
{
name: 'updatesEnabled',
label: 'Auto-updates enabled',
hide: ['rpm', 'deb'].includes(packageType),
},
];
if (['appimage', 'nsis', 'dmg'].includes(packageType)) {
list.push({
name: 'updatesEnabled',
label: i18next.t('settings.autoUpdate'),
});
}
return list;
}
interface SettingsModalProps {
toggleModal: (value: boolean) => void;
}
const EPInfo = etcherProInfo();
const InfoBox = (props: any) => (
<Box fontSize={14}>
<Txt>{props.label}</Txt>
<TextWithCopy code text={props.value} copy={props.value} />
</Box>
);
export function SettingsModal({ toggleModal }: SettingsModalProps) {
const [settingsList, setCurrentSettingsList] = React.useState<Setting[]>([]);
React.useEffect(() => {
@@ -85,49 +90,50 @@ export function SettingsModal({ toggleModal }: SettingsModalProps) {
})();
});
const toggleSetting = async (setting: string) => {
const toggleSetting = async (
setting: string,
options?: Setting['options'],
) => {
const value = currentSettings[setting];
analytics.logEvent('Toggle setting', { setting, value });
const dangerous = options !== undefined;
analytics.logEvent('Toggle setting', {
setting,
value,
dangerous,
});
await settings.set(setting, !value);
setCurrentSettings({
...currentSettings,
[setting]: !value,
});
return;
};
return (
<Modal
titleElement={
<Txt fontSize={24} mb={24}>
{i18next.t('settings.settings')}
Settings
</Txt>
}
done={() => toggleModal(false)}
>
<Flex flexDirection="column">
{settingsList.map((setting: Setting, i: number) => {
return (
return setting.hide ? null : (
<Flex key={setting.name} mb={14}>
<Checkbox
toggle
tabIndex={6 + i}
label={setting.label}
checked={currentSettings[setting.name]}
onChange={() => toggleSetting(setting.name)}
onChange={() => toggleSetting(setting.name, setting.options)}
/>
</Flex>
);
})}
{EPInfo !== undefined && (
<Flex flexDirection="column">
<Txt fontSize={24}>{i18next.t('settings.systemInformation')}</Txt>
{EPInfo.get_serial() === undefined ? (
<InfoBox label="UUID" value={EPInfo.uuid} />
) : (
<InfoBox label="Serial" value={EPInfo.get_serial()} />
)}
</Flex>
)}
<Flex
mt={18}
alignItems="center"

View File

@@ -18,8 +18,6 @@ import CopySvg from '@fortawesome/fontawesome-free/svgs/solid/copy.svg';
import FileSvg from '@fortawesome/fontawesome-free/svgs/solid/file.svg';
import LinkSvg from '@fortawesome/fontawesome-free/svgs/solid/link.svg';
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg';
import ChevronRightSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-right.svg';
import { sourceDestination } from 'etcher-sdk';
import { ipcRenderer, IpcRendererEvent } from 'electron';
import * as _ from 'lodash';
@@ -27,16 +25,7 @@ import { GPTPartition, MBRPartition } from 'partitioninfo';
import * as path from 'path';
import * as prettyBytes from 'pretty-bytes';
import * as React from 'react';
import {
Flex,
ButtonProps,
Modal as SmallModal,
Txt,
Card as BaseCard,
Input,
Spinner,
Link,
} from 'rendition';
import { Flex, ButtonProps, Modal as SmallModal, Txt } from 'rendition';
import styled from 'styled-components';
import * as errors from '../../../../shared/errors';
@@ -51,66 +40,21 @@ import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drive
import {
ChangeButton,
DetailsText,
Modal,
StepButton,
StepNameButton,
ScrollableFlex,
} from '../../styled-components';
import { colors } from '../../theme';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import URLSelector from '../url-selector/url-selector';
import { SVGIcon } from '../svg-icon/svg-icon';
import ImageSvg from '../../../assets/image.svg';
import SrcSvg from '../../../assets/src.svg';
import { DriveSelector } from '../drive-selector/drive-selector';
import { DrivelistDrive } from '../../../../shared/drive-constraints';
import axios, { AxiosRequestConfig } from 'axios';
import { isJson } from '../../../../shared/utils';
import * as i18next from 'i18next';
const recentUrlImagesKey = 'recentUrlImages';
function normalizeRecentUrlImages(urls: any[]): URL[] {
if (!Array.isArray(urls)) {
urls = [];
}
urls = urls
.map((url) => {
try {
return new URL(url);
} catch (error: any) {
// Invalid URL, skip
}
})
.filter((url) => url !== undefined);
urls = _.uniqBy(urls, (url) => url.href);
return urls.slice(urls.length - 5);
}
function getRecentUrlImages(): URL[] {
let urls = [];
try {
urls = JSON.parse(localStorage.getItem(recentUrlImagesKey) || '[]');
} catch {
// noop
}
return normalizeRecentUrlImages(urls);
}
function setRecentUrlImages(urls: URL[]) {
const normalized = normalizeRecentUrlImages(urls.map((url: URL) => url.href));
localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized));
}
const isURL = (imagePath: string) =>
imagePath.startsWith('https://') || imagePath.startsWith('http://');
const Card = styled(BaseCard)`
hr {
margin: 5px 0;
}
`;
// TODO move these styles to rendition
const ModalText = styled.p`
a {
@@ -123,11 +67,10 @@ const ModalText = styled.p`
`;
function getState() {
const image = selectionState.getImage();
return {
hasImage: selectionState.hasImage(),
imageName: image?.name,
imageSize: image?.size,
imageName: selectionState.getImageName(),
imageSize: selectionState.getImageSize(),
};
}
@@ -135,132 +78,6 @@ function isString(value: any): value is string {
return typeof value === 'string';
}
const URLSelector = ({
done,
cancel,
}: {
done: (imageURL: string, auth?: Authentication) => void;
cancel: () => void;
}) => {
const [imageURL, setImageURL] = React.useState('');
const [recentImages, setRecentImages] = React.useState<URL[]>([]);
const [loading, setLoading] = React.useState(false);
const [showBasicAuth, setShowBasicAuth] = React.useState(false);
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
React.useEffect(() => {
const fetchRecentUrlImages = async () => {
const recentUrlImages: URL[] = await getRecentUrlImages();
setRecentImages(recentUrlImages);
};
fetchRecentUrlImages();
}, []);
return (
<Modal
cancel={cancel}
primaryButtonProps={{
disabled: loading || !imageURL,
}}
action={loading ? <Spinner /> : i18next.t('ok')}
done={async () => {
setLoading(true);
const urlStrings = recentImages.map((url: URL) => url.href);
const normalizedRecentUrls = normalizeRecentUrlImages([
...urlStrings,
imageURL,
]);
setRecentUrlImages(normalizedRecentUrls);
const auth = username ? { username, password } : undefined;
await done(imageURL, auth);
}}
>
<Flex flexDirection="column">
<Flex mb={15} style={{ width: '100%' }} flexDirection="column">
<Txt mb="10px" fontSize="24px">
{i18next.t('source.useSourceURL')}
</Txt>
<Input
value={imageURL}
placeholder={i18next.t('source.enterValidURL')}
type="text"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setImageURL(evt.target.value)
}
/>
<Link
mt={15}
mb={15}
fontSize="14px"
onClick={() => {
if (showBasicAuth) {
setUsername('');
setPassword('');
}
setShowBasicAuth(!showBasicAuth);
}}
>
<Flex alignItems="center">
{showBasicAuth && (
<ChevronDownSvg height="1em" fill="currentColor" />
)}
{!showBasicAuth && (
<ChevronRightSvg height="1em" fill="currentColor" />
)}
<Txt ml={8}>{i18next.t('source.auth')}</Txt>
</Flex>
</Link>
{showBasicAuth && (
<React.Fragment>
<Input
mb={15}
value={username}
placeholder={i18next.t('source.username')}
type="text"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setUsername(evt.target.value)
}
/>
<Input
value={password}
placeholder={i18next.t('source.password')}
type="password"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setPassword(evt.target.value)
}
/>
</React.Fragment>
)}
</Flex>
{recentImages.length > 0 && (
<Flex flexDirection="column" height="78.6%">
<Txt fontSize={18}>Recent</Txt>
<ScrollableFlex flexDirection="column">
<Card
p="10px 15px"
rows={recentImages
.map((recent) => (
<Txt
key={recent.href}
onClick={() => {
setImageURL(recent.href);
}}
style={{
overflowWrap: 'break-word',
}}
>
{recent.pathname.split('/').pop()} - {recent.href}
</Txt>
))
.reverse()}
/>
</ScrollableFlex>
</Flex>
)}
</Flex>
</Modal>
);
};
interface Flow {
icon?: JSX.Element;
onClick: (evt: React.MouseEvent) => void;
@@ -296,7 +113,7 @@ const FlowSelector = styled(
font-weight: 600;
svg {
color: ${colors.primary.foreground} !important;
color: ${colors.primary.foreground}!important;
}
}
`;
@@ -316,7 +133,6 @@ export interface SourceMetadata extends sourceDestination.Metadata {
drive?: DrivelistDrive;
extension?: string;
archiveExtension?: string;
auth?: Authentication;
}
interface SourceSelectorProps {
@@ -332,13 +148,6 @@ interface SourceSelectorState {
showURLSelector: boolean;
showDriveSelector: boolean;
defaultFlowActive: boolean;
imageSelectorOpen: boolean;
imageLoading: boolean;
}
interface Authentication {
username: string;
password: string;
}
export class SourceSelector extends React.Component<
@@ -356,8 +165,6 @@ export class SourceSelector extends React.Component<
showURLSelector: false,
showDriveSelector: false,
defaultFlowActive: true,
imageSelectorOpen: false,
imageLoading: false,
};
// Bind `this` since it's used in an event's callback
@@ -378,52 +185,25 @@ export class SourceSelector extends React.Component<
}
private async onSelectImage(_event: IpcRendererEvent, imagePath: string) {
this.setState({ imageLoading: true });
await this.selectSource(
imagePath,
isURL(this.normalizeImagePath(imagePath))
? sourceDestination.Http
: sourceDestination.File,
isURL(imagePath) ? sourceDestination.Http : sourceDestination.File,
).promise;
this.setState({ imageLoading: false });
}
private async createSource(
selected: string,
SourceType: Source,
auth?: Authentication,
) {
private async createSource(selected: string, SourceType: Source) {
try {
selected = await replaceWindowsNetworkDriveLetter(selected);
} catch (error: any) {
} catch (error) {
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) {
const decodedPath = decodeURIComponent(imgPath);
if (isJson(decodedPath)) {
return JSON.parse(decodedPath).url ?? decodedPath;
}
return decodedPath;
return new sourceDestination.Http({ url: selected });
}
private reselectSource() {
@@ -437,7 +217,6 @@ export class SourceSelector extends React.Component<
private selectSource(
selected: string | DrivelistDrive,
SourceType: Source,
auth?: Authentication,
): { promise: Promise<void>; cancel: () => void } {
let cancelled = false;
return {
@@ -449,12 +228,9 @@ export class SourceSelector extends React.Component<
let source;
let metadata: SourceMetadata | undefined;
if (isString(selected)) {
if (
SourceType === sourceDestination.Http &&
!isURL(this.normalizeImagePath(selected))
) {
if (SourceType === sourceDestination.Http && !isURL(selected)) {
this.handleError(
i18next.t('source.unsupportedProtocol'),
'Unsupported protocol',
selected,
messages.error.unsupportedProtocol(),
);
@@ -466,11 +242,11 @@ export class SourceSelector extends React.Component<
this.setState({
warning: {
message: messages.warning.looksLikeWindowsImage(),
title: i18next.t('source.windowsImage'),
title: 'Possible Windows image detected',
},
});
}
source = await this.createSource(selected, SourceType, auth);
source = await this.createSource(selected, SourceType);
if (cancelled) {
return;
@@ -487,18 +263,18 @@ export class SourceSelector extends React.Component<
}
metadata.SourceType = SourceType;
if (!metadata.hasMBR && this.state.warning === null) {
if (!metadata.hasMBR) {
analytics.logEvent('Missing partition table', { metadata });
this.setState({
warning: {
message: messages.warning.missingPartitionTable(),
title: i18next.t('source.partitionTable'),
title: 'Missing partition table',
},
});
}
} catch (error: any) {
} catch (error) {
this.handleError(
i18next.t('source.errorOpen'),
'Error opening source',
sourcePath,
messages.error.openSource(sourcePath, error.message),
error,
@@ -506,20 +282,11 @@ export class SourceSelector extends React.Component<
} finally {
try {
await source.close();
} catch (error: any) {
} catch (error) {
// Noop
}
}
} else {
if (selected.partitionTableType === null) {
analytics.logEvent('Missing partition table', { selected });
this.setState({
warning: {
message: messages.warning.driveMissingPartitionTable(),
title: i18next.t('source.partitionTable'),
},
});
}
metadata = {
path: selected.device,
displayName: selected.displayName,
@@ -531,7 +298,6 @@ export class SourceSelector extends React.Component<
}
if (metadata !== undefined) {
metadata.auth = auth;
selectionState.selectSource(metadata);
analytics.logEvent('Select image', {
// An easy way so we can quickly identify if we're making use of
@@ -586,7 +352,6 @@ export class SourceSelector extends React.Component<
private async openImageSelector() {
analytics.logEvent('Open image selector');
this.setState({ imageSelectorOpen: true });
try {
const imagePath = await osDialog.selectImage();
@@ -597,10 +362,8 @@ export class SourceSelector extends React.Component<
return;
}
await this.selectSource(imagePath, sourceDestination.File).promise;
} catch (error: any) {
} catch (error) {
exceptionReporter.report(error);
} finally {
this.setState({ imageSelectorOpen: false });
}
}
@@ -639,7 +402,7 @@ export class SourceSelector extends React.Component<
private showSelectedImageDetails() {
analytics.logEvent('Show selected image tooltip', {
imagePath: selectionState.getImage()?.path,
imagePath: selectionState.getImagePath(),
});
this.setState({
@@ -651,21 +414,10 @@ export class SourceSelector extends React.Component<
this.setState({ defaultFlowActive });
}
private closeModal() {
this.setState({
showDriveSelector: false,
});
}
// TODO add a visual change when dragging a file over the selector
public render() {
const { flashing } = this.props;
const {
showImageDetails,
showURLSelector,
showDriveSelector,
imageLoading,
} = this.state;
const { showImageDetails, showURLSelector, showDriveSelector } = this.state;
const selectionImage = selectionState.getImage();
let image: SourceMetadata | DrivelistDrive =
selectionImage !== undefined ? selectionImage : ({} as SourceMetadata);
@@ -703,39 +455,36 @@ export class SourceSelector extends React.Component<
}}
/>
{selectionImage !== undefined || imageLoading ? (
{selectionImage !== undefined ? (
<>
<StepNameButton
plain
onClick={() => this.showSelectedImageDetails()}
tooltip={imageName || imageBasename}
>
<Spinner show={imageLoading}>
{middleEllipsis(imageName || imageBasename, 20)}
</Spinner>
{middleEllipsis(imageName || imageBasename, 20)}
</StepNameButton>
{!flashing && !imageLoading && (
{!flashing && (
<ChangeButton
plain
mb={14}
onClick={() => this.reselectSource()}
>
{i18next.t('cancel')}
Remove
</ChangeButton>
)}
{!_.isNil(imageSize) && !imageLoading && (
{!_.isNil(imageSize) && (
<DetailsText>{prettyBytes(imageSize)}</DetailsText>
)}
</>
) : (
<>
<FlowSelector
disabled={this.state.imageSelectorOpen}
primary={this.state.defaultFlowActive}
key="Flash from file"
flow={{
onClick: () => this.openImageSelector(),
label: i18next.t('source.fromFile'),
label: 'Flash from file',
icon: <FileSvg height="1em" fill="currentColor" />,
}}
onMouseEnter={() => this.setDefaultFlowActive(false)}
@@ -745,7 +494,7 @@ export class SourceSelector extends React.Component<
key="Flash from URL"
flow={{
onClick: () => this.openURLSelector(),
label: i18next.t('source.fromURL'),
label: 'Flash from URL',
icon: <LinkSvg height="1em" fill="currentColor" />,
}}
onMouseEnter={() => this.setDefaultFlowActive(false)}
@@ -755,7 +504,7 @@ export class SourceSelector extends React.Component<
key="Clone drive"
flow={{
onClick: () => this.openDriveSelector(),
label: i18next.t('source.clone'),
label: 'Clone drive',
icon: <CopySvg height="1em" fill="currentColor" />,
}}
onMouseEnter={() => this.setDefaultFlowActive(false)}
@@ -767,16 +516,13 @@ export class SourceSelector extends React.Component<
{this.state.warning != null && (
<SmallModal
style={{
boxShadow: '0 3px 7px rgba(0, 0, 0, 0.3)',
}}
titleElement={
<span>
<ExclamationTriangleSvg fill="#fca321" height="1em" />{' '}
<span>{this.state.warning.title}</span>
</span>
}
action={i18next.t('continue')}
action="Continue"
cancel={() => {
this.setState({ warning: null });
this.reselectSource();
@@ -794,17 +540,17 @@ export class SourceSelector extends React.Component<
{showImageDetails && (
<SmallModal
title={i18next.t('source.image')}
title="Image"
done={() => {
this.setState({ showImageDetails: false });
}}
>
<Txt.p>
<Txt.span bold>{i18next.t('source.name')}</Txt.span>
<Txt.span bold>Name: </Txt.span>
<Txt.span>{imageName || imageBasename}</Txt.span>
</Txt.p>
<Txt.p>
<Txt.span bold>{i18next.t('source.path')}</Txt.span>
<Txt.span bold>Path: </Txt.span>
<Txt.span>{imagePath}</Txt.span>
</Txt.p>
</SmallModal>
@@ -818,7 +564,7 @@ export class SourceSelector extends React.Component<
showURLSelector: false,
});
}}
done={async (imageURL: string, auth?: Authentication) => {
done={async (imageURL: string) => {
// Avoid analytics and selection state changes
// if no file was resolved from the dialog.
if (!imageURL) {
@@ -828,7 +574,6 @@ export class SourceSelector extends React.Component<
({ promise, cancel: cancelURLSelection } = this.selectSource(
imageURL,
sourceDestination.Http,
auth,
));
await promise;
}
@@ -841,35 +586,24 @@ export class SourceSelector extends React.Component<
{showDriveSelector && (
<DriveSelector
write={false}
multipleSelection={false}
titleLabel={i18next.t('source.selectSource')}
emptyListLabel={i18next.t('source.plugSource')}
emptyListIcon={<SrcSvg width="40px" />}
cancel={(originalList) => {
if (originalList.length) {
const originalSource = originalList[0];
if (selectionImage?.drive?.device !== originalSource.device) {
this.selectSource(
originalSource,
sourceDestination.BlockDevice,
);
}
} else {
selectionState.deselectImage();
}
this.closeModal();
titleLabel="Select source"
emptyListLabel="Plug a source"
cancel={() => {
this.setState({
showDriveSelector: false,
});
}}
done={() => this.closeModal()}
onSelect={(drive) => {
if (drive) {
if (
selectionState.getImage()?.drive?.device === drive?.device
) {
return selectionState.deselectImage();
}
this.selectSource(drive, sourceDestination.BlockDevice);
done={async (drives: DrivelistDrive[]) => {
if (drives.length) {
await this.selectSource(
drives[0],
sourceDestination.BlockDevice,
);
}
this.setState({
showDriveSelector: false,
});
}}
/>
)}

View File

@@ -24,7 +24,7 @@ import {
} from '../../../../shared/drive-constraints';
import { compatibility, warning } from '../../../../shared/messages';
import * as prettyBytes from 'pretty-bytes';
import { getImage, getSelectedDrives } from '../../models/selection-state';
import { getSelectedDrives } from '../../models/selection-state';
import {
ChangeButton,
DetailsText,
@@ -32,7 +32,6 @@ import {
StepNameButton,
} from '../../styled-components';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import * as i18next from 'i18next';
interface TargetSelectorProps {
targets: any[];
@@ -81,11 +80,9 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
if (targets.length === 1) {
const target = targets[0];
const warnings = getDriveImageCompatibilityStatuses(
target,
getImage(),
true,
).map(getDriveWarning);
const warnings = getDriveImageCompatibilityStatuses(target).map(
getDriveWarning,
);
return (
<>
<StepNameButton plain tooltip={props.tooltip}>
@@ -96,7 +93,7 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
</StepNameButton>
{!props.flashing && (
<ChangeButton plain mb={14} onClick={props.reselectDrive}>
{i18next.t('target.change')}
Change
</ChangeButton>
)}
{target.size != null && (
@@ -109,11 +106,9 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
if (targets.length > 1) {
const targetsTemplate = [];
for (const target of targets) {
const warnings = getDriveImageCompatibilityStatuses(
target,
getImage(),
true,
).map(getDriveWarning);
const warnings = getDriveImageCompatibilityStatuses(target).map(
getDriveWarning,
);
targetsTemplate.push(
<DetailsText
key={target.device}
@@ -133,11 +128,11 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
return (
<>
<StepNameButton plain tooltip={props.tooltip}>
{targets.length} {i18next.t('target.targets')}
{targets.length} Targets
</StepNameButton>
{!props.flashing && (
<ChangeButton plain onClick={props.reselectDrive} mb={14}>
{i18next.t('target.change')}
Change
</ChangeButton>
)}
{targetsTemplate}
@@ -152,7 +147,7 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
disabled={props.disabled}
onClick={props.openDriveSelector}
>
{i18next.t('target.selectTarget')}
Select target
</StepButton>
);
}

View File

@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { scanner } from 'etcher-sdk';
import * as React from 'react';
import { Flex, Txt } from 'rendition';
@@ -27,17 +28,14 @@ import {
getSelectedDrives,
deselectDrive,
selectDrive,
deselectAllDrives,
} from '../../models/selection-state';
import * as settings from '../../models/settings';
import { observe } from '../../models/store';
import * as analytics from '../../modules/analytics';
import { TargetSelectorButton } from './target-selector-button';
import TgtSvg from '../../../assets/tgt.svg';
import DriveSvg from '../../../assets/drive.svg';
import { warning } from '../../../../shared/messages';
import { DrivelistDrive } from '../../../../shared/drive-constraints';
import * as i18next from 'i18next';
export const getDriveListLabel = () => {
return getSelectedDrives()
@@ -47,7 +45,12 @@ export const getDriveListLabel = () => {
.join('\n');
};
const shouldShowDrivesButton = () => {
return !settings.getSync('disableExplicitDriveSelection');
};
const getDriveSelectionStateSlice = () => ({
showDrivesButton: shouldShowDrivesButton(),
driveListLabel: getDriveListLabel(),
targets: getSelectedDrives(),
image: getImage(),
@@ -56,14 +59,13 @@ const getDriveSelectionStateSlice = () => ({
export const TargetSelectorModal = (
props: Omit<
DriveSelectorProps,
'titleLabel' | 'emptyListLabel' | 'multipleSelection' | 'emptyListIcon'
'titleLabel' | 'emptyListLabel' | 'multipleSelection'
>,
) => (
<DriveSelector
multipleSelection={true}
titleLabel={i18next.t('target.selectTarget')}
emptyListLabel={i18next.t('target.plugTarget')}
emptyListIcon={<TgtSvg width="40px" />}
titleLabel="Select target"
emptyListLabel="Plug a target drive"
showWarnings={true}
selectedList={getSelectedDrives()}
updateSelectedList={getSelectedDrives}
@@ -71,7 +73,9 @@ export const TargetSelectorModal = (
/>
);
export const selectAllTargets = (modalTargets: DrivelistDrive[]) => {
export const selectAllTargets = (
modalTargets: scanner.adapters.DrivelistDrive[],
) => {
const selectedDrivesFromState = getSelectedDrives();
const deselected = selectedDrivesFromState.filter(
(drive) =>
@@ -110,11 +114,13 @@ export const TargetSelector = ({
flashing,
}: TargetSelectorProps) => {
// TODO: inject these from redux-connector
const [{ driveListLabel, targets }, setStateSlice] = React.useState(
getDriveSelectionStateSlice(),
const [
{ showDrivesButton, driveListLabel, targets },
setStateSlice,
] = React.useState(getDriveSelectionStateSlice());
const [showTargetSelectorModal, setShowTargetSelectorModal] = React.useState(
false,
);
const [showTargetSelectorModal, setShowTargetSelectorModal] =
React.useState(false);
React.useEffect(() => {
return observe(() => {
@@ -135,7 +141,7 @@ export const TargetSelector = ({
<TargetSelectorButton
disabled={disabled}
show={!hasDrive}
show={!hasDrive && showDrivesButton}
tooltip={driveListLabel}
openDriveSelector={() => {
setShowTargetSelectorModal(true);
@@ -162,31 +168,11 @@ export const TargetSelector = ({
{showTargetSelectorModal && (
<TargetSelectorModal
write={true}
cancel={(originalList) => {
if (originalList.length) {
selectAllTargets(originalList);
} else {
deselectAllDrives();
}
setShowTargetSelectorModal(false);
}}
cancel={() => setShowTargetSelectorModal(false)}
done={(modalTargets) => {
if (modalTargets.length === 0) {
deselectAllDrives();
}
selectAllTargets(modalTargets);
setShowTargetSelectorModal(false);
}}
onSelect={(drive) => {
if (
getSelectedDrives().find(
(selectedDrive) => selectedDrive.device === drive.device,
)
) {
return deselectDrive(drive.device);
}
selectDrive(drive.device);
}}
/>
)}
</Flex>

View File

@@ -0,0 +1,167 @@
import { uniqBy } from 'lodash';
import * as React from 'react';
import Checkbox from 'rendition/dist_esm5/components/Checkbox';
import { Flex } from 'rendition/dist_esm5/components/Flex';
import Input from 'rendition/dist_esm5/components/Input';
import Link from 'rendition/dist_esm5/components/Link';
import RadioButton from 'rendition/dist_esm5/components/RadioButton';
import Txt from 'rendition/dist_esm5/components/Txt';
import * as settings from '../../models/settings';
import { Modal, ScrollableFlex } from '../../styled-components';
import { openDialog } from '../../os/dialog';
import { startEllipsis } from '../../utils/start-ellipsis';
const RECENT_URL_IMAGES_KEY = 'recentUrlImages';
const SAVE_IMAGE_AFTER_FLASH_KEY = 'saveUrlImage';
const SAVE_IMAGE_AFTER_FLASH_PATH_KEY = 'saveUrlImageTo';
function normalizeRecentUrlImages(urls: any[]): URL[] {
if (!Array.isArray(urls)) {
urls = [];
}
urls = urls
.map((url) => {
try {
return new URL(url);
} catch (error) {
// Invalid URL, skip
}
})
.filter((url) => url !== undefined);
urls = uniqBy(urls, (url) => url.href);
return urls.slice(-5);
}
function getRecentUrlImages(): URL[] {
let urls = [];
try {
urls = JSON.parse(localStorage.getItem(RECENT_URL_IMAGES_KEY) || '[]');
} catch {
// noop
}
return normalizeRecentUrlImages(urls);
}
function setRecentUrlImages(urls: string[]) {
localStorage.setItem(RECENT_URL_IMAGES_KEY, JSON.stringify(urls));
}
export const URLSelector = ({
done,
cancel,
}: {
done: (imageURL: string) => void;
cancel: () => void;
}) => {
const [imageURL, setImageURL] = React.useState('');
const [recentImages, setRecentImages] = React.useState<URL[]>([]);
const [loading, setLoading] = React.useState(false);
const [saveImage, setSaveImage] = React.useState(false);
const [saveImagePath, setSaveImagePath] = React.useState('');
React.useEffect(() => {
const fetchRecentUrlImages = async () => {
const recentUrlImages: URL[] = await getRecentUrlImages();
setRecentImages(recentUrlImages);
};
const getSaveImageSettings = async () => {
const saveUrlImage: boolean = await settings.get(
SAVE_IMAGE_AFTER_FLASH_KEY,
);
const saveUrlImageToPath: string = await settings.get(
SAVE_IMAGE_AFTER_FLASH_PATH_KEY,
);
setSaveImage(saveUrlImage);
setSaveImagePath(saveUrlImageToPath);
};
fetchRecentUrlImages();
getSaveImageSettings();
}, []);
return (
<Modal
title="Use Image URL"
cancel={cancel}
primaryButtonProps={{
className: loading || !imageURL ? 'disabled' : '',
}}
done={async () => {
setLoading(true);
const urlStrings = recentImages
.map((url: URL) => url.href)
.concat(imageURL);
setRecentUrlImages(urlStrings);
await done(imageURL);
}}
>
<Flex flexDirection="column">
<Flex mb="16px" width="100%" height="auto" flexDirection="column">
<Input
value={imageURL}
placeholder="Enter a valid URL"
type="text"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setImageURL(evt.target.value)
}
/>
<Flex alignItems="flex-end">
<Checkbox
mt="16px"
checked={saveImage}
onChange={(evt) => {
const value = evt.target.checked;
setSaveImage(value);
settings
.set(SAVE_IMAGE_AFTER_FLASH_KEY, value)
.then(() => setSaveImage(value));
}}
label={<>Save file to:&nbsp;</>}
/>
<Link
disabled={!saveImage}
onClick={async () => {
if (saveImage) {
const folder = await openDialog('openDirectory');
if (folder) {
await settings.set(SAVE_IMAGE_AFTER_FLASH_PATH_KEY, folder);
setSaveImagePath(folder);
}
}
}}
>
{startEllipsis(saveImagePath, 20)}
</Link>
</Flex>
</Flex>
{recentImages.length > 0 && (
<Flex flexDirection="column" height="58%">
<Txt fontSize={18} mb="10px">
Recent
</Txt>
<ScrollableFlex flexDirection="column" p="0">
{recentImages
.map((recent, i) => (
<RadioButton
mb={i !== 0 ? '6px' : '0'}
key={recent.href}
checked={imageURL === recent.href}
label={`${recent.pathname.split('/').pop()} - ${
recent.href
}`}
onChange={() => {
setImageURL(recent.href);
}}
style={{
overflowWrap: 'break-word',
}}
/>
))
.reverse()}
</ScrollableFlex>
</Flex>
)}
</Flex>
</Modal>
);
};
export default URLSelector;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8">
<title>balenaEtcher</title>
<title>Etcher</title>
<link rel="stylesheet" type="text/css" href="index.css">
</head>
<body>

View File

@@ -14,13 +14,11 @@
* limitations under the License.
*/
import * as electron from 'electron';
import * as sdk from 'etcher-sdk';
import * as _ from 'lodash';
import { DrivelistDrive } from '../../../shared/drive-constraints';
import { bytesToMegabytes } from '../../../shared/units';
import { Actions, store } from './store';
import { etcherProInfo } from '../utils/etcher-pro-specific';
/**
* @summary Reset flash state
@@ -47,17 +45,6 @@ export function isFlashing(): boolean {
* start a flash process.
*/
export function setFlashingFlag() {
// see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods
if (etcherProInfo()?.uuid) {
try {
console.log(etcherProInfo()?.uuid);
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({
type: Actions.SET_FLASHING_FLAG,
data: {},
@@ -79,8 +66,6 @@ export function unsetFlashingFlag(results: {
type: Actions.UNSET_FLASHING_FLAG,
data: results,
});
// see https://github.com/balenablocks/balena-electron-env/blob/4fce9c461f294d4a768db8f247eea6f75d7b08b0/README.md#remote-methods
electron.ipcRenderer.invoke('enable-screensaver');
}
export function setDevicePaths(devicePaths: string[]) {
@@ -90,29 +75,25 @@ export function setDevicePaths(devicePaths: string[]) {
});
}
export function addFailedDeviceError({
export function addFailedDevicePath({
device,
error,
}: {
device: DrivelistDrive;
device: sdk.scanner.adapters.DrivelistDrive;
error: Error;
}) {
const failedDeviceErrorsMap = new Map(
store.getState().toJS().failedDeviceErrors,
const failedDevicePathsMap = new Map(
store.getState().toJS().failedDevicePaths,
);
if (failedDeviceErrorsMap.has(device.device)) {
// Only store the first error
return;
}
failedDeviceErrorsMap.set(device.device, {
failedDevicePathsMap.set(device.device, {
description: device.description,
device: device.device,
devicePath: device.devicePath,
...error,
});
store.dispatch({
type: Actions.SET_FAILED_DEVICE_ERRORS,
data: Array.from(failedDeviceErrorsMap),
type: Actions.SET_FAILED_DEVICE_PATHS,
data: Array.from(failedDevicePathsMap),
});
}

View File

@@ -15,19 +15,40 @@
*/
import * as _ from 'lodash';
import { Animator, AnimationFunction, Color, RGBLed } from 'sys-class-rgb-led';
import { AnimationFunction, Color, RGBLed } from 'sys-class-rgb-led';
import {
DrivelistDrive,
isSourceDrive,
DrivelistDrive,
} from '../../../shared/drive-constraints';
import { getDrives } from './available-drives';
import { getSelectedDrives } from './selection-state';
import * as settings from './settings';
import { observe, store } from './store';
import { DEFAULT_STATE, observe } from './store';
const leds: Map<string, RGBLed> = new Map();
const animator = new Animator([], 10);
function setLeds(
drivesPaths: Set<string>,
colorOrAnimation: Color | AnimationFunction,
frequency?: number,
) {
for (const path of drivesPaths) {
const led = leds.get(path);
if (led) {
if (Array.isArray(colorOrAnimation)) {
led.setStaticColor(colorOrAnimation);
} else {
led.setAnimation(colorOrAnimation, frequency);
}
}
}
}
const red: Color = [1, 0, 0];
const green: Color = [0, 1, 0];
const blue: Color = [0, 0, 1];
const white: Color = [1, 1, 1];
const black: Color = [0, 0, 0];
const purple: Color = [0.5, 0, 0.5];
function createAnimationFunction(
intensityFunction: (t: number) => number,
@@ -35,39 +56,21 @@ function createAnimationFunction(
): AnimationFunction {
return (t: number): Color => {
const intensity = intensityFunction(t);
return color.map((v: number) => v * intensity) as Color;
return color.map((v) => v * intensity) as Color;
};
}
function blink(t: number) {
return Math.floor(t) % 2;
return Math.floor(t / 1000) % 2;
}
function one(_t: number) {
return 1;
function breathe(t: number) {
return (1 + Math.sin(t / 1000)) / 2;
}
type LEDColors = {
green: Color;
purple: Color;
red: Color;
blue: Color;
white: Color;
black: Color;
};
type LEDAnimationFunctions = {
blinkGreen: AnimationFunction;
blinkPurple: AnimationFunction;
staticRed: AnimationFunction;
staticGreen: AnimationFunction;
staticBlue: AnimationFunction;
staticWhite: AnimationFunction;
staticBlack: AnimationFunction;
};
let ledColors: LEDColors;
let ledAnimationFunctions: LEDAnimationFunctions;
const breatheBlue = createAnimationFunction(breathe, blue);
const blinkGreen = createAnimationFunction(blink, green);
const blinkPurple = createAnimationFunction(blink, purple);
interface LedsState {
step: 'main' | 'flashing' | 'verifying' | 'finish';
@@ -77,17 +80,6 @@ interface LedsState {
failedDrives: string[];
}
function setLeds(animation: AnimationFunction, drivesPaths: Set<string>) {
const rgbLeds: RGBLed[] = [];
for (const path of drivesPaths) {
const led = leds.get(path);
if (led) {
rgbLeds.push(led);
}
}
return { animation, rgbLeds };
}
// Source slot (1st slot): behaves as a target unless it is chosen as source
// No drive: black
// Drive plugged: blue - on
@@ -118,7 +110,6 @@ export function updateLeds({
// Remove selected devices from plugged set
for (const d of selectedOk) {
plugged.delete(d);
unplugged.delete(d);
}
// Remove plugged devices from unplugged set
@@ -131,90 +122,74 @@ export function updateLeds({
selectedOk.delete(d);
}
const mapping: Array<{
animation: AnimationFunction;
rgbLeds: RGBLed[];
}> = [];
// Handle source slot
if (sourceDrive !== undefined) {
if (plugged.has(sourceDrive)) {
if (unplugged.has(sourceDrive)) {
unplugged.delete(sourceDrive);
// TODO
setLeds(new Set([sourceDrive]), breatheBlue, 2);
} else if (plugged.has(sourceDrive)) {
plugged.delete(sourceDrive);
mapping.push(
setLeds(ledAnimationFunctions.staticBlue, new Set([sourceDrive])),
);
setLeds(new Set([sourceDrive]), blue);
}
}
if (step === 'main') {
mapping.push(
setLeds(
ledAnimationFunctions.staticBlack,
new Set([...unplugged, ...plugged]),
),
setLeds(
ledAnimationFunctions.staticWhite,
new Set([...selectedOk, ...selectedFailed]),
),
);
setLeds(unplugged, black);
setLeds(plugged, black);
setLeds(selectedOk, white);
setLeds(selectedFailed, white);
} else if (step === 'flashing') {
mapping.push(
setLeds(
ledAnimationFunctions.staticBlack,
new Set([...unplugged, ...plugged]),
),
setLeds(ledAnimationFunctions.blinkPurple, selectedOk),
setLeds(ledAnimationFunctions.staticRed, selectedFailed),
);
setLeds(unplugged, black);
setLeds(plugged, black);
setLeds(selectedOk, blinkPurple, 2);
setLeds(selectedFailed, red);
} else if (step === 'verifying') {
mapping.push(
setLeds(
ledAnimationFunctions.staticBlack,
new Set([...unplugged, ...plugged]),
),
setLeds(ledAnimationFunctions.blinkGreen, selectedOk),
setLeds(ledAnimationFunctions.staticRed, selectedFailed),
);
setLeds(unplugged, black);
setLeds(plugged, black);
setLeds(selectedOk, blinkGreen, 2);
setLeds(selectedFailed, red);
} else if (step === 'finish') {
mapping.push(
setLeds(
ledAnimationFunctions.staticBlack,
new Set([...unplugged, ...plugged]),
),
setLeds(ledAnimationFunctions.staticGreen, selectedOk),
setLeds(ledAnimationFunctions.staticRed, selectedFailed),
);
setLeds(unplugged, black);
setLeds(plugged, black);
setLeds(selectedOk, green);
setLeds(selectedFailed, red);
}
animator.mapping = mapping;
}
interface DeviceFromState {
devicePath?: string;
device: string;
}
let ledsState: LedsState | undefined;
function stateObserver() {
const s = store.getState().toJS();
function stateObserver(state: typeof DEFAULT_STATE) {
const s = state.toJS();
let step: 'main' | 'flashing' | 'verifying' | 'finish';
if (s.isFlashing) {
step = s.flashState.type;
} else {
step = s.lastAverageFlashingSpeed == null ? 'main' : 'finish';
}
const availableDrives = getDrives().filter(
(d: DrivelistDrive) => d.devicePath,
const availableDrives = s.availableDrives.filter(
(d: DeviceFromState) => d.devicePath,
);
const sourceDrivePath = availableDrives.filter((d: DrivelistDrive) =>
isSourceDrive(d, s.selection.image),
)[0]?.devicePath;
const availableDrivesPaths = availableDrives.map(
(d: DrivelistDrive) => d.devicePath,
(d: DeviceFromState) => d.devicePath,
);
let selectedDrivesPaths: string[];
if (step === 'main') {
selectedDrivesPaths = getSelectedDrives()
.filter((drive) => drive.devicePath !== null)
.map((drive) => drive.devicePath) as string[];
selectedDrivesPaths = availableDrives
.filter((d: DrivelistDrive) => s.selection.devices.includes(d.device))
.map((d: DrivelistDrive) => d.devicePath);
} else {
selectedDrivesPaths = s.devicePaths;
}
const failedDevicePaths = s.failedDeviceErrors.map(
([, { devicePath }]: [string, { devicePath: string }]) => devicePath,
const failedDevicePaths = s.failedDevicePaths.map(
([devicePath]: [string]) => devicePath,
);
const newLedsState = {
step,
@@ -222,7 +197,7 @@ function stateObserver() {
availableDrives: availableDrivesPaths,
selectedDrives: selectedDrivesPaths,
failedDrives: failedDevicePaths,
} as LedsState;
};
if (!_.isEqual(newLedsState, ledsState)) {
updateLeds(newLedsState);
ledsState = newLedsState;
@@ -245,16 +220,6 @@ export async function init(): Promise<void> {
for (const [drivePath, ledsNames] of Object.entries(ledsMapping)) {
leds.set('/dev/disk/by-path/' + drivePath, new RGBLed(ledsNames));
}
ledColors = (await settings.get('ledColors')) || {};
ledAnimationFunctions = {
blinkGreen: createAnimationFunction(blink, ledColors['green']),
blinkPurple: createAnimationFunction(blink, ledColors['purple']),
staticRed: createAnimationFunction(one, ledColors['red']),
staticGreen: createAnimationFunction(one, ledColors['green']),
staticBlue: createAnimationFunction(one, ledColors['blue']),
staticWhite: createAnimationFunction(one, ledColors['white']),
staticBlack: createAnimationFunction(one, ledColors['black']),
};
observe(_.debounce(stateObserver, 1000, { maxWait: 1000 }));
}
}

View File

@@ -72,6 +72,26 @@ export function getImage(): SourceMetadata | undefined {
return store.getState().toJS().selection.image;
}
export function getImagePath(): string | undefined {
return store.getState().toJS().selection.image?.path;
}
export function getImageSize(): number | undefined {
return store.getState().toJS().selection.image?.size;
}
export function getImageName(): string | undefined {
return store.getState().toJS().selection.image?.name;
}
export function getImageLogo(): string | undefined {
return store.getState().toJS().selection.image?.logo;
}
export function getImageSupportUrl(): string | undefined {
return store.getState().toJS().selection.image?.supportUrl;
}
/**
* @summary Check if there is a selected drive
*/

View File

@@ -38,19 +38,23 @@ export const DEFAULT_HEIGHT = 480;
* - `~/Library/Application Support/etcher` on macOS
* See https://electronjs.org/docs/api/app#appgetpathname
*
* NOTE: We use the remote property when this module
* is loaded in the Electron's renderer process
* NOTE: The ternary is due to this module being loaded both,
* Electron's main process and renderer process
*/
function getConfigPath() {
const app = electron.app || require('@electron/remote').app;
return join(app.getPath('userData'), 'config.json');
}
const app = electron.app || electron.remote.app;
const USER_DATA_DIR = app.getPath('userData');
const CONFIG_PATH = join(USER_DATA_DIR, 'config.json');
const DOWNLOADS_DIR = app.getPath('downloads');
async function readConfigFile(filename: string): Promise<_.Dictionary<any>> {
let contents = '{}';
try {
contents = await fs.readFile(filename, { encoding: 'utf8' });
} catch (error: any) {
} catch (error) {
// noop
}
try {
@@ -63,7 +67,7 @@ async function readConfigFile(filename: string): Promise<_.Dictionary<any>> {
// exported for tests
export async function readAll() {
return await readConfigFile(getConfigPath());
return await readConfigFile(CONFIG_PATH);
}
// exported for tests
@@ -76,10 +80,14 @@ export async function writeConfigFile(
const DEFAULT_SETTINGS: _.Dictionary<any> = {
errorReporting: true,
updatesEnabled: ['appimage', 'nsis', 'dmg'].includes(packageJSON.packageType),
unmountOnSuccess: true,
validateWriteOnSuccess: true,
updatesEnabled: !_.includes(['rpm', 'deb'], packageJSON.packageType),
desktopNotifications: true,
autoBlockmapping: true,
decompressFirst: true,
saveUrlImage: false,
saveUrlImageTo: DOWNLOADS_DIR,
};
const settings = _.cloneDeep(DEFAULT_SETTINGS);
@@ -102,8 +110,8 @@ export async function set(
const previousValue = settings[key];
settings[key] = value;
try {
await writeConfigFileFn(getConfigPath(), settings);
} catch (error: any) {
await writeConfigFileFn(CONFIG_PATH, settings);
} catch (error) {
// Revert to previous value if persisting settings failed
settings[key] = previousValue;
throw error;

View File

@@ -16,7 +16,6 @@
import * as Immutable from 'immutable';
import * as _ from 'lodash';
import { basename } from 'path';
import * as redux from 'redux';
import { v4 as uuidV4 } from 'uuid';
@@ -63,7 +62,7 @@ export const DEFAULT_STATE = Immutable.fromJS({
},
isFlashing: false,
devicePaths: [],
failedDeviceErrors: [],
failedDevicePaths: [],
flashResults: {},
flashState: {
active: 0,
@@ -80,7 +79,7 @@ export const DEFAULT_STATE = Immutable.fromJS({
*/
export enum Actions {
SET_DEVICE_PATHS,
SET_FAILED_DEVICE_ERRORS,
SET_FAILED_DEVICE_PATHS,
SET_AVAILABLE_TARGETS,
SET_FLASH_STATE,
RESET_FLASH_STATE,
@@ -134,16 +133,11 @@ function storeReducer(
});
}
// Drives order is a list of devicePaths
const drivesOrder = settings.getSync('drivesOrder') ?? [];
drives = _.sortBy(drives, [
// System drives last
(d) => !!d.isSystem,
// Devices with no devicePath first (usbboot)
(d) => !!d.devicePath,
// Sort as defined in the drivesOrder setting if there is one (only for Linux with udev)
(d) => drivesOrder.indexOf(basename(d.devicePath || '')),
// Then sort by devicePath (only available on Linux with udev) or device
(d) => d.devicePath || d.device,
]);
@@ -175,7 +169,7 @@ function storeReducer(
);
const shouldAutoselectAll = Boolean(
settings.getSync('autoSelectAllDrives'),
settings.getSync('disableExplicitDriveSelection'),
);
const AUTOSELECT_DRIVE_COUNT = 1;
const nonStaleSelectedDevices = nonStaleNewState
@@ -197,13 +191,18 @@ function storeReducer(
drives,
(accState, drive) => {
if (
constraints.isDriveValid(drive, image) &&
!drive.isReadOnly &&
constraints.isDriveSizeRecommended(drive, image) &&
// We don't want to auto-select large drives execpt is autoSelectAllDrives is true
(!constraints.isDriveSizeLarge(drive) || shouldAutoselectAll) &&
// We don't want to auto-select system drives
!constraints.isSystemDrive(drive)
_.every([
constraints.isDriveValid(drive, image),
constraints.isDriveSizeRecommended(drive, image),
// We don't want to auto-select large drives
!constraints.isDriveSizeLarge(drive),
// We don't want to auto-select system drives,
// even when "unsafe mode" is enabled
!constraints.isSystemDrive(drive),
]) ||
(shouldAutoselectAll && constraints.isDriveValid(drive, image))
) {
// Auto-select this drive
return storeReducer(accState, {
@@ -270,7 +269,7 @@ function storeReducer(
.set('flashState', DEFAULT_STATE.get('flashState'))
.set('flashResults', DEFAULT_STATE.get('flashResults'))
.set('devicePaths', DEFAULT_STATE.get('devicePaths'))
.set('failedDeviceErrors', DEFAULT_STATE.get('failedDeviceErrors'))
.set('failedDevicePaths', DEFAULT_STATE.get('failedDevicePaths'))
.set(
'lastAverageFlashingSpeed',
DEFAULT_STATE.get('lastAverageFlashingSpeed'),
@@ -517,8 +516,8 @@ function storeReducer(
return state.set('devicePaths', action.data);
}
case Actions.SET_FAILED_DEVICE_ERRORS: {
return state.set('failedDeviceErrors', action.data);
case Actions.SET_FAILED_DEVICE_PATHS: {
return state.set('failedDevicePaths', action.data);
}
default: {

View File

@@ -15,188 +15,84 @@
*/
import * as _ from 'lodash';
import { Client, createClient, createNoopClient } from 'analytics-client';
import * as SentryRenderer from '@sentry/electron/renderer';
import * as resinCorvus from 'resin-corvus/browser';
import * as packageJSON from '../../../../package.json';
import { getConfig } from '../../../shared/utils';
import * as settings from '../models/settings';
import { store } from '../models/store';
import * as packageJSON from '../../../../package.json';
type AnalyticsPayload = _.Dictionary<any>;
const DEFAULT_PROBABILITY = 0.1;
const clearUserPath = (filename: string): string => {
const generatedFile = filename.split('generated').reverse()[0];
return generatedFile !== filename ? `generated${generatedFile}` : filename;
};
export const anonymizeSentryData = (
event: SentryRenderer.Event,
): SentryRenderer.Event => {
event.exception?.values?.forEach((exception) => {
exception.stacktrace?.frames?.forEach((frame) => {
if (frame.filename) {
frame.filename = clearUserPath(frame.filename);
}
});
async function installCorvus(): Promise<void> {
const sentryToken =
(await settings.get('analyticsSentryToken')) ||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
const mixpanelToken =
(await settings.get('analyticsMixpanelToken')) ||
_.get(packageJSON, ['analytics', 'mixpanel', 'token']);
resinCorvus.install({
services: {
sentry: sentryToken,
mixpanel: mixpanelToken,
},
options: {
release: packageJSON.version,
shouldReport: () => {
return settings.getSync('errorReporting');
},
mixpanelDeferred: true,
},
});
}
event.breadcrumbs?.forEach((breadcrumb) => {
if (breadcrumb.data?.url) {
breadcrumb.data.url = clearUserPath(breadcrumb.data.url);
}
});
let mixpanelSample = DEFAULT_PROBABILITY;
if (event.request?.url) {
event.request.url = clearUserPath(event.request.url);
}
return event;
};
const extractPathRegex = /(.*)(^|\s)(file\:\/\/)?(\w\:)?([\\\/].+)/;
const etcherSegmentMarkers = ['app.asar', 'Resources'];
export const anonymizePath = (input: string) => {
// First, extract a part of the value that matches a path pattern.
const match = extractPathRegex.exec(input);
if (match === null) {
return input;
}
const mainPart = match[5];
const space = match[2];
const beginning = match[1];
const uriPrefix = match[3] || '';
// We have to deal with both Windows and POSIX here.
// The path starts with its separator (we work with absolute paths).
const sep = mainPart[0];
const segments = mainPart.split(sep);
// Moving from the end, find the first marker and cut the path from there.
const startCutIndex = _.findLastIndex(segments, (segment) =>
etcherSegmentMarkers.includes(segment),
);
return (
beginning +
space +
uriPrefix +
'[PERSONAL PATH]' +
sep +
segments.splice(startCutIndex).join(sep)
);
};
const safeAnonymizePath = (input: string) => {
try {
return anonymizePath(input);
} catch (e) {
return '[ANONYMIZE PATH FAILED]';
}
};
const sensitiveEtcherProperties = [
'error.description',
'error.message',
'error.stack',
'image',
'image.path',
'path',
];
export const anonymizeAnalyticsPayload = (
data: AnalyticsPayload,
): AnalyticsPayload => {
for (const prop of sensitiveEtcherProperties) {
const value = data[prop];
if (value != null) {
data[prop] = safeAnonymizePath(value.toString());
}
}
return data;
};
let analyticsClient: Client;
/**
* @summary Init analytics configurations
*/
export const initAnalytics = _.once(() => {
const dsn =
settings.getSync('analyticsSentryToken') ||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
SentryRenderer.init({ dsn, beforeSend: anonymizeSentryData });
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];
async function initConfig() {
await installCorvus();
let validatedConfig = null;
try {
const configUrl = await settings.get('configUrl');
const config = await getConfig(configUrl);
const mixpanel = _.get(config, ['analytics', 'mixpanel'], {});
mixpanelSample = mixpanel.probability || DEFAULT_PROBABILITY;
if (isClientEligible(mixpanelSample)) {
validatedConfig = validateMixpanelConfig(mixpanel);
}
} catch (err) {
resinCorvus.logException(err);
}
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,
resinCorvus.setConfigs({
mixpanel: validatedConfig,
});
analyticsClient.track(message, event);
}
initConfig();
/**
* @summary Check that the client is eligible for analytics
*/
function isClientEligible(probability: number) {
return Math.random() < probability;
}
/**
* @summary Check that config has at least HTTP_PROTOCOL and api_host
*/
function validateMixpanelConfig(config: {
api_host?: string;
HTTP_PROTOCOL?: string;
}) {
const mixpanelConfig = {
api_host: 'https://api.mixpanel.com',
};
if (config.HTTP_PROTOCOL !== undefined && config.api_host !== undefined) {
mixpanelConfig.api_host = `${config.HTTP_PROTOCOL}://${config.api_host}`;
}
return mixpanelConfig;
}
/**
@@ -205,12 +101,17 @@ function reportAnalytics(message: string, data: AnalyticsPayload = {}) {
* @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);
}
export function logEvent(message: string, data: _.Dictionary<any> = {}) {
const {
applicationSessionUuid,
flashingWorkflowUuid,
} = store.getState().toJS();
resinCorvus.logEvent(message, {
...data,
sample: mixpanelSample,
applicationSessionUuid,
flashingWorkflowUuid,
});
}
/**
@@ -219,11 +120,4 @@ export async function logEvent(message: string, data: AnalyticsPayload = {}) {
* @description
* This function logs an exception to error reporting services.
*/
export function logException(error: any) {
const shouldReportErrors = settings.getSync('errorReporting');
console.error(error);
if (shouldReportErrors) {
initAnalytics();
SentryRenderer.captureException(error);
}
}
export const logException = resinCorvus.logException;

View File

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

View File

@@ -15,8 +15,9 @@
*/
import { Drive as DrivelistDrive } from 'drivelist';
import * as electron from 'electron';
import * as sdk from 'etcher-sdk';
import { Dictionary } from 'lodash';
import * as _ from 'lodash';
import * as ipc from 'node-ipc';
import * as os from 'os';
import * as path from 'path';
@@ -24,7 +25,6 @@ import * as path from 'path';
import * as packageJSON from '../../../../package.json';
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';
@@ -93,7 +93,11 @@ function terminateServer() {
}
function writerArgv(): string[] {
let entryPoint = path.join(getAppPath(), 'generated', 'child-writer.js');
let entryPoint = path.join(
electron.remote.app.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
@@ -129,14 +133,6 @@ function writerEnv() {
interface FlashResults {
skip?: boolean;
cancelled?: boolean;
results?: {
bytesWritten: number;
devices: {
failed: number;
successful: number;
};
errors: Error[];
};
}
async function performWrite(
@@ -147,7 +143,14 @@ async function performWrite(
let cancelled = false;
let skip = false;
ipc.serve();
const { autoBlockmapping, decompressFirst } = await settings.getAll();
const {
unmountOnSuccess,
validateWriteOnSuccess,
autoBlockmapping,
decompressFirst,
saveUrlImage,
saveUrlImageTo,
} = await settings.getAll();
return await new Promise((resolve, reject) => {
ipc.server.on('error', (error) => {
terminateServer();
@@ -166,22 +169,22 @@ async function performWrite(
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess,
validateWriteOnSuccess,
};
ipc.server.on('fail', ({ device, error }) => {
if (device.devicePath) {
flashState.addFailedDeviceError({ device, error });
flashState.addFailedDevicePath({ device, error });
}
handleErrorLogging(error, analyticsData);
});
ipc.server.on('done', (event) => {
event.results.errors = event.results.errors.map(
(data: Dictionary<any> & { message: string }) => {
return errors.fromJSON(data);
},
);
flashResults.results = event.results;
event.results.errors = _.map(event.results.errors, (data) => {
return errors.fromJSON(data);
});
_.merge(flashResults, event);
});
ipc.server.on('abort', () => {
@@ -201,15 +204,19 @@ async function performWrite(
image,
destinations: drives,
SourceType: image.SourceType.name,
validateWriteOnSuccess,
autoBlockmapping,
unmountOnSuccess,
decompressFirst,
saveUrlImage,
saveUrlImageTo,
});
});
const argv = writerArgv();
ipc.server.on('start', async () => {
console.log(`Elevating command: ${argv.join(' ')}`);
console.log(`Elevating command: ${_.join(argv, ' ')}`);
const env = writerEnv();
try {
const results = await permissions.elevateCommand(argv, {
@@ -218,7 +225,7 @@ async function performWrite(
});
flashResults.cancelled = cancelled || results.cancelled;
flashResults.skip = skip;
} catch (error: any) {
} catch (error) {
// This happens when the child is killed using SIGKILL
const SIGKILL_EXIT_CODE = 137;
if (error.code === SIGKILL_EXIT_CODE) {
@@ -231,11 +238,11 @@ async function performWrite(
}
console.log('Flash results', flashResults);
// The flash wasn't cancelled and we didn't get a 'done' event
// This likely means the child died halfway through
if (
!flashResults.cancelled &&
!flashResults.skip &&
flashResults.results === undefined
!_.get(flashResults, ['results', 'bytesWritten'])
) {
reject(
errors.createUserError({
@@ -268,7 +275,7 @@ export async function flash(
throw new Error('There is already a flash in progress');
}
await flashState.setFlashingFlag();
flashState.setFlashingFlag();
flashState.setDevicePaths(
drives.map((d) => d.devicePath).filter((p) => p != null) as string[],
);
@@ -280,18 +287,17 @@ export async function flash(
uuid: flashState.getFlashUuid(),
status: 'started',
flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess: await settings.get('unmountOnSuccess'),
validateWriteOnSuccess: await settings.get('validateWriteOnSuccess'),
};
analytics.logEvent('Flash', analyticsData);
try {
const result = await write(image, drives, flashState.setProgressState);
await flashState.unsetFlashingFlag(result);
} catch (error: any) {
await flashState.unsetFlashingFlag({
cancelled: false,
errorCode: error.code,
});
flashState.unsetFlashingFlag(result);
} catch (error) {
flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code });
windowProgress.clear();
const { results = {} } = flashState.getFlashResults();
const eventData = {
@@ -332,11 +338,13 @@ export async function cancel(type: string) {
const status = type.toLowerCase();
const drives = selectionState.getSelectedDevices();
const analyticsData = {
image: selectionState.getImage()?.path,
image: selectionState.getImagePath(),
drives,
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess: await settings.get('unmountOnSuccess'),
validateWriteOnSuccess: await settings.get('validateWriteOnSuccess'),
status,
};
analytics.logEvent('Cancel', analyticsData);
@@ -349,7 +357,7 @@ export async function cancel(type: string) {
if (socket !== undefined) {
ipc.server.emit(socket, status);
}
} catch (error: any) {
} catch (error) {
analytics.logException(error);
}
}

View File

@@ -15,7 +15,6 @@
*/
import * as prettyBytes from 'pretty-bytes';
import * as i18next from 'i18next';
export interface FlashState {
active: number;
@@ -23,7 +22,7 @@ export interface FlashState {
percentage?: number;
speed: number;
position: number;
type?: 'decompressing' | 'flashing' | 'verifying';
type?: 'decompressing' | 'flashing' | 'verifying' | 'downloading';
}
export function fromFlashState({
@@ -35,45 +34,42 @@ export function fromFlashState({
position?: string;
} {
if (type === undefined) {
return { status: i18next.t('progress.starting') };
return { status: 'Starting...' };
} else if (type === 'decompressing') {
if (percentage == null) {
return { status: i18next.t('progress.decompressing') };
return { status: 'Decompressing...' };
} else {
return {
position: `${percentage}%`,
status: i18next.t('progress.decompressing'),
};
return { position: `${percentage}%`, status: 'Decompressing...' };
}
} else if (type === 'flashing') {
if (percentage != null) {
if (percentage < 100) {
return {
position: `${percentage}%`,
status: i18next.t('progress.flashing'),
};
return { position: `${percentage}%`, status: 'Flashing...' };
} else {
return { status: i18next.t('progress.finishing') };
return { status: 'Finishing...' };
}
} else {
return {
status: i18next.t('progress.flashing'),
status: 'Flashing...',
position: `${position ? prettyBytes(position) : ''}`,
};
}
} else if (type === 'verifying') {
if (percentage == null) {
return { status: i18next.t('progress.verifying') };
return { status: 'Validating...' };
} else if (percentage < 100) {
return {
position: `${percentage}%`,
status: i18next.t('progress.verifying'),
};
return { position: `${percentage}%`, status: 'Validating...' };
} else {
return { status: i18next.t('progress.finishing') };
return { status: 'Finishing...' };
}
} else if (type === 'downloading') {
if (percentage == null) {
return { status: 'Downloading...' };
} else if (percentage < 100) {
return { position: `${percentage}%`, status: 'Downloading...' };
}
}
return { status: i18next.t('progress.failing') };
return { status: 'Failed' };
}
export function titleFromFlashState(

View File

@@ -15,13 +15,11 @@
*/
import * as electron from 'electron';
import * as remote from '@electron/remote';
import * as _ from 'lodash';
import * as errors from '../../../shared/errors';
import * as settings from '../../../gui/app/models/settings';
import { SUPPORTED_EXTENSIONS } from '../../../shared/supported-formats';
import * as i18next from 'i18next';
async function mountSourceDrive() {
// sourceDrivePath is the name of the link in /dev/disk/by-path
@@ -29,7 +27,7 @@ async function mountSourceDrive() {
if (sourceDrivePath) {
try {
await electron.ipcRenderer.invoke('mount-drive', sourceDrivePath);
} catch (error: any) {
} catch (error) {
// noop
}
}
@@ -42,6 +40,12 @@ async function mountSourceDrive() {
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
*/
export async function selectImage(): Promise<string | undefined> {
return await openDialog();
}
export async function openDialog(
type: 'openFile' | 'openDirectory' = 'openFile',
) {
await mountSourceDrive();
const options: electron.OpenDialogOptions = {
// This variable is set when running in GNU/Linux from
@@ -52,22 +56,26 @@ export async function selectImage(): Promise<string | undefined> {
//
// See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7
defaultPath: process.env.OWD,
properties: ['openFile', 'treatPackageAsDirectory'],
filters: [
{
name: i18next.t('source.osImages'),
extensions: SUPPORTED_EXTENSIONS,
},
{
name: i18next.t('source.allFiles'),
extensions: ['*'],
},
],
properties: [type, 'treatPackageAsDirectory'],
filters:
type === 'openFile'
? [
{
name: 'OS Images',
extensions: SUPPORTED_EXTENSIONS,
},
{
name: 'All',
extensions: ['*'],
},
]
: undefined,
};
const currentWindow = remote.getCurrentWindow();
const [file] = (await remote.dialog.showOpenDialog(currentWindow, options))
.filePaths;
return file;
const currentWindow = electron.remote.getCurrentWindow();
const [path] = (
await electron.remote.dialog.showOpenDialog(currentWindow, options)
).filePaths;
return path;
}
/**
@@ -80,8 +88,8 @@ export async function showWarning(options: {
description: string;
}): Promise<boolean> {
_.defaults(options, {
confirmationLabel: i18next.t('ok'),
rejectionLabel: i18next.t('cancel'),
confirmationLabel: 'OK',
rejectionLabel: 'Cancel',
});
const BUTTONS = [options.confirmationLabel, options.rejectionLabel];
@@ -92,14 +100,14 @@ export async function showWarning(options: {
);
const BUTTON_REJECTION_INDEX = _.indexOf(BUTTONS, options.rejectionLabel);
const { response } = await remote.dialog.showMessageBox(
remote.getCurrentWindow(),
const { response } = await electron.remote.dialog.showMessageBox(
electron.remote.getCurrentWindow(),
{
type: 'warning',
buttons: BUTTONS,
defaultId: BUTTON_REJECTION_INDEX,
cancelId: BUTTON_REJECTION_INDEX,
title: i18next.t('attention'),
title: 'Attention',
message: options.title,
detail: options.description,
},
@@ -113,5 +121,5 @@ export async function showWarning(options: {
export function showError(error: Error) {
const title = errors.getTitle(error);
const message = errors.getDescription(error);
remote.dialog.showErrorBox(title, message);
electron.remote.dialog.showErrorBox(title, message);
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import * as remote from '@electron/remote';
import * as electron from 'electron';
import * as settings from '../models/settings';
@@ -28,8 +28,8 @@ export async function send(title: string, body: string, icon: string) {
}
// `app.dock` is only defined in OS X
if (remote.app.dock) {
remote.app.dock.bounce();
if (electron.remote.app.dock) {
electron.remote.app.dock.bounce();
}
return new window.Notification(title, { body, icon });

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import * as remote from '@electron/remote';
import * as electron from 'electron';
import { percentageToFloat } from '../../../shared/utils';
import { FlashState, titleFromFlashState } from '../modules/progress-status';
@@ -40,7 +40,7 @@ function getWindowTitle(state?: FlashState) {
* @description
* We expose this property to `this` for testability purposes.
*/
export const currentWindow = remote.getCurrentWindow();
export const currentWindow = electron.remote.getCurrentWindow();
/**
* @summary Set operating system window progress

View File

@@ -15,7 +15,6 @@
*/
import { exec } from 'child_process';
import { withTmpFile } from 'etcher-sdk/build/tmp';
import { readFile } from 'fs';
import { chain, trim } from 'lodash';
import { platform } from 'os';
@@ -23,6 +22,8 @@ import { join } from 'path';
import { env } from 'process';
import { promisify } from 'util';
import { withTmpFile } from '../../../shared/tmp';
const readFileAsync = promisify(readFile);
const execAsync = promisify(exec);
@@ -40,11 +41,11 @@ async function getWmicNetworkDrivesOutput(): Promise<string> {
// So we just redirect to a file and read it afterwards as we know it will be ucs2 encoded.
const options = {
// Close the file once it's created
keepOpen: false,
discardDescriptor: true,
// Wmic fails with "Invalid global switch" when the "/output:" switch filename contains a dash ("-")
prefix: 'tmp',
};
return withTmpFile(options, async ({ path }) => {
return withTmpFile(options, async (path) => {
const command = [
join(env.SystemRoot as string, 'System32', 'Wbem', 'wmic'),
'path',

View File

@@ -37,7 +37,6 @@ import {
import FlashSvg from '../../../assets/flash.svg';
import DriveStatusWarningModal from '../../components/drive-status-warning-modal/drive-status-warning-modal';
import * as i18next from 'i18next';
const COMPLETED_PERCENTAGE = 100;
const SPEED_PRECISION = 2;
@@ -60,27 +59,6 @@ const getErrorMessageFromCode = (errorCode: string) => {
return '';
};
function notifySuccess(
iconPath: string,
basename: string,
drives: any,
devices: { successful: number; failed: number },
) {
notification.send(
'Flash complete!',
messages.info.flashComplete(basename, drives, devices),
iconPath,
);
}
function notifyFailure(iconPath: string, basename: string, drives: any) {
notification.send(
'Oops! Looks like the flash failed.',
messages.error.flashFailure(basename, drives),
iconPath,
);
}
async function flashImageToDrive(
isFlashing: boolean,
goToSuccess: () => void,
@@ -106,20 +84,20 @@ async function flashImageToDrive(
if (!flashState.wasLastFlashCancelled()) {
const {
results = { devices: { successful: 0, failed: 0 } },
skip,
cancelled,
} = flashState.getFlashResults();
if (!skip && !cancelled) {
if (results.devices.successful > 0) {
notifySuccess(iconPath, basename, drives, results.devices);
} else {
notifyFailure(iconPath, basename, drives);
}
}
notification.send(
'Flash complete!',
messages.info.flashComplete(basename, drives as any, results.devices),
iconPath,
);
goToSuccess();
}
} catch (error: any) {
notifyFailure(iconPath, basename, drives);
} catch (error) {
notification.send(
'Oops! Looks like the flash failed.',
messages.error.flashFailure(path.basename(image.path), drives),
iconPath,
);
let errorMessage = getErrorMessageFromCode(error.code);
if (!errorMessage) {
error.image = basename;
@@ -157,7 +135,6 @@ interface FlashStepProps {
failed: number;
speed?: number;
eta?: number;
width: string;
}
export interface DriveWithWarnings extends constraints.DrivelistDrive {
@@ -222,11 +199,7 @@ export class FlashStep extends React.PureComponent<
const drives = selection.getSelectedDrives().map((drive) => {
return {
...drive,
statuses: constraints.getDriveImageCompatibilityStatuses(
drive,
undefined,
true,
),
statuses: constraints.getDriveImageCompatibilityStatuses(drive),
};
});
if (drives.length === 0 || this.props.isFlashing) {
@@ -264,7 +237,6 @@ export class FlashStep extends React.PureComponent<
<Flex
flexDirection="column"
alignItems="start"
width={this.props.width}
style={this.props.style}
>
<FlashSvg
@@ -294,17 +266,9 @@ export class FlashStep extends React.PureComponent<
color="#7e8085"
width="100%"
>
<Txt>
{i18next.t('flash.speedShort', {
speed: this.props.speed.toFixed(SPEED_PRECISION),
})}
</Txt>
<Txt>{this.props.speed.toFixed(SPEED_PRECISION)} MB/s</Txt>
{!_.isNil(this.props.eta) && (
<Txt>
{i18next.t('flash.eta', {
eta: formatSeconds(this.props.eta),
})}
</Txt>
<Txt>ETA: {formatSeconds(this.props.eta)}</Txt>
)}
</Flex>
)}
@@ -344,7 +308,6 @@ export class FlashStep extends React.PureComponent<
)}
{this.state.showDriveSelectorModal && (
<TargetSelectorModal
write={true}
cancel={() => this.setState({ showDriveSelectorModal: false })}
done={(modalTargets) => {
selectAllTargets(modalTargets);

View File

@@ -48,6 +48,7 @@ import { FlashStep } from './Flash';
import EtcherSvg from '../../../assets/etcher.svg';
import { SafeWebview } from '../../components/safe-webview/safe-webview';
import { colors } from '../../theme';
const Icon = styled(BaseIcon)`
margin-right: 20px;
@@ -87,9 +88,7 @@ const StepBorder = styled.div<{
position: relative;
height: 2px;
background-color: ${(props) =>
props.disabled
? props.theme.colors.dark.disabled.foreground
: props.theme.colors.dark.foreground};
props.disabled ? colors.dark.disabled.foreground : colors.dark.foreground};
width: 120px;
top: 19px;
@@ -132,13 +131,12 @@ export class MainPage extends React.Component<
}
private stateHelper(): MainPageStateFromStore {
const image = selectionState.getImage();
return {
isFlashing: flashState.isFlashing(),
hasImage: selectionState.hasImage(),
hasDrive: selectionState.hasDrive(),
imageLogo: image?.logo,
imageSize: image?.size,
imageLogo: selectionState.getImageLogo(),
imageSize: selectionState.getImageSize(),
imageName: getImageBasename(selectionState.getImage()),
driveTitle: getDrivesTitle(),
driveLabel: getDriveListLabel(),
@@ -148,7 +146,7 @@ export class MainPage extends React.Component<
private async getFeaturedProjectURL() {
const url = new URL(
(await settings.get('featuredProjectEndpoint')) ||
'https://efp.balena.io/index.html',
'https://assets.balena.io/etcher-featured/index.html',
);
url.searchParams.append('borderRight', 'false');
url.searchParams.append('darkBackground', 'true');
@@ -239,7 +237,6 @@ export class MainPage extends React.Component<
)}
<FlashStep
width={this.state.isWebviewShowing ? '220px' : '200px'}
goToSuccess={() => this.setState({ current: 'success' })}
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
isFlashing={this.state.isFlashing}
@@ -276,9 +273,9 @@ export class MainPage extends React.Component<
style={{
// Allow window to be dragged from header
// @ts-ignore
WebkitAppRegion: 'drag',
'-webkit-app-region': 'drag',
position: 'relative',
zIndex: 2,
zIndex: 1,
}}
>
<Flex width="100%" />
@@ -304,7 +301,7 @@ export class MainPage extends React.Component<
onClick={() => this.setState({ hideSettings: false })}
style={{
// Make touch events click instead of dragging
WebkitAppRegion: 'no-drag',
'-webkit-app-region': 'no-drag',
}}
/>
{!settings.getSync('disableExternalLinks') && (
@@ -312,14 +309,14 @@ export class MainPage extends React.Component<
icon={<QuestionCircleSvg height="1em" fill="currentColor" />}
onClick={() =>
openExternal(
selectionState.getImage()?.supportUrl ||
selectionState.getImageSupportUrl() ||
'https://github.com/balena-io/etcher/blob/master/SUPPORT.md',
)
}
tabIndex={6}
style={{
// Make touch events click instead of dragging
WebkitAppRegion: 'no-drag',
'-webkit-app-region': 'no-drag',
}}
/>
)}

View File

@@ -1,15 +0,0 @@
// @ts-nocheck
import { main } from './app';
import './i18n';
import { langParser } from './i18n';
import { ipcRenderer } from 'electron';
ipcRenderer.send('change-lng', langParser());
if (module.hot) {
module.hot.accept('./app', () => {
main();
});
}
main();

View File

@@ -126,30 +126,47 @@ const modalFooterShadowCss = css`
export const Modal = styled(({ style, children, ...props }) => {
return (
<ModalBase
position="top"
width="97vw"
cancelButtonProps={{
style: {
marginRight: '20px',
border: 'solid 1px #2a506f',
<Provider
theme={_.merge({}, theme, {
header: {
height: '50px',
},
}}
style={{
height: '87.5vh',
...style,
}}
{...props}
layer: {
extend: () => `
${theme.layer.extend()}
> div:last-child {
top: 0;
}
`,
},
})}
>
<ScrollableFlex flexDirection="column" width="100%" height="90%">
{children.length ? children.map((c: any) => <>{c}</>) : children}
</ScrollableFlex>
</ModalBase>
<ModalBase
position="top"
width="97vw"
cancelButtonProps={{
style: {
marginRight: '20px',
border: 'solid 1px #2a506f',
},
}}
style={{
height: '87.5vh',
...style,
}}
{...props}
>
<ScrollableFlex flexDirection="column" width="100%" height="90%">
{...children}
</ScrollableFlex>
</ModalBase>
</Provider>
);
})`
> div {
padding: 0;
height: 99%;
height: 100%;
> div:first-child {
height: 81%;

View File

@@ -71,11 +71,7 @@ export const colors = {
const font = 'SourceSansPro';
export const theme = _.merge({}, Theme, {
colors,
font,
header: {
height: '40px',
},
global: {
font: {
family: font,
@@ -100,7 +96,6 @@ export const theme = _.merge({}, Theme, {
font-size: 16px;
&& {
width: 200px;
height: 48px;
}

View File

@@ -1,73 +0,0 @@
/*
* Copyright 2022 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 { Dictionary } from 'lodash';
type BalenaTag = {
id: number;
name: string;
value: string;
};
export class EtcherPro {
private supervisorAddr: string;
private supervisorKey: string;
private tags: Dictionary<string> | undefined;
public uuid: string;
constructor(supervisorAddr: string, supervisorKey: string) {
this.supervisorAddr = supervisorAddr;
this.supervisorKey = supervisorKey;
this.uuid = (process.env.BALENA_DEVICE_UUID ?? 'NO-UUID').substring(0, 7);
this.tags = undefined;
this.get_tags().then((tags) => (this.tags = tags));
}
async get_tags(): Promise<Dictionary<string>> {
const result = await fetch(
this.supervisorAddr + '/v2/device/tags?apikey=' + this.supervisorKey,
);
const parsed = await result.json();
if (parsed['status'] === 'success') {
return Object.assign(
{},
...parsed['tags'].map((tag: BalenaTag) => {
return { [tag.name]: tag.value };
}),
);
} else {
return {};
}
}
public get_serial(): string | undefined {
if (this.tags) {
return this.tags['Serial'];
} else {
return undefined;
}
}
}
export function etcherProInfo(): EtcherPro | undefined {
const BALENA_SUPERVISOR_ADDRESS = process.env.BALENA_SUPERVISOR_ADDRESS;
const BALENA_SUPERVISOR_API_KEY = process.env.BALENA_SUPERVISOR_API_KEY;
if (BALENA_SUPERVISOR_ADDRESS && BALENA_SUPERVISOR_API_KEY) {
return new EtcherPro(BALENA_SUPERVISOR_ADDRESS, BALENA_SUPERVISOR_API_KEY);
}
return undefined;
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2020 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @summary Truncate text from the start with an ellipsis
*/
export function startEllipsis(input: string, limit: number): string {
// Do nothing, the string doesn't need truncation.
if (input.length <= limit) {
return input;
}
const lastPart = input.slice(input.length - limit, input.length);
return `${lastPart}`;
}

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 39 90" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<g transform="translate(-380 -166)">
<g transform="translate(380 166)">
<path d="m30.88 39.87h-23.363v23.209c0 0.6909 0.56062 1.251 1.251 1.251h20.861c0.69114 0 1.251-0.55986 1.251-1.251v-23.209zm-22.363 0.9999h21.363l4e-4 22.209c0 0.13886-0.11214 0.251-0.251 0.251h-20.861l-0.057452-0.0066403c-0.11075-0.026055-0.19355-0.12572-0.19355-0.24436l-4e-4 -22.209z" fill="#2A506F" fill-rule="nonzero"/>
<path d="m16.558 48.924h-3.967c-0.58314 0-1.055 0.47186-1.055 1.055v2.732c0 0.58235 0.47206 1.055 1.055 1.055h3.967c0.58223 0 1.054-0.47295 1.054-1.055v-2.732c0-0.58285-0.47156-1.055-1.054-1.055zm-3.967 1h3.967c0.029872 0 0.054 0.024158 0.054 0.055v2.732c0 0.030327-0.024612 0.055-0.054 0.055h-3.967c-0.030373 0-0.055-0.024658-0.055-0.055v-2.732c0-0.030858 0.024142-0.055 0.055-0.055z" fill="#2A506F" fill-rule="nonzero"/>
<path d="m25.97 48.924h-3.967c-0.58314 0-1.055 0.47186-1.055 1.055v2.732c0 0.58235 0.47206 1.055 1.055 1.055h3.967c0.58223 0 1.054-0.47295 1.054-1.055v-2.732c0-0.58285-0.47156-1.055-1.054-1.055zm-3.967 1h3.967c0.029872 0 0.054 0.024158 0.054 0.055v2.732c0 0.030327-0.024612 0.055-0.054 0.055h-3.967c-0.030373 0-0.055-0.024658-0.055-0.055v-2.732c0-0.030858 0.024142-0.055 0.055-0.055z" fill="#2A506F" fill-rule="nonzero"/>
<path d="m37.398 5.418v30.534c0 2.43-1.988 4.418-4.418 4.418h-27.562c-2.43 0-4.418-1.988-4.418-4.418v-30.534c0-2.43 1.988-4.418 4.418-4.418h27.562c2.43 0 4.418 1.988 4.418 4.418" fill="#2A506F"/>
<path d="m32.98-5.6843e-14h-27.562c-2.9823 0-5.418 2.4357-5.418 5.418v30.534c0 2.9823 2.4357 5.418 5.418 5.418h27.562c2.9823 0 5.418-2.4357 5.418-5.418v-30.534c0-2.9823-2.4357-5.418-5.418-5.418zm-27.562 2h27.562c1.8777 0 3.418 1.5403 3.418 3.418v30.534c0 1.8777-1.5403 3.418-3.418 3.418h-27.562c-1.8777 0-3.418-1.5403-3.418-3.418v-30.534c0-1.8777 1.5403-3.418 3.418-3.418z" fill="#2A506F" fill-rule="nonzero"/>
<path d="m19.147 73.551c0.24546 0 0.44961 0.17688 0.49194 0.41012l0.0080557 0.089876v14.882c0 0.27614-0.22386 0.5-0.5 0.5-0.24546 0-0.44961-0.17688-0.49194-0.41012l-0.0080557-0.089876v-14.882c0-0.27614 0.22386-0.5 0.5-0.5z" fill="#2A506F" fill-rule="nonzero"/>
<line x1="19.147" x2="14.532" y1="88.933" y2="84.214" stroke="#2A506F" stroke-linecap="round"/>
<line x1="19.147" x2="23.866" y1="88.933" y2="84.318" stroke="#2A506F" stroke-linecap="round"/>
<path d="m14.007 26.177c0.51076 0 0.96749-0.071211 1.3702-0.21363s0.74649-0.33887 1.0313-0.58934 0.50339-0.54268 0.65564-0.87664 0.22837-0.69247 0.22837-1.0755c0-0.3536-0.051567-0.66546-0.1547-0.93557s-0.2431-0.50585-0.4199-0.7072-0.38798-0.37816-0.63354-0.5304-0.50585-0.2873-0.78087-0.40517l-1.3702-0.58934c-0.19645-0.078578-0.38798-0.16452-0.5746-0.25783s-0.35851-0.20136-0.51567-0.32413-0.28239-0.2652-0.3757-0.42727-0.13997-0.36097-0.13997-0.5967c0-0.442 0.16452-0.78824 0.49357-1.0387s0.76368-0.3757 1.3039-0.3757c0.45182 0 0.85699 0.081034 1.2155 0.2431s0.6851 0.38552 0.97977 0.67037l0.663-0.7956c-0.34378-0.3536-0.76123-0.6409-1.2523-0.8619s-1.0264-0.3315-1.6059-0.3315c-0.442 0-0.84717 0.063845-1.2155 0.19153s-0.68756 0.30695-0.95767 0.53777-0.48129 0.50339-0.63354 0.8177-0.22837 0.65318-0.22837 1.0166c0 0.3536 0.058934 0.66546 0.1768 0.93557s0.27011 0.50339 0.45674 0.69984 0.3978 0.36342 0.63354 0.50094 0.46656 0.25538 0.69247 0.3536l1.3849 0.60407c0.22591 0.10804 0.43709 0.21118 0.63354 0.3094s0.36588 0.20872 0.5083 0.3315 0.25538 0.27011 0.33887 0.442 0.12523 0.38061 0.12523 0.62617c0 0.47147-0.1768 0.85208-0.5304 1.1418s-0.84963 0.43464-1.4881 0.43464c-0.50094 0-0.98468-0.1105-1.4512-0.3315s-0.87173-0.51321-1.2155-0.87664l-0.73667 0.85454c0.42236 0.442 0.92329 0.79069 1.5028 1.0461s1.2081 0.38307 1.8859 0.38307zm6.2664-0.1768v-4.5968c0.24556-0.60898 0.53286-1.0362 0.8619-1.2818s0.64581-0.36834 0.9503-0.36834c0.14733 0 0.27011 0.0098223 0.36834 0.029467s0.20627 0.049111 0.32413 0.0884l0.23573-1.0608c-0.22591-0.098223-0.48129-0.14733-0.76614-0.14733-0.41254 0-0.79315 0.1326-1.1418 0.3978s-0.64581 0.62371-0.89137 1.0755h-0.0442l-0.10313-1.2965h-1.0019v7.1604h1.2081zm6.5758 0.1768c0.43218 0 0.84471-0.081034 1.2376-0.2431s0.7514-0.38552 1.0755-0.67037l-0.5304-0.81034c-0.22591 0.19645-0.47884 0.36588-0.75877 0.5083s-0.58688 0.21363-0.92084 0.21363c-0.32413 0-0.62371-0.0663-0.89874-0.1989s-0.5083-0.31922-0.69984-0.55987-0.34132-0.52795-0.44937-0.8619-0.16207-0.7072-0.16207-1.1197 0.056478-0.78824 0.16943-1.1271 0.26766-0.63108 0.4641-0.87664 0.43218-0.43464 0.7072-0.56724 0.5746-0.1989 0.89874-0.1989c0.28485 0 0.54268 0.058934 0.7735 0.1768s0.44937 0.27011 0.65564 0.45674l0.6188-0.7956c-0.25538-0.22591-0.55005-0.42236-0.884-0.58934s-0.73667-0.25047-1.2081-0.25047c-0.46165 0-0.90119 0.083489-1.3186 0.25047s-0.78333 0.41254-1.0976 0.73667-0.56478 0.71948-0.7514 1.186-0.27993 0.99942-0.27993 1.5986c0 0.58934 0.085945 1.1173 0.25783 1.5838s0.40762 0.85945 0.7072 1.1787 0.65564 0.56232 1.0682 0.7293 0.85454 0.25047 1.326 0.25047z" fill="#fff" fill-rule="nonzero"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -15,33 +15,24 @@
*/
import * as electron from 'electron';
import * as remoteMain from '@electron/remote/main';
import { autoUpdater } from 'electron-updater';
import { promises as fs } from 'fs';
import { platform } from 'os';
import * as path from 'path';
import * as semver from 'semver';
import * as _ from 'lodash';
import './app/i18n';
import { packageType, version } from '../../package.json';
import * as EXIT_CODES from '../shared/exit-codes';
import { delay, getConfig } from '../shared/utils';
import * as settings from './app/models/settings';
import { logException } from './app/modules/analytics';
import { buildWindowMenu } from './menu';
import * as i18n from 'i18next';
import * as SentryMain from '@sentry/electron/main';
import * as packageJSON from '../../package.json';
import { anonymizeSentryData } from './app/modules/analytics';
const customProtocol = 'etcher';
const scheme = `${customProtocol}://`;
const updatablePackageTypes = ['appimage', 'nsis', 'dmg'];
const packageUpdatable = updatablePackageTypes.includes(packageType);
let packageUpdated = false;
let mainWindow: any = null;
remoteMain.initialize();
async function checkForUpdates(interval: number) {
// We use a while loop instead of a setInterval to preserve
@@ -51,28 +42,20 @@ async function checkForUpdates(interval: number) {
try {
const release = await autoUpdater.checkForUpdates();
const isOutdated =
semver.compare(release!.updateInfo.version, version) > 0;
const shouldUpdate = release!.updateInfo.stagingPercentage !== 0; // undefinded (default) means 100%
semver.compare(release.updateInfo.version, version) > 0;
const shouldUpdate = release.updateInfo.stagingPercentage || 0 > 0;
if (shouldUpdate && isOutdated) {
await autoUpdater.downloadUpdate();
packageUpdated = true;
}
} catch (err) {
logMainProcessException(err);
logException(err);
}
}
await delay(interval);
}
}
function logMainProcessException(error: any) {
const shouldReportErrors = settings.getSync('errorReporting');
console.error(error);
if (shouldReportErrors) {
SentryMain.captureException(error);
}
}
async function isFile(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
@@ -107,14 +90,6 @@ async function getCommandLineURL(argv: string[]): Promise<string | undefined> {
}
}
const initSentryMain = _.once(() => {
const dsn =
settings.getSync('analyticsSentryToken') ||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
SentryMain.init({ dsn, beforeSend: anonymizeSentryData });
});
const sourceSelectorReady = new Promise((resolve) => {
electron.ipcMain.on('source-selector-ready', resolve);
});
@@ -122,7 +97,6 @@ const sourceSelectorReady = new Promise((resolve) => {
async function selectImageURL(url?: string) {
// 'data:,' is the default chromedriver url that is passed as last argument when running spectron tests
if (url !== undefined && url !== 'data:,') {
url = url.replace(/\/$/, ''); // on windows the url ends with an extra slash
url = url.startsWith(scheme) ? url.slice(scheme.length) : url;
await sourceSelectorReady;
electron.BrowserWindow.getAllWindows().forEach((window) => {
@@ -155,11 +129,11 @@ async function createMainWindow() {
if (fullscreen) {
({ width, height } = electron.screen.getPrimaryDisplay().bounds);
}
mainWindow = new electron.BrowserWindow({
const mainWindow = new electron.BrowserWindow({
width,
height,
frame: !fullscreen,
useContentSize: true,
useContentSize: false,
show: false,
resizable: false,
maximizable: false,
@@ -173,14 +147,15 @@ async function createMainWindow() {
webPreferences: {
backgroundThrottling: false,
nodeIntegration: true,
contextIsolation: false,
webviewTag: true,
zoomFactor: width / defaultWidth,
enableRemoteModule: true,
},
});
electron.app.setAsDefaultProtocolClient(customProtocol);
buildWindowMenu(mainWindow);
mainWindow.setFullScreen(true);
// Prevent flash of white when starting the application
@@ -208,26 +183,34 @@ async function createMainWindow() {
);
const page = mainWindow.webContents;
remoteMain.enable(page);
page.once('did-frame-finish-load', async () => {
console.log('packageUpdatable', packageUpdatable);
autoUpdater.on('error', (err) => {
logMainProcessException(err);
logException(err);
});
if (packageUpdatable) {
try {
const checkForUpdatesTimer = 300000;
const configUrl = await settings.get('configUrl');
const onlineConfig = await getConfig(configUrl);
const autoUpdaterConfig: AutoUpdaterConfig = onlineConfig?.autoUpdates
?.autoUpdaterConfig ?? {
autoDownload: false,
};
for (const [key, value] of Object.entries(autoUpdaterConfig)) {
autoUpdater[key as keyof AutoUpdaterConfig] = value;
}
const checkForUpdatesTimer =
onlineConfig?.autoUpdates?.checkForUpdatesTimer ?? 300000;
checkForUpdates(checkForUpdatesTimer);
} catch (err) {
logMainProcessException(err);
logException(err);
}
}
});
return mainWindow;
}
electron.app.allowRendererProcessReuse = false;
electron.app.on('window-all-closed', electron.app.quit);
// Sending a `SIGINT` (e.g: Ctrl-C) to an Electron app that registers
@@ -245,7 +228,6 @@ async function main(): Promise<void> {
if (!electron.app.requestSingleInstanceLock()) {
electron.app.quit();
} else {
initSentryMain();
await electron.app.whenReady();
const window = await createMainWindow();
electron.app.on('second-instance', async (_event, argv) => {
@@ -256,19 +238,9 @@ async function main(): Promise<void> {
await selectImageURL(await getCommandLineURL(argv));
});
await selectImageURL(await getCommandLineURL(process.argv));
electron.ipcMain.on('change-lng', function (event, args) {
i18n.changeLanguage(args, () => {
console.log('Language changed to: ' + args);
});
if (mainWindow != null) {
buildWindowMenu(mainWindow);
} else {
console.log('Build menu failed. ');
}
});
}
}
main();
console.time('ready-to-show');

View File

@@ -17,8 +17,6 @@
import * as electron from 'electron';
import { displayName } from '../../package.json';
import * as i18next from 'i18next';
/**
* @summary Builds a native application menu for a given window
*/
@@ -44,13 +42,12 @@ export function buildWindowMenu(window: electron.BrowserWindow) {
const menuTemplate: electron.MenuItemConstructorOptions[] = [
{
role: 'editMenu',
label: i18next.t('menu.edit'),
},
{
label: i18next.t('menu.view'),
label: 'View',
submenu: [
{
label: i18next.t('menu.devTool'),
label: 'Toggle Developer Tools',
accelerator:
process.platform === 'darwin' ? 'Command+Alt+I' : 'Control+Shift+I',
click: toggleDevTools,
@@ -59,14 +56,12 @@ export function buildWindowMenu(window: electron.BrowserWindow) {
},
{
role: 'windowMenu',
label: i18next.t('menu.window'),
},
{
role: 'help',
label: i18next.t('menu.help'),
submenu: [
{
label: i18next.t('menu.pro'),
label: 'Etcher Pro',
click() {
electron.shell.openExternal(
'https://etcher.io/pro?utm_source=etcher_menu&ref=etcher_menu',
@@ -74,13 +69,13 @@ export function buildWindowMenu(window: electron.BrowserWindow) {
},
},
{
label: i18next.t('menu.website'),
label: 'Etcher Website',
click() {
electron.shell.openExternal('https://etcher.io?ref=etcher_menu');
},
},
{
label: i18next.t('menu.issue'),
label: 'Report an issue',
click() {
electron.shell.openExternal(
'https://github.com/balena-io/etcher/issues',
@@ -97,29 +92,25 @@ export function buildWindowMenu(window: electron.BrowserWindow) {
submenu: [
{
role: 'about' as const,
label: i18next.t('menu.about'),
label: 'About Etcher',
},
{
type: 'separator' as const,
},
{
role: 'hide' as const,
label: i18next.t('menu.hide'),
},
{
role: 'hideOthers' as const,
label: i18next.t('menu.hideOthers'),
},
{
role: 'unhide' as const,
label: i18next.t('menu.unhide'),
},
{
type: 'separator' as const,
},
{
role: 'quit' as const,
label: i18next.t('menu.quit'),
},
],
});

View File

@@ -15,30 +15,18 @@
*/
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 * as sdk from 'etcher-sdk';
import { cleanupTmpFiles } from 'etcher-sdk/build/tmp';
import { promises as fs } from 'fs';
import * as _ from 'lodash';
import * as ipc from 'node-ipc';
import { totalmem } from 'os';
import { BlockDevice, File, Http } from 'etcher-sdk/build/source-destination';
import { toJSON } from '../../shared/errors';
import { GENERAL_ERROR, SUCCESS } from '../../shared/exit-codes';
import { delay, isJson } from '../../shared/utils';
import { delay } 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;
@@ -71,7 +59,7 @@ function log(message: string) {
*/
async function terminate(exitCode: number) {
ipc.disconnect(IPC_SERVER_ID);
await cleanupTmpFiles(Date.now(), DECOMPRESSED_IMAGE_PREFIX);
await cleanupTmpFiles(Date.now());
process.nextTick(() => {
process.exit(exitCode || SUCCESS);
});
@@ -86,25 +74,14 @@ async function handleError(error: Error) {
await terminate(GENERAL_ERROR);
}
export interface FlashError extends Error {
description: string;
device: string;
code: string;
}
export interface WriteResult {
bytesWritten?: number;
devices?: {
interface WriteResult {
bytesWritten: number;
devices: {
failed: number;
successful: number;
};
errors: FlashError[];
sourceMetadata?: Metadata;
}
export interface FlashResults extends WriteResult {
skip?: boolean;
cancelled?: boolean;
errors: Array<Error & { device: string }>;
sourceMetadata: sdk.sourceDestination.Metadata;
}
/**
@@ -126,15 +103,19 @@ async function writeAndValidate({
onProgress,
onFail,
}: {
source: SourceDestination;
destinations: BlockDevice[];
source: sdk.sourceDestination.SourceDestination;
destinations: sdk.sourceDestination.BlockDevice[];
verify: boolean;
autoBlockmapping: boolean;
decompressFirst: boolean;
onProgress: OnProgressFunction;
onFail: OnFailFunction;
onProgress: sdk.multiWrite.OnProgressFunction;
onFail: sdk.multiWrite.OnFailFunction;
}): Promise<WriteResult> {
const { sourceMetadata, failures, bytesWritten } = await decompressThenFlash({
const {
sourceMetadata,
failures,
bytesWritten,
} = await sdk.multiWrite.decompressThenFlash({
source,
destinations,
onFail,
@@ -158,8 +139,8 @@ async function writeAndValidate({
sourceMetadata,
};
for (const [destination, error] of failures) {
const err = error as FlashError;
const drive = destination as BlockDevice;
const err = error as Error & { device: string; description: string };
const drive = destination as sdk.sourceDestination.BlockDevice;
err.device = drive.device;
err.description = drive.description;
result.errors.push(err);
@@ -170,10 +151,18 @@ async function writeAndValidate({
interface WriteOptions {
image: SourceMetadata;
destinations: DrivelistDrive[];
unmountOnSuccess: boolean;
validateWriteOnSuccess: boolean;
autoBlockmapping: boolean;
decompressFirst: boolean;
SourceType: string;
httpRequest?: any;
saveUrlImage: boolean;
saveUrlImageTo: string;
}
interface ProgressState
extends Omit<sdk.multiWrite.MultiDestinationProgress, 'type'> {
type: sdk.multiWrite.MultiDestinationProgress['type'] | 'downloading';
}
ipc.connectTo(IPC_SERVER_ID, () => {
@@ -211,7 +200,7 @@ ipc.connectTo(IPC_SERVER_ID, () => {
* @example
* writer.on('progress', onProgress)
*/
const onProgress = (state: MultiDestinationProgress) => {
const onProgress = (state: ProgressState) => {
ipc.of[IPC_SERVER_ID].emit('state', state);
};
@@ -247,7 +236,10 @@ ipc.connectTo(IPC_SERVER_ID, () => {
* @example
* writer.on('fail', onFail)
*/
const onFail = (destination: SourceDestination, error: Error) => {
const onFail = (
destination: sdk.sourceDestination.SourceDestination,
error: Error,
) => {
ipc.of[IPC_SERVER_ID].emit('fail', {
// TODO: device should be destination
// @ts-ignore (destination.drive is private)
@@ -260,12 +252,14 @@ ipc.connectTo(IPC_SERVER_ID, () => {
const imagePath = options.image.path;
log(`Image: ${imagePath}`);
log(`Devices: ${destinations.join(', ')}`);
log(`Umount on success: ${options.unmountOnSuccess}`);
log(`Validate on success: ${options.validateWriteOnSuccess}`);
log(`Auto blockmapping: ${options.autoBlockmapping}`);
log(`Decompress first: ${options.decompressFirst}`);
const dests = options.destinations.map((destination) => {
return new BlockDevice({
return new sdk.sourceDestination.BlockDevice({
drive: destination,
unmountOnSuccess: true,
unmountOnSuccess: options.unmountOnSuccess,
write: true,
direct: true,
});
@@ -284,28 +278,22 @@ ipc.connectTo(IPC_SERVER_ID, () => {
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,
});
if (options.saveUrlImage) {
source = await saveFileBeforeFlash(
imagePath,
options.saveUrlImageTo,
onProgress,
onFail,
);
} else {
source = new Http({
url: imagePath,
avoidRandomAccess: true,
auth: options.image.auth,
});
source = new Http({ url: imagePath, avoidRandomAccess: true });
}
}
}
const results = await writeAndValidate({
source,
destinations: dests,
verify: true,
verify: options.validateWriteOnSuccess,
autoBlockmapping: options.autoBlockmapping,
decompressFirst: options.decompressFirst,
onProgress,
@@ -318,7 +306,8 @@ ipc.connectTo(IPC_SERVER_ID, () => {
ipc.of[IPC_SERVER_ID].emit('done', { results });
await delay(DISCONNECT_DELAY);
await terminate(exitCode);
} catch (error: any) {
} catch (error) {
log(`Error: ${error.message}`);
exitCode = GENERAL_ERROR;
ipc.of[IPC_SERVER_ID].emit('error', toJSON(error));
}
@@ -331,3 +320,43 @@ ipc.connectTo(IPC_SERVER_ID, () => {
ipc.of[IPC_SERVER_ID].emit('ready', {});
});
});
async function saveFileBeforeFlash(
imagePath: string,
saveUrlImageTo: string,
onProgress: (state: ProgressState) => void,
onFail: (
destination: sdk.sourceDestination.SourceDestination,
error: Error,
) => void,
) {
const urlImage = new Http({ url: imagePath, avoidRandomAccess: true });
const source = await urlImage.getInnerSource();
const metadata = await source.getMetadata();
const fileName = `${saveUrlImageTo}/${metadata.name}`;
let alreadyDownloaded = false;
try {
alreadyDownloaded = (await fs.stat(fileName)).isFile();
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
if (!alreadyDownloaded) {
await sdk.multiWrite.decompressThenFlash({
source,
destinations: [new File({ path: fileName, write: true })],
onProgress: (progress) => {
onProgress({
...progress,
type: 'downloading',
});
},
onFail: (...args) => {
onFail(...args);
},
verify: true,
});
}
return new File({ path: fileName });
}

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env osascript -l JavaScript
ObjC.import('stdlib')
const app = Application.currentApplication()
app.includeStandardAdditions = true
const result = app.displayDialog('balenaEtcher 需要来自管理员的权限才能烧录镜像到磁盘。\n\n输入您的密码以允许此操作。', {
defaultAnswer: '',
withIcon: 'caution',
buttons: ['取消', '好'],
defaultButton: '好',
hiddenAnswer: true,
})
if (result.buttonReturned === '好') {
result.textReturned
} else {
$.exit(255)
}

View File

@@ -15,13 +15,11 @@
*/
import { execFile } from 'child_process';
import { app, remote } from 'electron';
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';
@@ -31,15 +29,6 @@ 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}`],
@@ -48,9 +37,9 @@ export async function sudo(
env: {
PATH: env.PATH,
SUDO_ASKPASS: join(
getAppPath(),
(app || remote.app).getAppPath(),
__dirname,
`sudo-askpass.osascript-${lang}.js`,
'sudo-askpass.osascript.js',
),
},
},
@@ -60,7 +49,7 @@ export async function sudo(
stdout: stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length),
stderr,
};
} catch (error: any) {
} catch (error) {
if (error.code === 1) {
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {
return { cancelled: true };

View File

@@ -34,6 +34,16 @@ export type DrivelistDrive = Drive & {
displayName: string;
};
/**
* @summary Check if a drive is locked
*
* @description
* This usually points out a locked SD Card.
*/
export function isDriveLocked(drive: DrivelistDrive): boolean {
return Boolean(drive.isReadOnly);
}
/**
* @summary Check if a drive is a system drive
*/
@@ -63,7 +73,9 @@ export function isSourceDrive(
): boolean {
if (selection) {
if (selection.drive) {
return selection.drive.device === drive.device;
const sourcePath = selection.drive.devicePath || selection.drive.device;
const drivePath = drive.devicePath || drive.device;
return pathIsInside(sourcePath, drivePath);
}
if (selection.path) {
return sourceIsInsideDrive(selection.path, drive);
@@ -105,18 +117,24 @@ export function isDriveLargeEnough(
}
/**
* @summary Check if a drive is valid, i.e. large enough for an image
* @summary Check if a drive is disabled (i.e. not ready for selection)
*/
export function isDriveDisabled(drive: DrivelistDrive): boolean {
return drive.disabled || false;
}
/**
* @summary Check if a drive is valid, i.e. not locked and large enough for an image
*/
export function isDriveValid(
drive: DrivelistDrive,
image?: SourceMetadata,
write: boolean = true,
): boolean {
return (
!write ||
(!drive.disabled &&
isDriveLargeEnough(drive, image) &&
!isSourceDrive(drive, image as SourceMetadata))
!isDriveLocked(drive) &&
isDriveLargeEnough(drive, image) &&
!isSourceDrive(drive, image as SourceMetadata) &&
!isDriveDisabled(drive)
);
}
@@ -197,19 +215,17 @@ export const statuses = {
*/
export function getDriveImageCompatibilityStatuses(
drive: DrivelistDrive,
image: SourceMetadata | undefined,
write: boolean,
image?: SourceMetadata,
) {
const statusList = [];
// Mind the order of the if-statements if you modify.
if (drive.isReadOnly && write) {
if (isDriveLocked(drive)) {
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.locked(),
});
}
if (
} else if (
!_.isNil(drive) &&
!_.isNil(drive.size) &&
!isDriveLargeEnough(drive, image)
@@ -248,11 +264,10 @@ export function getDriveImageCompatibilityStatuses(
*/
export function getListDriveImageCompatibilityStatuses(
drives: DrivelistDrive[],
image: SourceMetadata | undefined,
write: boolean,
image: SourceMetadata,
) {
return drives.flatMap((drive) => {
return getDriveImageCompatibilityStatuses(drive, image, write);
return getDriveImageCompatibilityStatuses(drive, image);
});
}
@@ -264,12 +279,9 @@ export function getListDriveImageCompatibilityStatuses(
*/
export function hasDriveImageCompatibilityStatus(
drive: DrivelistDrive,
image: SourceMetadata | undefined,
write: boolean,
image: SourceMetadata,
) {
return Boolean(
getDriveImageCompatibilityStatuses(drive, image, write).length,
);
return Boolean(getDriveImageCompatibilityStatuses(drive, image).length);
}
export interface DriveStatus {

View File

@@ -17,16 +17,16 @@
import { Dictionary } from 'lodash';
import { outdent } from 'outdent';
import * as prettyBytes from 'pretty-bytes';
import '../gui/app/i18n';
import * as i18next from 'i18next';
export const progress: Dictionary<(quantity: number) => string> = {
successful: (quantity: number) => {
return i18next.t('message.flashSucceed', { count: quantity });
const plural = quantity === 1 ? '' : 's';
return `Successful target${plural}`;
},
failed: (quantity: number) => {
return i18next.t('message.flashFail', { count: quantity });
const plural = quantity === 1 ? '' : 's';
return `Failed target${plural}`;
},
};
@@ -38,121 +38,124 @@ export const info = {
) => {
const targets = [];
if (failed + successful === 1) {
targets.push(
i18next.t('message.toDrive', {
description: drive.description,
name: drive.displayName,
}),
);
targets.push(`to ${drive.description} (${drive.displayName})`);
} else {
if (successful) {
targets.push(
i18next.t('message.toTarget', {
count: successful,
num: successful,
}),
);
const plural = successful === 1 ? '' : 's';
targets.push(`to ${successful} target${plural}`);
}
if (failed) {
targets.push(
i18next.t('message.andFailTarget', { count: failed, num: failed }),
);
const plural = failed === 1 ? '' : 's';
targets.push(`and failed to be flashed to ${failed} target${plural}`);
}
}
return i18next.t('message.succeedTo', {
name: imageBasename,
target: targets.join(' '),
});
return `${imageBasename} was successfully flashed ${targets.join(' ')}`;
},
};
export const compatibility = {
sizeNotRecommended: () => {
return i18next.t('message.sizeNotRecommended');
return 'Not recommended';
},
tooSmall: () => {
return i18next.t('message.tooSmall');
return 'Too small';
},
locked: () => {
return i18next.t('message.locked');
return 'Locked';
},
system: () => {
return i18next.t('message.system');
return 'System drive';
},
containsImage: () => {
return i18next.t('message.containsImage');
return 'Source drive';
},
// The drive is large and therefore likely not a medium you want to write to.
largeDrive: () => {
return i18next.t('message.largeDrive');
return 'Large drive';
},
} as const;
export const warning = {
tooSmall: (source: { size: number }, target: { size: number }) => {
unrecommendedDriveSize: (
image: { recommendedDriveSize: number },
drive: { device: string; size: number },
) => {
return outdent({ newline: ' ' })`
${i18next.t('message.sourceLarger', {
byte: prettyBytes(source.size - target.size),
})}
This image recommends a ${prettyBytes(image.recommendedDriveSize)}
drive, however ${drive.device} is only ${prettyBytes(drive.size)}.
`;
},
exitWhileFlashing: () => {
return i18next.t('message.exitWhileFlashing');
return [
'You are currently flashing a drive.',
'Closing Etcher may leave your drive in an unusable state.',
].join(' ');
},
looksLikeWindowsImage: () => {
return i18next.t('message.looksLikeWindowsImage');
return [
'It looks like you are trying to burn a Windows image.\n\n',
'Unlike other images, Windows images require special processing to be made bootable.',
'We suggest you use a tool specially designed for this purpose, such as',
'<a href="https://rufus.akeo.ie">Rufus</a> (Windows),',
'<a href="https://github.com/slacka/WoeUSB">WoeUSB</a> (Linux),',
'or Boot Camp Assistant (macOS).',
].join(' ');
},
missingPartitionTable: () => {
return i18next.t('message.missingPartitionTable', {
type: i18next.t('message.image'),
});
},
driveMissingPartitionTable: () => {
return i18next.t('message.missingPartitionTable', {
type: i18next.t('message.drive'),
});
return [
'It looks like this is not a bootable image.\n\n',
'The image does not appear to contain a partition table,',
'and might not be recognized or bootable by your device.',
].join(' ');
},
largeDriveSize: () => {
return i18next.t('message.largeDriveSize');
return 'This is a large drive! Make sure it doesn\'t contain files that you want to keep.';
},
systemDrive: () => {
return i18next.t('message.systemDrive');
return 'Selecting your system drive is dangerous and will erase your drive!';
},
sourceDrive: () => {
return i18next.t('message.sourceDrive');
return 'Contains the image you chose to flash';
},
};
export const error = {
notEnoughSpaceInDrive: () => {
return i18next.t('message.noSpace');
return [
'Not enough space on the drive.',
'Please insert larger one and try again.',
].join(' ');
},
genericFlashError: (err: Error) => {
return i18next.t('message.genericFlashError', { error: err.message });
return `Something went wrong. If it is a compressed image, please check that the archive is not corrupted.\n${err.message}`;
},
validation: () => {
return i18next.t('message.validation');
return [
'The write has been completed successfully but Etcher detected potential',
'corruption issues when reading the image back from the drive.',
'\n\nPlease consider writing the image to a different drive.',
].join(' ');
},
openSource: (sourceName: string, errorMessage: string) => {
return i18next.t('message.openError', {
source: sourceName,
error: errorMessage,
});
return outdent`
Something went wrong while opening ${sourceName}
Error: ${errorMessage}
`;
},
flashFailure: (
@@ -161,33 +164,35 @@ export const error = {
) => {
const target =
drives.length === 1
? i18next.t('message.toDrive', {
description: drives[0].description,
name: drives[0].displayName,
})
: i18next.t('message.toTarget', {
count: drives.length,
num: drives.length,
});
return i18next.t('message.flashError', {
image: imageBasename,
targets: target,
});
? `${drives[0].description} (${drives[0].displayName})`
: `${drives.length} targets`;
return `Something went wrong while writing ${imageBasename} to ${target}.`;
},
driveUnplugged: () => {
return i18next.t('message.unplug');
return [
'Looks like Etcher lost access to the drive.',
'Did it get unplugged accidentally?',
"\n\nSometimes this error is caused by faulty readers that don't provide stable access to the drive.",
].join(' ');
},
inputOutput: () => {
return i18next.t('message.cannotWrite');
return [
'Looks like Etcher is not able to write to this location of the drive.',
'This error is usually caused by a faulty drive, reader, or port.',
'\n\nPlease try again with another drive, reader, or port.',
].join(' ');
},
childWriterDied: () => {
return i18next.t('message.childWriterDied');
return [
'The writer process ended unexpectedly.',
'Please try again, and contact the Etcher team if the problem persists.',
].join(' ');
},
unsupportedProtocol: () => {
return i18next.t('message.badProtocol');
return 'Only http:// and https:// URLs are supported.';
},
};

View File

@@ -15,32 +15,30 @@
*/
import * as childProcess from 'child_process';
import { withTmpFile } from 'etcher-sdk/build/tmp';
import { promises as fs } from 'fs';
import * as _ from 'lodash';
import * as os from 'os';
import * as semver from 'semver';
import * as sudoPrompt from '@balena/sudo-prompt';
import * as sudoPrompt from 'sudo-prompt';
import { promisify } from 'util';
import { sudo as catalinaSudo } from './catalina-sudo/sudo';
import * as errors from './errors';
import { withTmpFile } from './tmp';
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 }> {
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
sudoPrompt.exec(
cmd,
options,
(error: Error | undefined, stdout: Std, stderr: Std) => {
if (error) {
(error: Error | null, stdout: string, stderr: string) => {
if (error != null) {
reject(error);
} else {
resolve({ stdout, stderr });
@@ -62,7 +60,7 @@ export async function isElevated(): Promise<boolean> {
// See http://stackoverflow.com/a/28268802
try {
await execAsync('fltmc');
} catch (error: any) {
} catch (error) {
if (error.code === os.constants.errno.EPERM) {
return false;
}
@@ -148,7 +146,7 @@ async function elevateScriptCatalina(
try {
const { cancelled } = await catalinaSudo(cmd);
return { cancelled };
} catch (error: any) {
} catch (error) {
throw errors.createError({ title: error.stderr });
}
}
@@ -174,11 +172,10 @@ export async function elevateCommand(
);
return await withTmpFile(
{
keepOpen: false,
prefix: 'balena-etcher-electron-',
postfix: '.cmd',
},
async ({ path }) => {
async (path) => {
await fs.writeFile(path, launchScript);
if (isWindows) {
return elevateScriptWindows(path, options.applicationName);
@@ -192,7 +189,7 @@ export async function elevateCommand(
}
try {
return await elevateScriptUnix(path, options.applicationName);
} catch (error: any) {
} catch (error) {
// We're hardcoding internal error messages declared by `sudo-prompt`.
// There doesn't seem to be a better way to handle these errors, so
// for now, we should make sure we double check if the error messages

27
lib/shared/tmp.ts Normal file
View File

@@ -0,0 +1,27 @@
import * as tmp from 'tmp';
function tmpFileAsync(
options: tmp.FileOptions,
): Promise<{ path: string; cleanup: () => void }> {
return new Promise((resolve, reject) => {
tmp.file(options, (error, path, _fd, cleanup) => {
if (error) {
reject(error);
} else {
resolve({ path, cleanup });
}
});
});
}
export async function withTmpFile<T>(
options: tmp.FileOptions,
fn: (path: string) => Promise<T>,
): Promise<T> {
const { path, cleanup } = await tmpFileAsync(options);
try {
return await fn(path);
} finally {
cleanup();
}
}

View File

@@ -32,30 +32,18 @@ export function percentageToFloat(percentage: any) {
return percentage / 100;
}
/**
* @summary Get etcher configs stored online
* @param {String} - url where config.json is stored
*/
export async function getConfig(configUrl?: string): Promise<Dictionary<any>> {
configUrl = configUrl ?? 'https://balena.io/etcher/static/config.json';
const response = await axios.get(configUrl, { responseType: 'json' });
return response.data;
}
export async function delay(duration: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, duration);
});
}
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) {
try {
JSON.parse(jsonString);
} catch (e) {
return false;
}
return true;
}

16768
npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load Diff

36145
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "balena-etcher",
"private": true,
"displayName": "balenaEtcher",
"version": "1.18.6",
"version": "1.5.109",
"packageType": "local",
"main": "generated/etcher.js",
"description": "Flash OS images to SD cards and USB drives, safely and easily.",
@@ -13,25 +13,22 @@
"url": "git@github.com:balena-io/etcher.git"
},
"scripts": {
"build": "npm run webpack",
"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",
"flowzone-preinstall-macos": "true",
"flowzone-preinstall-windows": "npx node-gyp install",
"flowzone-preinstall": "npm run flowzone-preinstall-linux",
"lint-css": "prettier --write lib/**/*.css",
"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",
"lint-css": "prettier --write lib/**/*.css",
"lint-spell": "codespell --dictionary - --dictionary dictionary.txt --skip *.ttf *.svg *.gz,*.bz2,*.xz,*.zip,*.img,*.dmg,*.iso,*.rpi-sdcard,*.wic,.DS_Store,*.dtb,*.dtbo,*.dat,*.elf,*.bin,*.foo,xz-without-extension lib tests docs Makefile *.md LICENSE",
"lint": "npm run lint-ts && npm run lint-css && npm run lint-spell",
"test-spectron": "mocha --recursive --reporter spec --require ts-node/register --require-main tests/gui/allow-renderer-process-reuse.ts tests/spectron/runner.spec.ts",
"test-gui": "electron-mocha --recursive --reporter spec --require ts-node/register --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 --require-main tests/gui/allow-renderer-process-reuse.ts --full-trace --no-sandbox tests/shared/**/*.ts",
"test": "npm run lint && npm run test-gui && npm run test-shared && npm run test-spectron && npm run sanity-checks",
"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"
"postshrinkwrap": "ts-node ./scripts/clean-shrinkwrap.ts",
"webpack": "webpack",
"watch": "webpack --watch",
"concourse-build-electron": "npm run webpack",
"concourse-test": "npx npm@6.14.5 test",
"concourse-test-electron": "npx npm@6.14.5 test"
},
"husky": {
"hooks": {
@@ -46,86 +43,74 @@
"npm run lint-css"
]
},
"author": "Balena Ltd. <hello@balena.io>",
"author": "Balena Inc. <hello@etcher.io>",
"license": "Apache-2.0",
"platformSpecificDependencies": [
"fsevents",
"winusb-driver-generator"
],
"devDependencies": {
"@balena/lint": "5.4.2",
"@balena/sudo-prompt": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
"@electron/remote": "^2.0.9",
"@fortawesome/fontawesome-free": "5.15.4",
"@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",
"electron": "^19.1.9",
"electron-builder": "^23.6.0",
"electron-mocha": "^11.0.2",
"electron-notarize": "1.2.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",
"lint-staged": "10.5.4",
"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",
"path-is-inside": "1.0.2",
"pnp-webpack-plugin": "1.7.0",
"pretty-bytes": "5.6.0",
"react": "16.8.5",
"react-dom": "16.8.5",
"react-i18next": "11.18.6",
"redux": "4.2.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",
"sys-class-rgb-led": "3.0.1",
"terser-webpack-plugin": "5.3.6",
"ts-loader": "8.4.0",
"ts-node": "9.1.1",
"tslib": "2.4.1",
"typescript": "4.4.4",
"url-loader": "4.1.1",
"uuid": "8.3.2",
"webpack": "5.75.0",
"webpack-cli": "4.10.0",
"webpack-dev-server": "4.11.1"
},
"engines": {
"node": ">=16"
},
"versionist": {
"publishedAt": "2023-03-21T13:24:18.905Z"
"@balena/lint": "^5.0.4",
"@fortawesome/fontawesome-free": "^5.13.1",
"@svgr/webpack": "^5.4.0",
"@types/chai": "^4.2.7",
"@types/copy-webpack-plugin": "^6.0.0",
"@types/mime-types": "^2.1.0",
"@types/mini-css-extract-plugin": "^0.9.1",
"@types/mocha": "^8.0.3",
"@types/node": "^12.12.39",
"@types/node-ipc": "^9.1.2",
"@types/react-dom": "^16.8.4",
"@types/semver": "^7.1.0",
"@types/sinon": "^9.0.0",
"@types/terser-webpack-plugin": "^4.1.0",
"@types/tmp": "^0.2.0",
"@types/webpack-node-externals": "^2.5.0",
"chai": "^4.2.0",
"copy-webpack-plugin": "^6.0.1",
"css-loader": "^4.2.1",
"d3": "^4.13.0",
"debug": "^4.2.0",
"electron": "9.2.1",
"electron-builder": "^22.7.0",
"electron-mocha": "^9.1.0",
"electron-notarize": "^1.0.0",
"electron-rebuild": "^1.11.0",
"electron-updater": "^4.3.2",
"etcher-sdk": "^4.1.30",
"file-loader": "^6.0.0",
"husky": "^4.2.5",
"immutable": "^3.8.1",
"lint-staged": "^10.2.2",
"lodash": "^4.17.10",
"mini-css-extract-plugin": "^0.10.0",
"mocha": "^8.0.1",
"native-addon-loader": "^2.0.1",
"node-ipc": "^9.1.1",
"omit-deep-lodash": "1.1.4",
"outdent": "^0.7.1",
"path-is-inside": "^1.0.2",
"pretty-bytes": "^5.3.0",
"react": "^16.8.5",
"react-dom": "^16.8.5",
"redux": "^4.0.5",
"rendition": "^18.8.3",
"resin-corvus": "^2.0.5",
"semver": "^7.3.2",
"simple-progress-webpack-plugin": "^1.1.2",
"sinon": "^9.0.2",
"spectron": "^11.0.0",
"string-replace-loader": "^2.3.0",
"styled-components": "^5.1.0",
"sudo-prompt": "github:zvin/sudo-prompt#workaround-windows-amperstand-in-username",
"sys-class-rgb-led": "^2.1.0",
"tmp": "^0.2.1",
"ts-loader": "^8.0.0",
"ts-node": "^9.0.0",
"tslib": "^2.0.0",
"typescript": "^4.0.2",
"uuid": "^8.1.0",
"webpack": "^4.40.2",
"webpack-cli": "^3.3.9"
}
}

View File

@@ -1,21 +1,11 @@
---
type: electron
release: github
publishMetadata: true
sentry:
org: balenaetcher
team: resinio
type: electron
org: balenaetcher
team: resinio
type: electron
triggerNotification:
version: 1.7.9
stagingPercentage: 100
upstream:
- repo: etcher-sdk
url: https://github.com/balena-io-modules/etcher-sdk
module: etcher-sdk
- repo: sys-class-rgb-led
url: https://github.com/balena-io-modules/sys-class-rgb-led
module: sys-class-rgb-led
- repo: rendition
url: https://github.com/balena-io-modules/rendition
module: rendition
version: 1.5.81
stagingPercentage: 100

View File

@@ -1,2 +1,3 @@
awscli==1.27.28
shyaml==0.6.2
codespell==1.12.0
awscli==1.11.87
shyaml==0.5.0

View File

@@ -29,15 +29,11 @@ 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) {
} catch (error) {
console.log(`[ERROR] Couldn't write shrinkwrap file: ${error.stack}`);
process.exitCode = 1;
}

Binary file not shown.

Binary file not shown.

View File

@@ -1,13 +1,5 @@
// tslint:disable-next-line:no-var-requires
const { app } = require('electron');
if (app !== undefined) {
// tslint:disable-next-line:no-var-requires
const remoteMain = require('@electron/remote/main');
remoteMain.initialize();
app.on('browser-window-created', (_event, window) =>
remoteMain.enable(window.webContents),
);
app.allowRendererProcessReuse = false;
}

View File

@@ -573,8 +573,7 @@ describe('Model: flashState', function () {
});
describe('.getFlashUuid()', function () {
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
it('should be initially undefined', function () {
expect(flashState.getFlashUuid()).to.be.undefined;

View File

@@ -33,6 +33,26 @@ describe('Model: selectionState', function () {
expect(selectionState.getImage()).to.be.undefined;
});
it('getImagePath() should return undefined', function () {
expect(selectionState.getImagePath()).to.be.undefined;
});
it('getImageSize() should return undefined', function () {
expect(selectionState.getImageSize()).to.be.undefined;
});
it('getImageName() should return undefined', function () {
expect(selectionState.getImageName()).to.be.undefined;
});
it('getImageLogo() should return undefined', function () {
expect(selectionState.getImageLogo()).to.be.undefined;
});
it('getImageSupportUrl() should return undefined', function () {
expect(selectionState.getImageSupportUrl()).to.be.undefined;
});
it('hasDrive() should return false', function () {
const hasDrive = selectionState.hasDrive();
expect(hasDrive).to.be.false;
@@ -359,6 +379,43 @@ describe('Model: selectionState', function () {
});
});
describe('.getImagePath()', function () {
it('should return the image path', function () {
const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('foo.img');
});
});
describe('.getImageSize()', function () {
it('should return the image size', function () {
const imageSize = selectionState.getImageSize();
expect(imageSize).to.equal(999999999);
});
});
describe('.getImageName()', function () {
it('should return the image name', function () {
const imageName = selectionState.getImageName();
expect(imageName).to.equal('Raspbian');
});
});
describe('.getImageLogo()', function () {
it('should return the image logo', function () {
const imageLogo = selectionState.getImageLogo();
expect(imageLogo).to.equal(
'<svg><text fill="red">Raspbian</text></svg>',
);
});
});
describe('.getImageSupportUrl()', function () {
it('should return the image support url', function () {
const imageSupportUrl = selectionState.getImageSupportUrl();
expect(imageSupportUrl).to.equal('https://www.raspbian.org/forums/');
});
});
describe('.hasImage()', function () {
it('should return true', function () {
const hasImage = selectionState.hasImage();
@@ -378,9 +435,9 @@ describe('Model: selectionState', function () {
SourceType: File,
});
const imagePath = selectionState.getImage()?.path;
const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('bar.img');
const imageSize = selectionState.getImage()?.size;
const imageSize = selectionState.getImageSize();
expect(imageSize).to.equal(999999999);
});
});
@@ -389,9 +446,9 @@ describe('Model: selectionState', function () {
it('should clear the image', function () {
selectionState.deselectImage();
const imagePath = selectionState.getImage()?.path;
const imagePath = selectionState.getImagePath();
expect(imagePath).to.be.undefined;
const imageSize = selectionState.getImage()?.size;
const imageSize = selectionState.getImageSize();
expect(imageSize).to.be.undefined;
});
});
@@ -415,9 +472,9 @@ describe('Model: selectionState', function () {
it('should be able to set an image', function () {
selectionState.selectSource(image);
const imagePath = selectionState.getImage()?.path;
const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('foo.img');
const imageSize = selectionState.getImage()?.size;
const imageSize = selectionState.getImageSize();
expect(imageSize).to.equal(999999999);
});
@@ -428,7 +485,7 @@ describe('Model: selectionState', function () {
archiveExtension: 'zip',
});
const imagePath = selectionState.getImage()?.path;
const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('foo.zip');
});
@@ -439,7 +496,7 @@ describe('Model: selectionState', function () {
archiveExtension: 'xz',
});
const imagePath = selectionState.getImage()?.path;
const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('foo.xz');
});
@@ -450,7 +507,7 @@ describe('Model: selectionState', function () {
archiveExtension: 'gz',
});
const imagePath = selectionState.getImage()?.path;
const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('something.linux-x86-64.gz');
});
@@ -618,12 +675,12 @@ describe('Model: selectionState', function () {
});
it('getImagePath() should return undefined', function () {
const imagePath = selectionState.getImage()?.path;
const imagePath = selectionState.getImagePath();
expect(imagePath).to.be.undefined;
});
it('getImageSize() should return undefined', function () {
const imageSize = selectionState.getImage()?.size;
const imageSize = selectionState.getImageSize();
expect(imageSize).to.be.undefined;
});
@@ -643,12 +700,12 @@ describe('Model: selectionState', function () {
});
it('getImagePath() should return the image path', function () {
const imagePath = selectionState.getImage()?.path;
const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('foo.img');
});
it('getImageSize() should return the image size', function () {
const imageSize = selectionState.getImage()?.size;
const imageSize = selectionState.getImageSize();
expect(imageSize).to.equal(999999999);
});

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