mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-31 07:06:37 +00:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a79db1db6b | ||
![]() |
7aa5db9408 | ||
![]() |
c824a60e5d | ||
![]() |
2a470f5e6c | ||
![]() |
f3123f3cbe | ||
![]() |
391164bf15 | ||
![]() |
7c2c2bc3d6 | ||
![]() |
c2d160f5c7 | ||
![]() |
385bf45883 | ||
![]() |
aa6d526fea | ||
![]() |
c2fc36971c |
4
.gitattributes
vendored
4
.gitattributes
vendored
@ -62,7 +62,3 @@ CODEOWNERS text
|
||||
*.ttf binary diff=hex
|
||||
xz-without-extension binary diff=hex
|
||||
wmic-output.txt binary diff=hex
|
||||
|
||||
# gitsecret
|
||||
*.secret binary
|
||||
.gitsecret/** binary
|
||||
|
2
.github/actions/publish/action.yml
vendored
2
.github/actions/publish/action.yml
vendored
@ -15,7 +15,7 @@ inputs:
|
||||
# Beware that native modules will be built for this version,
|
||||
# which might not be compatible with the one used by pkg (see forge.sidecar.ts)
|
||||
# https://github.com/vercel/pkg-fetch/releases
|
||||
default: '20.x'
|
||||
default: '20.19'
|
||||
VERBOSE:
|
||||
type: string
|
||||
default: 'true'
|
||||
|
13
.github/actions/test/action.yml
vendored
13
.github/actions/test/action.yml
vendored
@ -12,7 +12,7 @@ inputs:
|
||||
# --- custom environment
|
||||
NODE_VERSION:
|
||||
type: string
|
||||
default: '20.10'
|
||||
default: '20.19'
|
||||
VERBOSE:
|
||||
type: string
|
||||
default: 'true'
|
||||
@ -59,6 +59,17 @@ runs:
|
||||
# as the shrinkwrap might have been done on mac/linux, this is ensure the package is there for windows
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
npm i -D winusb-driver-generator
|
||||
|
||||
# need to modifies @yao-pkg/pkg-fetch
|
||||
# expected-shas.json and patches.json files to force use of nodejs v20.11.1 instead of latest minor (v20.19.4 at the time of writing).
|
||||
# this is required for Windows compatibility as 20.15.1 introduced a regression that breaks the flasher on Windows.
|
||||
# As soon as nodejs the fix is backported to node20 and, or node 22, this script can be removed: https://github.com/nodejs/node/pull/55623
|
||||
|
||||
# Add entry to expected-shas.json
|
||||
sed -i 's/}$/,\n "node-v20.11.1-win-x64": "140c377c2c91751832e673cb488724cbd003f01aa237615142cd2907f34fa1a2"\n}/' node_modules/@yao-pkg/pkg-fetch/lib-es5/expected-shas.json
|
||||
|
||||
# Replace any "v20..." key with "v20.11.1" in patches.json (keeps value)
|
||||
sed -i -E 's/"v20[^"]*":/"v20.11.1":/' node_modules/@yao-pkg/pkg-fetch/patches/patches.json
|
||||
fi
|
||||
|
||||
npm run lint
|
||||
|
6
.github/workflows/flowzone.yml
vendored
6
.github/workflows/flowzone.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
||||
{
|
||||
"os": [
|
||||
["ubuntu-22.04"],
|
||||
["windows-2019"],
|
||||
["windows-2022"],
|
||||
["macos-13"],
|
||||
["macos-latest-xlarge"]
|
||||
]
|
||||
@ -31,11 +31,11 @@ jobs:
|
||||
{
|
||||
"os": [
|
||||
["ubuntu-22.04"],
|
||||
["windows-2019"],
|
||||
["windows-2022"],
|
||||
["macos-13"],
|
||||
["macos-latest-xlarge"]
|
||||
]
|
||||
}
|
||||
restrict_custom_actions: false
|
||||
github_prerelease: true
|
||||
cloudflare_website: "etcher"
|
||||
cloudflare_website: 'etcher'
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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,3 +1,49 @@
|
||||
- commits:
|
||||
- subject: "patch: fix ubuntu 24 build and flash issues - bump electron-forge to
|
||||
7.8.1 - bump electron to 37.2.4 - stop producing broken appimage"
|
||||
hash: c824a60e5dc9a78b92679fa8915e3ddad4127c05
|
||||
body: ""
|
||||
footer: {}
|
||||
author: Edwin Joassart
|
||||
nested: []
|
||||
- subject: "patch: fix windows build and flash issues - downgrade flasher's node
|
||||
to 20.11.1 on windows - bump windows GHA runner to 2022 - bump
|
||||
winusb-driver-generator to 2.1.9"
|
||||
hash: 2a470f5e6c864ea161188f1e1a01d4baeba6c310
|
||||
body: ""
|
||||
footer: {}
|
||||
author: Edwin Joassart
|
||||
nested: []
|
||||
- subject: "patch: refactor permission code"
|
||||
hash: f3123f3cbe0159c624412ec2f162d01e079d314e
|
||||
body: ""
|
||||
footer: {}
|
||||
author: Edwin Joassart
|
||||
nested: []
|
||||
version: 2.1.4
|
||||
title: ""
|
||||
date: 2025-07-29T12:17:33.739Z
|
||||
- commits:
|
||||
- subject: Remove stale secrets
|
||||
hash: c2fc36971c9460eac6bd02cfc7bdcabec7b97a6d
|
||||
body: ""
|
||||
footer:
|
||||
change-type: patch
|
||||
author: Anton Belodedenko
|
||||
nested: []
|
||||
version: 2.1.3
|
||||
title: ""
|
||||
date: 2025-05-15T18:09:55.848Z
|
||||
- commits:
|
||||
- subject: "patch: remove analytics"
|
||||
hash: aa6d526fea010d181f49dd81ae3bdaefb8d1938e
|
||||
body: ""
|
||||
footer: {}
|
||||
author: Edwin Joassart
|
||||
nested: []
|
||||
version: 2.1.2
|
||||
title: ""
|
||||
date: 2025-05-08T08:51:44.810Z
|
||||
- commits:
|
||||
- subject: "patch: fix signin windows artifacts"
|
||||
hash: a1e9be2f94629447e02994e52e12c67ec98de831
|
||||
|
17
CHANGELOG.md
17
CHANGELOG.md
@ -3,6 +3,23 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# v2.1.4
|
||||
## (2025-07-29)
|
||||
|
||||
* patch: fix ubuntu 24 build and flash issues - bump electron-forge to 7.8.1 - bump electron to 37.2.4 - stop producing broken appimage [Edwin Joassart]
|
||||
* patch: fix windows build and flash issues - downgrade flasher's node to 20.11.1 on windows - bump windows GHA runner to 2022 - bump winusb-driver-generator to 2.1.9 [Edwin Joassart]
|
||||
* patch: refactor permission code [Edwin Joassart]
|
||||
|
||||
# v2.1.3
|
||||
## (2025-05-15)
|
||||
|
||||
* Remove stale secrets [Anton Belodedenko]
|
||||
|
||||
# v2.1.2
|
||||
## (2025-05-08)
|
||||
|
||||
* patch: remove analytics [Edwin Joassart]
|
||||
|
||||
# v2.1.1
|
||||
## (2025-05-05)
|
||||
|
||||
|
@ -1,10 +1,8 @@
|
||||
Maintaining Etcher
|
||||
==================
|
||||
# Maintaining Etcher
|
||||
|
||||
This document is meant to serve as a guide for maintainers to perform common tasks.
|
||||
|
||||
Releasing
|
||||
---------
|
||||
## Releasing
|
||||
|
||||
### Release Types
|
||||
|
||||
@ -13,16 +11,15 @@ Releasing
|
||||
- **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*.
|
||||
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.
|
||||
|
||||
|
||||
#### Preparation
|
||||
|
||||
- [Prepare the new version](#preparing-a-new-version)
|
||||
- [Generate build artifacts](#generating-binaries) (binaries, archives, etc.)
|
||||
- [Draft a release on GitHub](https://github.com/balena-io/etcher/releases)
|
||||
- Upload build artifacts to GitHub release draft
|
||||
- Upload build artifacts to GitHub release draft
|
||||
|
||||
#### Testing
|
||||
|
||||
@ -35,7 +32,7 @@ Mark the pre-release as final when it is necessary, then distribute the packages
|
||||
- [Post release note to forums](https://forums.balena.io/c/etcher)
|
||||
- [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) 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
|
||||
@ -51,7 +48,6 @@ Make sure to set the analytics tokens when generating production release binarie
|
||||
|
||||
```bash
|
||||
export ANALYTICS_SENTRY_TOKEN="xxxxxx"
|
||||
export ANALYTICS_AMPLITUDE_TOKEN="xxxxxx"
|
||||
```
|
||||
|
||||
#### Linux
|
||||
@ -71,7 +67,6 @@ npm run make
|
||||
|
||||
Our CI will appropriately sign artifacts for macOS and some Windows targets.
|
||||
|
||||
|
||||
### Uploading packages to Cloudfront
|
||||
|
||||
Log in to cloudfront and upload the `rpm` and `deb` files.
|
||||
@ -99,7 +94,6 @@ aws s3api delete-object --bucket <bucket name> --key <file name>
|
||||
|
||||
The Bintray dashboard provides an easy way to delete a version's files.
|
||||
|
||||
|
||||
### Submitting binaries to Symantec
|
||||
|
||||
- [Report a Suspected Erroneous Detection](https://submit.symantec.com/false_positive/standard/)
|
||||
|
@ -1,22 +1,19 @@
|
||||
Manual Testing
|
||||
==============
|
||||
# Manual Testing
|
||||
|
||||
This document describes a high-level script of manual tests to check for. We
|
||||
should aim to replace items on this list with automated Spectron test cases.
|
||||
|
||||
Image Selection
|
||||
---------------
|
||||
## Image Selection
|
||||
|
||||
- [ ] Cancel image selection dialog
|
||||
- [ ] Select an unbootable image (without a partition table), and expect a
|
||||
sensible warning
|
||||
sensible warning
|
||||
- [ ] Attempt to select a ZIP archive with more than one image
|
||||
- [ ] Attempt to select a tar archive (with any compression method)
|
||||
- [ ] Change image selection
|
||||
- [ ] Select a Windows image, and expect a sensible warning
|
||||
|
||||
Drive Selection
|
||||
---------------
|
||||
## Drive Selection
|
||||
|
||||
- [ ] Open the drive selection modal
|
||||
- [ ] Switch drive selection
|
||||
@ -25,16 +22,15 @@ Drive Selection
|
||||
- [ ] Insert a locked SD Card and expect a warning
|
||||
- [ ] Insert a too small drive and expect a warning
|
||||
- [ ] Put an image into a drive and attempt to flash the image to the drive
|
||||
that contains it
|
||||
that contains it
|
||||
- [ ] Attempt to flash a compressed image (for which we can get the
|
||||
uncompressed size) into a drive that is big enough to hold the compressed
|
||||
image, but not big enough to hold the uncompressed version
|
||||
uncompressed size) into a drive that is big enough to hold the compressed
|
||||
image, but not big enough to hold the uncompressed version
|
||||
- [ ] Enable "Unsafe Mode" and attempt to select a system drive
|
||||
- [ ] Enable "Unsafe Mode", and if there is only one system drive (and no
|
||||
removable ones), don't expect autoselection
|
||||
removable ones), don't expect autoselection
|
||||
|
||||
Image Support
|
||||
-------------
|
||||
## Image Support
|
||||
|
||||
Run the following tests with and without validation enabled:
|
||||
|
||||
@ -51,18 +47,17 @@ Run the following tests with and without validation enabled:
|
||||
- [ ] Flash an archive image containing a blockmap file
|
||||
- [ ] Flash an archive image containing a manifest metadata file
|
||||
|
||||
Flashing Process
|
||||
----------------
|
||||
## Flashing Process
|
||||
|
||||
- [ ] Unplug the drive during flash or validation
|
||||
- [ ] Click "Flash", cancel elevation dialog, and click "Flash" again
|
||||
- [ ] Start flashing an image, try to close Etcher, cancel the application
|
||||
close warning dialog, and check that Etcher continues to flash the image
|
||||
close warning dialog, and check that Etcher continues to flash the image
|
||||
|
||||
### Child Writer
|
||||
|
||||
- [ ] Kill the child writer process (i.e. with `SIGINT` or `SIGKILL`), and
|
||||
check that the UI reacts appropriately
|
||||
check that the UI reacts appropriately
|
||||
- [ ] Close the application while flashing using the window manager close icon
|
||||
- [ ] Close the application while flashing using the OS keyboard shortcut
|
||||
- [ ] Close the application from the terminal using Ctrl-C while flashing
|
||||
@ -72,11 +67,10 @@ In all these cases, the child writer process should not remain alive. Note that
|
||||
in some systems you need to open your process monitor tool of choice with extra
|
||||
permissions to see the elevated child writer process.
|
||||
|
||||
GUI
|
||||
----
|
||||
## GUI
|
||||
|
||||
- [ ] Close application from the terminal using Ctrl-C while the application is
|
||||
idle
|
||||
idle
|
||||
- [ ] Click footer links that take you to an external website
|
||||
- [ ] Attempt to change image or drive selection while flashing
|
||||
- [ ] Go to the settings page while flashing and come back
|
||||
@ -85,31 +79,20 @@ GUI
|
||||
- [ ] Minimize the application
|
||||
- [ ] Start the application given no internet connection
|
||||
|
||||
Success Banner
|
||||
--------------
|
||||
## Success Banner
|
||||
|
||||
- [ ] Click an external link on the success banner (with and without internet
|
||||
connection)
|
||||
connection)
|
||||
|
||||
Elevation Prompt
|
||||
----------------
|
||||
## Elevation Prompt
|
||||
|
||||
- [ ] Flash an image as `root`/administrator
|
||||
- [ ] Reject elevation prompt
|
||||
- [ ] Put incorrect elevation prompt password
|
||||
- [ ] Unplug the drive during elevation
|
||||
|
||||
Unmounting
|
||||
----------
|
||||
## Unmounting
|
||||
|
||||
- [ ] Disable unmounting and flash an image
|
||||
- [ ] Flash an image with a file system that is readable by the host OS, and
|
||||
check that is unmounted correctly
|
||||
|
||||
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**
|
||||
check that is unmounted correctly
|
||||
|
@ -4,7 +4,7 @@ import { MakerZIP } from '@electron-forge/maker-zip';
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||
import { MakerAppImage } from '@reforged/maker-appimage';
|
||||
// import { MakerAppImage } from '@reforged/maker-appimage';
|
||||
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
||||
import { WebpackPlugin } from '@electron-forge/plugin-webpack';
|
||||
import { exec } from 'child_process';
|
||||
@ -86,12 +86,12 @@ const config: ForgeConfig = {
|
||||
},
|
||||
},
|
||||
}),
|
||||
new MakerAppImage({
|
||||
options: {
|
||||
icon: './assets/icon.png',
|
||||
categories: ['Utility'],
|
||||
},
|
||||
}),
|
||||
// new MakerAppImage({
|
||||
// options: {
|
||||
// icon: './assets/icon.png',
|
||||
// categories: ['Utility'],
|
||||
// },
|
||||
// }),
|
||||
new MakerRpm({
|
||||
options: {
|
||||
icon: './assets/icon.png',
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { PluginBase } from '@electron-forge/plugin-base';
|
||||
import type {
|
||||
ForgeHookMap,
|
||||
ForgeMultiHookMap,
|
||||
ResolvedForgeConfig,
|
||||
} from '@electron-forge/shared-types';
|
||||
import { WebpackPlugin } from '@electron-forge/plugin-webpack';
|
||||
@ -10,9 +10,9 @@ import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as d from 'debug';
|
||||
import debug from 'debug';
|
||||
|
||||
const debug = d('sidecar');
|
||||
const log = debug('sidecar');
|
||||
|
||||
function isStartScrpt(): boolean {
|
||||
return process.env.npm_lifecycle_event === 'start';
|
||||
@ -40,7 +40,7 @@ function addWebpackDefine(
|
||||
: // otherwise point relative to the resources folder of the bundled app
|
||||
binName;
|
||||
|
||||
debug(`define '${defineName}'='${value}'`);
|
||||
log(`define '${defineName}'='${value}'`);
|
||||
|
||||
mainConfig.plugins.push(
|
||||
new DefinePlugin({
|
||||
@ -98,7 +98,7 @@ function build(
|
||||
});
|
||||
|
||||
commands.forEach(([cmd, args, opt]) => {
|
||||
debug('running command:', cmd, args.join(' '));
|
||||
log('running command:', cmd, args.join(' '));
|
||||
execFileSync(cmd, args, { shell: true, stdio: 'inherit', ...opt });
|
||||
});
|
||||
}
|
||||
@ -119,7 +119,7 @@ function copyArtifact(
|
||||
// buildPath points to appPath, which is inside resources dir which is the one we actually want
|
||||
const resourcesPath = path.dirname(buildPath);
|
||||
const dest = path.resolve(resourcesPath, path.basename(binPath));
|
||||
debug(`copying '${binPath}' to '${dest}'`);
|
||||
log(`copying '${binPath}' to '${dest}'`);
|
||||
fs.copyFileSync(binPath, dest);
|
||||
}
|
||||
|
||||
@ -129,10 +129,10 @@ export class SidecarPlugin extends PluginBase<void> {
|
||||
constructor() {
|
||||
super();
|
||||
this.getHooks = this.getHooks.bind(this);
|
||||
debug('isStartScript:', isStartScrpt());
|
||||
log('isStartScript:', isStartScrpt());
|
||||
}
|
||||
|
||||
getHooks(): ForgeHookMap {
|
||||
getHooks(): ForgeMultiHookMap {
|
||||
const DEFINE_NAME = 'ETCHER_UTIL_BIN_PATH';
|
||||
const BASE_DIR = path.join('out', 'sidecar');
|
||||
const SRC_DIR = path.join(BASE_DIR, 'src');
|
||||
@ -141,11 +141,11 @@ export class SidecarPlugin extends PluginBase<void> {
|
||||
|
||||
return {
|
||||
resolveForgeConfig: async (currentConfig) => {
|
||||
debug('resolveForgeConfig');
|
||||
log('resolveForgeConfig');
|
||||
return addWebpackDefine(currentConfig, DEFINE_NAME, BIN_DIR, BIN_NAME);
|
||||
},
|
||||
generateAssets: async (_config, platform, arch) => {
|
||||
debug('generateAssets', { platform, arch });
|
||||
log('generateAssets', { platform, arch });
|
||||
build(SRC_DIR, arch, BIN_DIR, BIN_NAME);
|
||||
},
|
||||
packageAfterCopy: async (
|
||||
@ -155,7 +155,7 @@ export class SidecarPlugin extends PluginBase<void> {
|
||||
platform,
|
||||
arch,
|
||||
) => {
|
||||
debug('packageAfterCopy', {
|
||||
log('packageAfterCopy', {
|
||||
buildPath,
|
||||
electronVersion,
|
||||
platform,
|
||||
|
@ -64,9 +64,6 @@ store.dispatch({
|
||||
data: uuidV4(),
|
||||
});
|
||||
|
||||
const applicationSessionUuid = store.getState().toJS().applicationSessionUuid;
|
||||
const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid;
|
||||
|
||||
console.log(outdent`
|
||||
${outdent}
|
||||
_____ _ _
|
||||
@ -82,13 +79,6 @@ console.log(outdent`
|
||||
Version = ${packageJSON.version}, Type = ${packageJSON.packageType}
|
||||
`);
|
||||
|
||||
const currentVersion = packageJSON.version;
|
||||
|
||||
analytics.logEvent('Application start', {
|
||||
packageType: packageJSON.packageType,
|
||||
version: currentVersion,
|
||||
});
|
||||
|
||||
const debouncedLog = debounce(console.log, 1000, { maxWait: 1000 });
|
||||
|
||||
function pluralize(word: string, quantity: number) {
|
||||
@ -172,9 +162,6 @@ analytics.initAnalytics();
|
||||
|
||||
window.addEventListener('beforeunload', async (event) => {
|
||||
if (!flashState.isFlashing() || popupExists) {
|
||||
analytics.logEvent('Close application', {
|
||||
isFlashing: flashState.isFlashing(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -184,8 +171,6 @@ window.addEventListener('beforeunload', async (event) => {
|
||||
// Don't open any more popups
|
||||
popupExists = true;
|
||||
|
||||
analytics.logEvent('Close attempt while flashing');
|
||||
|
||||
try {
|
||||
const confirmed = await osDialog.showWarning({
|
||||
confirmationLabel: i18next.t('yesExit'),
|
||||
@ -194,19 +179,11 @@ window.addEventListener('beforeunload', async (event) => {
|
||||
description: messages.warning.exitWhileFlashing(),
|
||||
});
|
||||
if (confirmed) {
|
||||
analytics.logEvent('Close confirmed while flashing', {
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
});
|
||||
|
||||
// This circumvents the 'beforeunload' event unlike
|
||||
// remote.app.quit() which does not.
|
||||
remote.process.exit(EXIT_CODES.SUCCESS);
|
||||
}
|
||||
|
||||
analytics.logEvent('Close rejected while flashing', {
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
});
|
||||
popupExists = false;
|
||||
} catch (error: any) {
|
||||
exceptionReporter.report(error);
|
||||
|
@ -36,7 +36,7 @@ import prettyBytes from 'pretty-bytes';
|
||||
import { getDrives, hasAvailableDrives } from '../../models/available-drives';
|
||||
import { getImage, isDriveSelected } from '../../models/selection-state';
|
||||
import { store } from '../../models/store';
|
||||
import { logEvent, logException } from '../../modules/analytics';
|
||||
import { logException } from '../../modules/analytics';
|
||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
||||
import type { GenericTableProps } from '../../styled-components';
|
||||
import { Alert, Modal, Table } from '../../styled-components';
|
||||
@ -355,9 +355,6 @@ export class DriveSelector extends React.Component<
|
||||
|
||||
private installMissingDrivers(drive: DriverlessDrive) {
|
||||
if (drive.link) {
|
||||
logEvent('Open driver link modal', {
|
||||
url: drive.link,
|
||||
});
|
||||
this.setState({ missingDriversModal: { drive } });
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,6 @@ import * as flashState from '../../models/flash-state';
|
||||
import * as selectionState from '../../models/selection-state';
|
||||
import * as settings from '../../models/settings';
|
||||
import { Actions, store } from '../../models/store';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
import { FlashAnother } from '../flash-another/flash-another';
|
||||
import type { FlashError } from '../flash-results/flash-results';
|
||||
import { FlashResults } from '../flash-results/flash-results';
|
||||
@ -30,7 +29,6 @@ import { SafeWebview } from '../safe-webview/safe-webview';
|
||||
|
||||
function restart(goToMain: () => void) {
|
||||
selectionState.deselectAllDrives();
|
||||
analytics.logEvent('Restart');
|
||||
|
||||
// Reset the flashing workflow uuid
|
||||
store.dispatch({
|
||||
|
@ -21,7 +21,6 @@ import * as React from 'react';
|
||||
|
||||
import * as packageJSON from '../../../../../package.json';
|
||||
import * as settings from '../../models/settings';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
|
||||
/**
|
||||
* @summary Electron session identifier
|
||||
@ -196,10 +195,6 @@ 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,
|
||||
});
|
||||
this.setState({
|
||||
shouldShow: event.statusCode === HTTP_OK,
|
||||
});
|
||||
|
@ -21,7 +21,6 @@ import { Box, Checkbox, Flex, Txt } from 'rendition';
|
||||
|
||||
import { version, packageType } from '../../../../../package.json';
|
||||
import * as settings from '../../models/settings';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
||||
import { Modal } from '../../styled-components';
|
||||
import * as i18next from 'i18next';
|
||||
@ -89,7 +88,6 @@ export function SettingsModal({ toggleModal }: SettingsModalProps) {
|
||||
|
||||
const toggleSetting = async (setting: string) => {
|
||||
const value = currentSettings[setting];
|
||||
analytics.logEvent('Toggle setting', { setting, value });
|
||||
await settings.set(setting, !value);
|
||||
setCurrentSettings({
|
||||
...currentSettings,
|
||||
|
@ -392,10 +392,6 @@ export class SourceSelector extends React.Component<
|
||||
}
|
||||
|
||||
private reselectSource() {
|
||||
analytics.logEvent('Reselect image', {
|
||||
previousImage: selectionState.getImage(),
|
||||
});
|
||||
|
||||
selectionState.deselectImage();
|
||||
this.props.hideAnalyticsAlert();
|
||||
}
|
||||
@ -426,7 +422,6 @@ export class SourceSelector extends React.Component<
|
||||
}
|
||||
|
||||
if (supportedFormats.looksLikeWindowsImage(selected)) {
|
||||
analytics.logEvent('Possibly Windows image', { image: selected });
|
||||
this.setState({
|
||||
warning: {
|
||||
message: messages.warning.looksLikeWindowsImage(),
|
||||
@ -450,7 +445,6 @@ export class SourceSelector extends React.Component<
|
||||
metadata = await requestMetadata({ selected, SourceType, auth });
|
||||
|
||||
if (!metadata?.hasMBR && this.state.warning === null) {
|
||||
analytics.logEvent('Missing partition table', { metadata });
|
||||
this.setState({
|
||||
warning: {
|
||||
message: messages.warning.missingPartitionTable(),
|
||||
@ -468,7 +462,6 @@ export class SourceSelector extends React.Component<
|
||||
}
|
||||
} else {
|
||||
if (selected.partitionTableType === null) {
|
||||
analytics.logEvent('Missing partition table', { selected });
|
||||
this.setState({
|
||||
warning: {
|
||||
message: messages.warning.driveMissingPartitionTable(),
|
||||
@ -490,15 +483,6 @@ export class SourceSelector extends React.Component<
|
||||
metadata.auth = auth;
|
||||
metadata.SourceType = SourceType;
|
||||
selectionState.selectSource(metadata);
|
||||
analytics.logEvent('Select image', {
|
||||
// An easy way so we can quickly identify if we're making use of
|
||||
// certain features without printing pages of text to DevTools.
|
||||
image: {
|
||||
...metadata,
|
||||
logo: Boolean(metadata.logo),
|
||||
blockMap: Boolean(metadata.blockMap),
|
||||
},
|
||||
});
|
||||
}
|
||||
})(),
|
||||
};
|
||||
@ -519,11 +503,9 @@ export class SourceSelector extends React.Component<
|
||||
analytics.logException(error);
|
||||
return;
|
||||
}
|
||||
analytics.logEvent(title, { path: sourcePath });
|
||||
}
|
||||
|
||||
private async openImageSelector() {
|
||||
analytics.logEvent('Open image selector');
|
||||
this.setState({ imageSelectorOpen: true });
|
||||
|
||||
try {
|
||||
@ -531,7 +513,6 @@ export class SourceSelector extends React.Component<
|
||||
// Avoid analytics and selection state changes
|
||||
// if no file was resolved from the dialog.
|
||||
if (!imagePath) {
|
||||
analytics.logEvent('Image selector closed');
|
||||
return;
|
||||
}
|
||||
await this.selectSource(imagePath, 'File').promise;
|
||||
@ -550,16 +531,12 @@ export class SourceSelector extends React.Component<
|
||||
}
|
||||
|
||||
private openURLSelector() {
|
||||
analytics.logEvent('Open image URL selector');
|
||||
|
||||
this.setState({
|
||||
showURLSelector: true,
|
||||
});
|
||||
}
|
||||
|
||||
private openDriveSelector() {
|
||||
analytics.logEvent('Open drive selector');
|
||||
|
||||
this.setState({
|
||||
showDriveSelector: true,
|
||||
});
|
||||
@ -576,10 +553,6 @@ export class SourceSelector extends React.Component<
|
||||
}
|
||||
|
||||
private showSelectedImageDetails() {
|
||||
analytics.logEvent('Show selected image tooltip', {
|
||||
imagePath: selectionState.getImage()?.path,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
showImageDetails: true,
|
||||
});
|
||||
@ -759,9 +732,7 @@ export class SourceSelector extends React.Component<
|
||||
done={async (imageURL: string, auth?: Authentication) => {
|
||||
// Avoid analytics and selection state changes
|
||||
// if no file was resolved from the dialog.
|
||||
if (!imageURL) {
|
||||
analytics.logEvent('URL selector closed');
|
||||
} else {
|
||||
if (imageURL) {
|
||||
let promise;
|
||||
({ promise, cancel: cancelURLSelection } = this.selectSource(
|
||||
imageURL,
|
||||
|
@ -20,7 +20,6 @@ import { Flex, Txt } from 'rendition';
|
||||
import type { DriveSelectorProps } from '../drive-selector/drive-selector';
|
||||
import { DriveSelector } from '../drive-selector/drive-selector';
|
||||
import {
|
||||
isDriveSelected,
|
||||
getImage,
|
||||
getSelectedDrives,
|
||||
deselectDrive,
|
||||
@ -28,7 +27,6 @@ import {
|
||||
deselectAllDrives,
|
||||
} from '../../models/selection-state';
|
||||
import { observe } from '../../models/store';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
import { TargetSelectorButton } from './target-selector-button';
|
||||
|
||||
import TgtSvg from '../../../assets/tgt.svg';
|
||||
@ -77,21 +75,10 @@ export const selectAllTargets = (modalTargets: DrivelistDrive[]) => {
|
||||
);
|
||||
// deselect drives
|
||||
deselected.forEach((drive) => {
|
||||
analytics.logEvent('Toggle drive', {
|
||||
drive,
|
||||
previouslySelected: true,
|
||||
});
|
||||
deselectDrive(drive.device);
|
||||
});
|
||||
// select drives
|
||||
modalTargets.forEach((drive) => {
|
||||
// Don't send events for drives that were already selected
|
||||
if (!isDriveSelected(drive.device)) {
|
||||
analytics.logEvent('Toggle drive', {
|
||||
drive,
|
||||
previouslySelected: false,
|
||||
});
|
||||
}
|
||||
selectDrive(drive.device);
|
||||
});
|
||||
};
|
||||
@ -142,7 +129,6 @@ export const TargetSelector = ({
|
||||
hideAnalyticsAlert();
|
||||
}}
|
||||
reselectDrive={() => {
|
||||
analytics.logEvent('Reselect drive');
|
||||
setShowTargetSelectorModal(true);
|
||||
}}
|
||||
flashing={flashing}
|
||||
|
@ -133,8 +133,7 @@ const translation = {
|
||||
flashCompleted: 'Flash Completed!',
|
||||
},
|
||||
settings: {
|
||||
errorReporting:
|
||||
'Anonymously report errors and usage statistics to balena.io',
|
||||
errorReporting: 'Anonymously report errors to balena.io',
|
||||
autoUpdate: 'Auto-updates enabled',
|
||||
settings: 'Settings',
|
||||
systemInformation: 'System Information',
|
||||
|
@ -15,12 +15,8 @@
|
||||
*/
|
||||
|
||||
import { findLastIndex, once } from 'lodash';
|
||||
import type { Client } from 'analytics-client';
|
||||
import { createClient, createNoopClient } from 'analytics-client';
|
||||
import * as SentryRenderer from '@sentry/electron/renderer';
|
||||
import * as settings from '../models/settings';
|
||||
import { store } from '../models/store';
|
||||
import { version } from '../../../../package.json';
|
||||
|
||||
type AnalyticsPayload = _.Dictionary<any>;
|
||||
|
||||
@ -115,7 +111,6 @@ export const anonymizeAnalyticsPayload = (
|
||||
return data;
|
||||
};
|
||||
|
||||
let analyticsClient: Client;
|
||||
/**
|
||||
* @summary Init analytics configurations
|
||||
*/
|
||||
@ -127,95 +122,8 @@ export const initAnalytics = once(() => {
|
||||
beforeSend: anonymizeSentryData,
|
||||
debug: process.env.ETCHER_SENTRY_DEBUG === 'true',
|
||||
});
|
||||
|
||||
const projectName =
|
||||
settings.getSync('analyticsAmplitudeToken') || process.env.AMPLITUDE_TOKEN;
|
||||
|
||||
const clientConfig = {
|
||||
projectName,
|
||||
endpoint: 'data.balena-cloud.com',
|
||||
componentName: 'etcher',
|
||||
componentVersion: version,
|
||||
};
|
||||
analyticsClient = projectName
|
||||
? createClient(clientConfig)
|
||||
: createNoopClient();
|
||||
});
|
||||
|
||||
const getCircularReplacer = () => {
|
||||
const seen = new WeakSet();
|
||||
return (_key: any, value: any) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
};
|
||||
|
||||
function flattenObject(obj: any) {
|
||||
const toReturn: AnalyticsPayload = {};
|
||||
|
||||
for (const i in obj) {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj[i])) {
|
||||
toReturn[i] = obj[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof obj[i] === 'object' && obj[i] !== null) {
|
||||
const flatObject = flattenObject(obj[i]);
|
||||
for (const x in flatObject) {
|
||||
if (!Object.prototype.hasOwnProperty.call(flatObject, x)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toReturn[i.toLowerCase() + '.' + x.toLowerCase()] = flatObject[x];
|
||||
}
|
||||
} else {
|
||||
toReturn[i] = obj[i];
|
||||
}
|
||||
}
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
function formatEvent(data: any): AnalyticsPayload {
|
||||
const event = JSON.parse(JSON.stringify(data, getCircularReplacer()));
|
||||
return anonymizeAnalyticsPayload(flattenObject(event));
|
||||
}
|
||||
|
||||
function reportAnalytics(message: string, data: AnalyticsPayload = {}) {
|
||||
const { applicationSessionUuid, flashingWorkflowUuid } = store
|
||||
.getState()
|
||||
.toJS();
|
||||
|
||||
const event = formatEvent({
|
||||
...data,
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
});
|
||||
analyticsClient.track(message, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Log an event
|
||||
*
|
||||
* @description
|
||||
* This function sends the debug message to product analytics services.
|
||||
*/
|
||||
export async function logEvent(message: string, data: AnalyticsPayload = {}) {
|
||||
const shouldReportAnalytics = await settings.get('errorReporting');
|
||||
if (shouldReportAnalytics) {
|
||||
initAnalytics();
|
||||
reportAnalytics(message, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Log an exception
|
||||
*
|
||||
|
@ -98,7 +98,8 @@ async function connectToChildProcess(
|
||||
): Promise<ChildApi | { failed: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// TODO: default to IPC connections https://github.com/websockets/ws/blob/master/doc/ws.md#ipc-connections
|
||||
// TOOD: use the path as cheap authentication
|
||||
// TODO: use the path as cheap authentication
|
||||
|
||||
console.log(etcherServerId);
|
||||
|
||||
const url = `ws://${etcherServerAddress}:${etcherServerPort}`;
|
||||
@ -196,9 +197,9 @@ async function spawnChildAndConnect({
|
||||
`etcher-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
console.log(
|
||||
`Spawning ${
|
||||
`Starting ${
|
||||
withPrivileges ? 'priviledged' : 'unpriviledged'
|
||||
} sidecar on port ${etcherServerPort}`,
|
||||
} flasher sidecar on port ${etcherServerPort}`,
|
||||
);
|
||||
|
||||
// spawn the child process, which will act as the ws server
|
||||
@ -212,11 +213,11 @@ async function spawnChildAndConnect({
|
||||
etcherServerPort,
|
||||
);
|
||||
if (result.cancelled) {
|
||||
throw new Error('Spwaning the child process was cancelled');
|
||||
throw new Error('Starting flasher sidecar process was cancelled');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error spawning child process', error);
|
||||
throw new Error('Error spawning the child process');
|
||||
console.error('Error starting flasher sidecar process', error);
|
||||
throw new Error('Error starting flasher sidecar process');
|
||||
}
|
||||
}
|
||||
|
||||
@ -232,7 +233,7 @@ async function spawnChildAndConnect({
|
||||
if (failed) {
|
||||
retry++;
|
||||
console.log(
|
||||
`Retrying to connect to child process in ${connectionRetryDelay}... ${retry} / ${connectionRetryAttempts}`,
|
||||
`Connection to sidecar flasher process attempt ${retry} / ${connectionRetryAttempts} failed; retrying in ${connectionRetryDelay}ms...`,
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, connectionRetryDelay),
|
||||
@ -241,10 +242,11 @@ async function spawnChildAndConnect({
|
||||
}
|
||||
return { failed, emit, registerHandler };
|
||||
}
|
||||
throw new Error('Connection to etcher-util timed out');
|
||||
// TODO: raised an error to the user if we reach this point
|
||||
throw new Error('Connection to sidecar flasher process timed out');
|
||||
} catch (error) {
|
||||
console.error('Error connecting to child process', error);
|
||||
throw new Error('Connection to etcher-util failed');
|
||||
console.error('Error connecting to sidecar flasher process process', error);
|
||||
throw new Error('Connection to sidecar flasher process failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -20,44 +20,11 @@ import type { Dictionary } from 'lodash';
|
||||
import * as errors from '../../../shared/errors';
|
||||
import type { SourceMetadata } from '../../../shared/typings/source-selector';
|
||||
import * as flashState from '../models/flash-state';
|
||||
import * as selectionState from '../models/selection-state';
|
||||
import * as settings from '../models/settings';
|
||||
import * as analytics from '../modules/analytics';
|
||||
import * as windowProgress from '../os/window-progress';
|
||||
import { spawnChildAndConnect } from './api';
|
||||
|
||||
/**
|
||||
* @summary Handle a flash error and log it to analytics
|
||||
*/
|
||||
function handleErrorLogging(
|
||||
error: Error & { code: string },
|
||||
analyticsData: any,
|
||||
) {
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
};
|
||||
|
||||
if (error.code === 'EVALIDATION') {
|
||||
analytics.logEvent('Validation error', eventData);
|
||||
} else if (error.code === 'EUNPLUGGED') {
|
||||
analytics.logEvent('Drive unplugged', eventData);
|
||||
} else if (error.code === 'EIO') {
|
||||
analytics.logEvent('Input/output error', eventData);
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
analytics.logEvent('Out of space', eventData);
|
||||
} else if (error.code === 'ECHILDDIED') {
|
||||
analytics.logEvent('Child died unexpectedly', eventData);
|
||||
} else {
|
||||
analytics.logEvent('Flash error', {
|
||||
...eventData,
|
||||
error: errors.toJSON(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let cancelEmitter: (type: string) => void | undefined;
|
||||
|
||||
interface FlashResults {
|
||||
skip?: boolean;
|
||||
cancelled?: boolean;
|
||||
@ -88,14 +55,6 @@ async function performWrite(
|
||||
|
||||
const flashResults: FlashResults = {};
|
||||
|
||||
const analyticsData = {
|
||||
image,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
};
|
||||
|
||||
const onFail = ({ device, error }: { device: any; error: any }) => {
|
||||
console.log('fail event');
|
||||
console.log(device);
|
||||
@ -103,7 +62,6 @@ async function performWrite(
|
||||
if (device.devicePath) {
|
||||
flashState.addFailedDeviceError({ device, error });
|
||||
}
|
||||
handleErrorLogging(error, analyticsData);
|
||||
finish();
|
||||
};
|
||||
|
||||
@ -195,17 +153,6 @@ export async function flash(
|
||||
drives.map((d) => d.devicePath).filter((p) => p != null) as string[],
|
||||
);
|
||||
|
||||
const analyticsData = {
|
||||
image,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
status: 'started',
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
};
|
||||
|
||||
analytics.logEvent('Flash', analyticsData);
|
||||
|
||||
// start api and call the flasher
|
||||
try {
|
||||
const result = await write(image, drives, flashState.setProgressState);
|
||||
@ -220,39 +167,10 @@ export async function flash(
|
||||
|
||||
windowProgress.clear();
|
||||
|
||||
const { results = {} } = flashState.getFlashResults();
|
||||
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
errors: results.errors,
|
||||
devices: results.devices,
|
||||
status: 'failed',
|
||||
error,
|
||||
};
|
||||
analytics.logEvent('Write failed', eventData);
|
||||
throw error;
|
||||
}
|
||||
|
||||
windowProgress.clear();
|
||||
|
||||
if (flashState.wasLastFlashCancelled()) {
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
status: 'cancel',
|
||||
};
|
||||
analytics.logEvent('Elevation cancelled', eventData);
|
||||
} else {
|
||||
const { results = {} } = flashState.getFlashResults();
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
errors: results.errors,
|
||||
devices: results.devices,
|
||||
status: 'finished',
|
||||
bytesWritten: results.bytesWritten,
|
||||
sourceMetadata: results.sourceMetadata,
|
||||
};
|
||||
analytics.logEvent('Done', eventData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -261,16 +179,6 @@ export async function flash(
|
||||
*/
|
||||
export async function cancel(type: string) {
|
||||
const status = type.toLowerCase();
|
||||
const drives = selectionState.getSelectedDevices();
|
||||
const analyticsData = {
|
||||
image: selectionState.getImage()?.path,
|
||||
drives,
|
||||
driveCount: drives.length,
|
||||
uuid: flashState.getFlashUuid(),
|
||||
flashInstanceUuid: flashState.getFlashUuid(),
|
||||
status,
|
||||
};
|
||||
analytics.logEvent('Cancel', analyticsData);
|
||||
|
||||
if (cancelEmitter) {
|
||||
cancelEmitter(status);
|
||||
|
@ -34,8 +34,6 @@ export function fromFlashState({
|
||||
status: string;
|
||||
position?: string;
|
||||
} {
|
||||
console.log(i18next.t('progress.starting'));
|
||||
|
||||
if (type === undefined) {
|
||||
return { status: i18next.t('progress.starting') };
|
||||
} else if (type === 'decompressing') {
|
||||
|
@ -16,7 +16,6 @@
|
||||
|
||||
import * as electron from 'electron';
|
||||
import * as settings from '../../../models/settings';
|
||||
import { logEvent } from '../../../modules/analytics';
|
||||
|
||||
/**
|
||||
* @summary Open an external resource
|
||||
@ -27,8 +26,6 @@ export async function open(url: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
logEvent('Open external link', { url });
|
||||
|
||||
if (url) {
|
||||
electron.shell.openExternal(url);
|
||||
}
|
||||
|
@ -198,9 +198,7 @@ export class FlashStep extends React.PureComponent<
|
||||
private handleFlashErrorResponse(shouldRetry: boolean) {
|
||||
this.setState({ errorMessage: '' });
|
||||
flashState.resetState();
|
||||
if (shouldRetry) {
|
||||
analytics.logEvent('Restart after failure');
|
||||
} else {
|
||||
if (!shouldRetry) {
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
|
@ -123,7 +123,6 @@ const initSentryMain = once(() => {
|
||||
beforeSend: anonymizeSentryData,
|
||||
debug: process.env.ETCHER_SENTRY_DEBUG === 'true',
|
||||
});
|
||||
console.log(SentryMain.getCurrentScope());
|
||||
});
|
||||
|
||||
const sourceSelectorReady = new Promise((resolve) => {
|
||||
|
@ -14,16 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* This is convoluted and needlessly complex. It should be simplified and modernized.
|
||||
* The environment variable setting and escaping should be greatly simplified by letting {linux|catalina}-sudo handle that.
|
||||
* We shouldn't need to write a script to a file and then execute it. We should be able to forwatd the command to the sudo code directly.
|
||||
*/
|
||||
|
||||
import { spawn, exec } from 'child_process';
|
||||
import { withTmpFile } from 'etcher-sdk/build/tmp';
|
||||
import { promises as fs } from 'fs';
|
||||
import { promisify } from 'util';
|
||||
import * as _ from 'lodash';
|
||||
import * as os from 'os';
|
||||
@ -41,6 +32,32 @@ const execAsync = promisify(exec);
|
||||
*/
|
||||
const UNIX_SUPERUSER_USER_ID = 0;
|
||||
|
||||
// Augment the command to pass the environment variables as args
|
||||
// This is required because both windows and linux sudo commands strips the environment
|
||||
// variables when running the elevated command, so we need to pass them as arguments
|
||||
function commandWithEnv(
|
||||
command: string[],
|
||||
env: _.Dictionary<string | undefined>,
|
||||
): string[] {
|
||||
const envFilter: string[] = [
|
||||
'ETCHER_SERVER_ADDRESS',
|
||||
'ETCHER_SERVER_PORT',
|
||||
'ETCHER_SERVER_ID',
|
||||
'ETCHER_NO_SPAWN_UTIL',
|
||||
'ETCHER_TERMINATE_TIMEOUT',
|
||||
'UV_THREADPOOL_SIZE',
|
||||
];
|
||||
|
||||
return [
|
||||
command[0],
|
||||
...command.slice(1),
|
||||
...Object.keys(env)
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(env, key))
|
||||
.filter((key) => envFilter.includes(key))
|
||||
.map((key) => `--${key}=${env[key]}`),
|
||||
];
|
||||
}
|
||||
|
||||
export async function isElevated(): Promise<boolean> {
|
||||
if (os.platform() === 'win32') {
|
||||
// `fltmc` is available on WinPE, XP, Vista, 7, 8, and 10
|
||||
@ -66,80 +83,6 @@ export function isElevatedUnixSync(): boolean {
|
||||
return process.geteuid!() === UNIX_SUPERUSER_USER_ID;
|
||||
}
|
||||
|
||||
function escapeSh(value: any): string {
|
||||
// Make sure it's a string
|
||||
// Replace ' -> '\'' (closing quote, escaped quote, opening quote)
|
||||
// Surround with quotes
|
||||
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function escapeParamCmd(value: any): string {
|
||||
// Make sure it's a string
|
||||
// Escape " -> \"
|
||||
// Surround with double quotes
|
||||
return `"${String(value).replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function setEnvVarSh(value: any, name: string): string {
|
||||
return `export ${name}=${escapeSh(value)}`;
|
||||
}
|
||||
|
||||
function setEnvVarCmd(value: any, name: string): string {
|
||||
return `set "${name}=${String(value)}"`;
|
||||
}
|
||||
|
||||
// Exported for tests
|
||||
export function createLaunchScript(
|
||||
command: string,
|
||||
argv: string[],
|
||||
environment: _.Dictionary<string | undefined>,
|
||||
): string {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const lines = [];
|
||||
if (isWindows) {
|
||||
// Switch to utf8
|
||||
lines.push('chcp 65001');
|
||||
}
|
||||
const [setEnvVarFn, escapeFn] = isWindows
|
||||
? [setEnvVarCmd, escapeParamCmd]
|
||||
: [setEnvVarSh, escapeSh];
|
||||
lines.push(..._.map(environment, setEnvVarFn));
|
||||
lines.push([command, ...argv].map(escapeFn).join(' '));
|
||||
return lines.join(os.EOL);
|
||||
}
|
||||
|
||||
async function elevateScriptWindows(
|
||||
path: string,
|
||||
name: string,
|
||||
env: any,
|
||||
): Promise<{ cancelled: false }> {
|
||||
// '&' needs to be escaped here (but not when written to a .cmd file)
|
||||
const cmd = ['cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&')].join(' ');
|
||||
await winSudo(cmd, name, env);
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
async function elevateScriptUnix(
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const cmd = ['bash', escapeSh(path)].join(' ');
|
||||
await linuxSudo(cmd, { name });
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
async function elevateScriptCatalina(
|
||||
path: string,
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const cmd = ['bash', escapeSh(path)].join(' ');
|
||||
try {
|
||||
const { cancelled } = await darwinSudo(cmd);
|
||||
return { cancelled };
|
||||
} catch (error: any) {
|
||||
throw errors.createError({ title: error.stderr });
|
||||
}
|
||||
}
|
||||
|
||||
export async function elevateCommand(
|
||||
command: string[],
|
||||
options: {
|
||||
@ -147,66 +90,60 @@ export async function elevateCommand(
|
||||
applicationName: string;
|
||||
},
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
// if we're running with elevated privileges, we can just spawn the command
|
||||
if (await isElevated()) {
|
||||
spawn(command[0], command.slice(1), {
|
||||
env: options.env,
|
||||
});
|
||||
return { cancelled: false };
|
||||
}
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const launchScript = createLaunchScript(
|
||||
command[0],
|
||||
command.slice(1),
|
||||
options.env,
|
||||
);
|
||||
return await withTmpFile(
|
||||
{
|
||||
keepOpen: false,
|
||||
prefix: 'balena-etcher-electron-',
|
||||
postfix: '.cmd',
|
||||
},
|
||||
async ({ path }) => {
|
||||
await fs.writeFile(path, launchScript);
|
||||
if (isWindows) {
|
||||
return elevateScriptWindows(path, options.applicationName, options.env);
|
||||
}
|
||||
if (
|
||||
os.platform() === 'darwin' &&
|
||||
semver.compare(os.release(), '19.0.0') >= 0
|
||||
) {
|
||||
// >= macOS Catalina
|
||||
return elevateScriptCatalina(path);
|
||||
}
|
||||
try {
|
||||
return elevateScriptUnix(path, options.applicationName);
|
||||
} catch (error: any) {
|
||||
// We're hardcoding internal error messages declared by `sudo-prompt`.
|
||||
// There doesn't seem to be a better way to handle these errors, so
|
||||
// for now, we should make sure we double check if the error messages
|
||||
// have changed every time we upgrade `sudo-prompt`.
|
||||
console.log('error', error);
|
||||
if (_.includes(error.message, 'is not in the sudoers file')) {
|
||||
throw errors.createUserError({
|
||||
title: "Your user doesn't have enough privileges to proceed",
|
||||
description:
|
||||
'This application requires sudo privileges to be able to write to drives',
|
||||
});
|
||||
} else if (_.startsWith(error.message, 'Command failed:')) {
|
||||
throw errors.createUserError({
|
||||
title: 'The elevated process died unexpectedly',
|
||||
description: `The process error code was ${error.code}`,
|
||||
});
|
||||
} else if (error.message === 'User did not grant permission.') {
|
||||
return { cancelled: true };
|
||||
} else if (error.message === 'No polkit authentication agent found.') {
|
||||
throw errors.createUserError({
|
||||
title: 'No polkit authentication agent found',
|
||||
description:
|
||||
'Please install a polkit authentication agent for your desktop environment of choice to continue',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
if (os.platform() === 'win32') {
|
||||
const { cancelled } = await winSudo(commandWithEnv(command, options.env));
|
||||
return { cancelled };
|
||||
}
|
||||
if (
|
||||
os.platform() === 'darwin' &&
|
||||
semver.compare(os.release(), '19.0.0') >= 0
|
||||
) {
|
||||
// >= macOS Catalina
|
||||
const { cancelled } = await darwinSudo(command, options.env);
|
||||
return { cancelled };
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw errors.createError({ title: error.stderr });
|
||||
}
|
||||
|
||||
try {
|
||||
const { cancelled } = await linuxSudo(commandWithEnv(command, options.env));
|
||||
return { cancelled };
|
||||
} catch (error: any) {
|
||||
// We're hardcoding internal error messages declared by `sudo-prompt`.
|
||||
// There doesn't seem to be a better way to handle these errors, so
|
||||
// for now, we should make sure we double check if the error messages
|
||||
// have changed every time we upgrade `sudo-prompt`.
|
||||
console.log('error', error);
|
||||
if (_.includes(error.message, 'is not in the sudoers file')) {
|
||||
throw errors.createUserError({
|
||||
title: "Your user doesn't have enough privileges to proceed",
|
||||
description:
|
||||
'This application requires sudo privileges to be able to write to drives',
|
||||
});
|
||||
} else if (_.startsWith(error.message, 'Command failed:')) {
|
||||
throw errors.createUserError({
|
||||
title: 'The elevated process died unexpectedly',
|
||||
description: `The process error code was ${error.code}`,
|
||||
});
|
||||
} else if (error.message === 'User did not grant permission.') {
|
||||
return { cancelled: true };
|
||||
} else if (error.message === 'No polkit authentication agent found.') {
|
||||
throw errors.createUserError({
|
||||
title: 'No polkit authentication agent found',
|
||||
description:
|
||||
'Please install a polkit authentication agent for your desktop environment of choice to continue',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2019 balena.io
|
||||
* Copyright 2025 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@ -16,15 +16,10 @@
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { env } from 'process';
|
||||
// import { promisify } from "util";
|
||||
|
||||
import { supportedLocales } from '../../gui/app/i18n';
|
||||
|
||||
// const execFileAsync = promisify(execFile);
|
||||
|
||||
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
|
||||
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`;
|
||||
|
||||
function getAskPassScriptPath(lang: string): string {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@ -36,67 +31,68 @@ function getAskPassScriptPath(lang: string): string {
|
||||
}
|
||||
|
||||
export async function sudo(
|
||||
command: string,
|
||||
command: string[],
|
||||
env: any,
|
||||
): 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';
|
||||
}
|
||||
let lang = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
lang = lang.substr(0, 2);
|
||||
if (supportedLocales.indexOf(lang) === -1) {
|
||||
lang = 'en';
|
||||
}
|
||||
|
||||
// Build the shell command string
|
||||
const shellCmd = `echo ${SUCCESSFUL_AUTH_MARKER} && ${command[0]} ${command
|
||||
.slice(1)
|
||||
.map((a) => a.replace(/\\/g, '\\\\').replace(/"/g, '\\"'))
|
||||
.join(' ')}`;
|
||||
|
||||
let elevated = 'pending';
|
||||
|
||||
try {
|
||||
const elevateProcess = spawn(
|
||||
'sudo',
|
||||
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`],
|
||||
['-E', '--askpass', 'sh', '-c', shellCmd],
|
||||
{
|
||||
// encoding: "utf8",
|
||||
env: {
|
||||
...env,
|
||||
PATH: env.PATH,
|
||||
SUDO_ASKPASS: getAskPassScriptPath(lang),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let elevated = 'pending';
|
||||
|
||||
elevateProcess.stdout.on('data', (data) => {
|
||||
// console.log(`stdout: ${data}`);
|
||||
if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
|
||||
// if the first data comming out of the sudo command is the expected marker we resolve the promise
|
||||
elevated = 'granted';
|
||||
} else {
|
||||
// if the first data comming out of the sudo command is not the expected marker we reject the promise
|
||||
elevated = 'rejected';
|
||||
}
|
||||
});
|
||||
|
||||
// we don't spawn or read stdout in the promise otherwise resolving stop the process
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkElevation = setInterval(() => {
|
||||
if (elevated === 'granted') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: false });
|
||||
} else if (elevated === 'rejected') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: true });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// if the elevation didn't occured in 30 seconds we reject the promise
|
||||
setTimeout(() => {
|
||||
clearInterval(checkElevation);
|
||||
reject(new Error('Elevation timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
// elevateProcess.stderr.on('data', (data) => {
|
||||
// console.log(`stderr: ${data}`);
|
||||
// });
|
||||
} catch (error: any) {
|
||||
if (error.code === 1) {
|
||||
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
error.stdout = error.stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length);
|
||||
}
|
||||
throw error;
|
||||
console.error('Error starting sudo process', error);
|
||||
throw new Error('Error starting sudo process');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkElevation = setInterval(() => {
|
||||
console.log('elevated', elevated);
|
||||
if (elevated === 'granted') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: false });
|
||||
} else if (elevated === 'rejected') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: true });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(checkElevation);
|
||||
reject(new Error('Elevation timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
@ -1,38 +1,21 @@
|
||||
/*
|
||||
* This is heavily inspired (read: a ripof) https://github.com/balena-io-modules/sudo-prompt
|
||||
* Which was a fork of https://github.com/jorangreef/sudo-prompt
|
||||
*
|
||||
* This and the original code was released under The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2015 Joran Dirk Greef
|
||||
* Copyright (c) 2024 Balena
|
||||
*
|
||||
The MIT License (MIT)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
/*
|
||||
* Copyright 2025 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { access, constants } from 'fs/promises';
|
||||
import { env } from 'process';
|
||||
|
||||
// const execFileAsync = promisify(execFile);
|
||||
|
||||
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
|
||||
|
||||
@ -62,40 +45,30 @@ function escapeDoubleQuotes(escapeString: string) {
|
||||
}
|
||||
|
||||
export async function sudo(
|
||||
command: string,
|
||||
{ name }: { name: string },
|
||||
command: string[],
|
||||
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
|
||||
const linuxBinary: string = (await checkLinuxBinary()) as string;
|
||||
if (!linuxBinary) {
|
||||
throw new Error('Unable to find pkexec or kdesudo.');
|
||||
throw new Error('Unable to find pkexec.');
|
||||
}
|
||||
|
||||
const parameters = [];
|
||||
|
||||
if (/kdesudo/i.test(linuxBinary)) {
|
||||
parameters.push(
|
||||
'--comment',
|
||||
`"${name} wants to make changes.
|
||||
Enter your password to allow this."`,
|
||||
);
|
||||
parameters.push('-d'); // Do not show the command to be run in the dialog.
|
||||
parameters.push('--');
|
||||
} else if (/pkexec/i.test(linuxBinary)) {
|
||||
// Add pkexec specific parameters
|
||||
if (/pkexec/i.test(linuxBinary)) {
|
||||
parameters.push('--disable-internal-agent');
|
||||
}
|
||||
|
||||
// Build the shell command string
|
||||
const shellCmd = `echo ${SUCCESSFUL_AUTH_MARKER} && ${command
|
||||
.map((a) => escapeDoubleQuotes(a))
|
||||
.join(' ')}`;
|
||||
|
||||
parameters.push('/bin/bash');
|
||||
parameters.push('-c');
|
||||
parameters.push(
|
||||
`echo ${SUCCESSFUL_AUTH_MARKER} && ${escapeDoubleQuotes(command)}`,
|
||||
);
|
||||
parameters.push(shellCmd);
|
||||
|
||||
const elevateProcess = spawn(linuxBinary, parameters, {
|
||||
// encoding: "utf8",
|
||||
env: {
|
||||
PATH: env.PATH,
|
||||
},
|
||||
});
|
||||
const elevateProcess = spawn(linuxBinary, parameters);
|
||||
|
||||
let elevated = '';
|
||||
|
||||
@ -110,17 +83,6 @@ export async function sudo(
|
||||
}
|
||||
});
|
||||
|
||||
// elevateProcess.stderr.on('data', (data) => {
|
||||
// // console.log(`stderr: ${data.toString()}`);
|
||||
// // if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
|
||||
// // // if the first data comming out of the sudo command is the expected marker we resolve the promise
|
||||
// // elevated = 'granted';
|
||||
// // } else {
|
||||
// // // if the first data comming out of the sudo command is not the expected marker we reject the promise
|
||||
// // elevated = 'refused';
|
||||
// // }
|
||||
// });
|
||||
|
||||
// we don't spawn or read stdout in the promise otherwise resolving stop the process
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkElevation = setInterval(() => {
|
||||
|
@ -1,190 +1,85 @@
|
||||
/*
|
||||
* This is heavily inspired (read: a ripof) https://github.com/balena-io-modules/sudo-prompt
|
||||
* Which was a fork of https://github.com/jorangreef/sudo-prompt
|
||||
*
|
||||
* This and the original code was released under The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2015 Joran Dirk Greef
|
||||
* Copyright (c) 2024 Balena
|
||||
*
|
||||
The MIT License (MIT)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
/*
|
||||
* Copyright 2025 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
// import { env } from 'process';
|
||||
import { tmpdir } from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { join, sep } from 'path';
|
||||
import { mkdir, writeFile, copyFile, readFile } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* Migrate, modernize and clenup the windows elevation code from the old @balena/sudo-prompt package in a similar way to linux-sudo.ts and catalina-sudo files.
|
||||
*/
|
||||
|
||||
export async function sudo(
|
||||
command: string,
|
||||
_name: string,
|
||||
env: any,
|
||||
command: string[],
|
||||
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
|
||||
const uuid = uuidv4();
|
||||
|
||||
const temp = tmpdir();
|
||||
if (!temp) {
|
||||
throw new Error('os.tmpdir() not defined.');
|
||||
}
|
||||
|
||||
const tmpFolder = join(temp, uuid);
|
||||
|
||||
if (/"/.test(tmpFolder)) {
|
||||
// We expect double quotes to be reserved on Windows.
|
||||
// Even so, we test for this and abort if they are present.
|
||||
throw new Error('instance.path cannot contain double-quotes.');
|
||||
}
|
||||
|
||||
const executeScriptPath = join(tmpFolder, 'execute.bat');
|
||||
const commandScriptPath = join(tmpFolder, 'command.bat');
|
||||
const stdoutPath = join(tmpFolder, 'stdout');
|
||||
const stderrPath = join(tmpFolder, 'stderr');
|
||||
const statusPath = join(tmpFolder, 'status');
|
||||
|
||||
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
|
||||
|
||||
try {
|
||||
await mkdir(tmpFolder);
|
||||
// Powershell (required to ask for elevated privileges) as of win10
|
||||
// cannot pass environment variables as a map, so we pass them as args
|
||||
// this is a workaround as we can't use an equivalent of `sudo -E` on Windows
|
||||
|
||||
// WindowsWriteExecuteScript(instance, end)
|
||||
const executeScript = `
|
||||
@echo off\r\n
|
||||
call "${commandScriptPath}" > "${stdoutPath}" 2> "${stderrPath}"\r\n
|
||||
(echo %ERRORLEVEL%) > "${statusPath}"
|
||||
`;
|
||||
|
||||
await writeFile(executeScriptPath, executeScript, 'utf-8');
|
||||
|
||||
// WindowsWriteCommandScript(instance, end)
|
||||
const cwd = process.cwd();
|
||||
if (/"/.test(cwd)) {
|
||||
// We expect double quotes to be reserved on Windows.
|
||||
// Even so, we test for this and abort if they are present.
|
||||
throw new Error('process.cwd() cannot contain double-quotes.');
|
||||
}
|
||||
|
||||
const commandScriptArray = [];
|
||||
commandScriptArray.push('@echo off');
|
||||
// Set code page to UTF-8:
|
||||
commandScriptArray.push('chcp 65001>nul');
|
||||
// Preserve current working directory:
|
||||
// We pass /d as an option in case the cwd is on another drive (issue 70).
|
||||
commandScriptArray.push(`cd /d "${cwd}"`);
|
||||
// Export environment variables:
|
||||
for (const key in env) {
|
||||
// "The characters <, >, |, &, ^ are special command shell characters, and
|
||||
// they must be preceded by the escape character (^) or enclosed in
|
||||
// quotation marks. If you use quotation marks to enclose a string that
|
||||
// contains one of the special characters, the quotation marks are set as
|
||||
// part of the environment variable value."
|
||||
// In other words, Windows assigns everything that follows the equals sign
|
||||
// to the value of the variable, whereas Unix systems ignore double quotes.
|
||||
if (Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
const value = env[key];
|
||||
commandScriptArray.push(
|
||||
`set ${key}=${value!.replace(/([<>\\|&^])/g, '^$1')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
commandScriptArray.push(`echo ${SUCCESSFUL_AUTH_MARKER}`);
|
||||
commandScriptArray.push(command);
|
||||
await writeFile(
|
||||
commandScriptPath,
|
||||
commandScriptArray.join('\r\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// WindowsCopyCmd(instance, end)
|
||||
if (windowsNeedsCopyCmd(tmpFolder)) {
|
||||
// Work around https://github.com/jorangreef/sudo-prompt/issues/97
|
||||
// Powershell can't properly escape amperstands in paths.
|
||||
// We work around this by copying cmd.exe in our temporary folder and running
|
||||
// it from here (see WindowsElevate below).
|
||||
// That way, we don't have to pass the path containing the amperstand at all.
|
||||
// A symlink would probably work too but you have to be an administrator in
|
||||
// order to create symlinks on Windows.
|
||||
await copyFile(
|
||||
join(process.env.SystemRoot!, 'System32', 'cmd.exe'),
|
||||
join(tmpFolder, 'cmd.exe'),
|
||||
);
|
||||
}
|
||||
|
||||
// WindowsElevate(instance, end)
|
||||
// We used to use this for executing elevate.vbs:
|
||||
// var command = 'cscript.exe //NoLogo "' + instance.pathElevate + '"';
|
||||
const spawnCommand = [];
|
||||
// spawnCommand.push("powershell.exe") // as we use spawn this one is out of the array
|
||||
spawnCommand.push('Start-Process');
|
||||
spawnCommand.push('-FilePath');
|
||||
const options: any = { encoding: 'utf8' };
|
||||
if (windowsNeedsCopyCmd(tmpFolder)) {
|
||||
// Node.path.join('.', 'cmd.exe') would return 'cmd.exe'
|
||||
spawnCommand.push(['.', 'cmd.exe'].join(sep));
|
||||
spawnCommand.push('-ArgumentList');
|
||||
spawnCommand.push('"/C","execute.bat"');
|
||||
options.cwd = tmpFolder;
|
||||
} else {
|
||||
// Escape characters for cmd using double quotes:
|
||||
// Escape characters for PowerShell using single quotes:
|
||||
// Escape single quotes for PowerShell using backtick:
|
||||
// See: https://ss64.com/ps/syntax-esc.html
|
||||
spawnCommand.push(`'${executeScriptPath.replace(/'/g, "`'")}'`);
|
||||
}
|
||||
|
||||
// Escape characters for cmd using double quotes:
|
||||
// Escape characters for PowerShell using single quotes:
|
||||
// Escape single quotes for PowerShell using backtick:
|
||||
// See: https://ss64.com/ps/syntax-esc.html
|
||||
spawnCommand.push(`'${command[0].replace(/'/g, "`'")}'`);
|
||||
|
||||
spawnCommand.push('-ArgumentList');
|
||||
|
||||
// Join and escape arguments for PowerShell
|
||||
spawnCommand.push(
|
||||
`'${command
|
||||
.slice(1)
|
||||
.map((a) => a.replace(/'/g, "`'"))
|
||||
.join(' ')}'`,
|
||||
);
|
||||
spawnCommand.push('-WindowStyle hidden');
|
||||
spawnCommand.push('-Verb runAs');
|
||||
|
||||
spawn('powershell.exe', spawnCommand);
|
||||
const child = spawn('powershell.exe', spawnCommand);
|
||||
|
||||
// setTimeout(() => {elevated = "granted"}, 5000)
|
||||
let result = { status: 'waiting' };
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// User accepted UAC, process started
|
||||
console.log('UAC accepted, process started');
|
||||
result = { status: 'granted' };
|
||||
} else {
|
||||
// User cancelled or error occurred
|
||||
console.log('UAC cancelled or error occurred');
|
||||
result = { status: 'cancelled' };
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
result = { status: err.message };
|
||||
});
|
||||
|
||||
// we don't spawn directly in the promise otherwise resolving stop the process
|
||||
|
||||
// we don't spawn or read stdout in the promise otherwise resolving stop the process
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkElevation = setInterval(async () => {
|
||||
try {
|
||||
const result = await readFile(stdoutPath, 'utf-8');
|
||||
const error = await readFile(stderrPath, 'utf-8');
|
||||
|
||||
if (error && error !== '') {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
// TODO: should track something more generic
|
||||
if (result.includes(SUCCESSFUL_AUTH_MARKER)) {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Error while reading flasher elevation script output',
|
||||
error,
|
||||
);
|
||||
const checkElevation = setInterval(() => {
|
||||
if (result.status === 'waiting') {
|
||||
return;
|
||||
} else if (result.status === 'granted') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: false });
|
||||
} else if (result.status === 'cancelled') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: true });
|
||||
}
|
||||
}, 1000);
|
||||
}, 300);
|
||||
|
||||
// if the elevation didn't occured in 30 seconds we reject the promise
|
||||
setTimeout(() => {
|
||||
@ -192,27 +87,7 @@ export async function sudo(
|
||||
reject(new Error('Elevation timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// WindowsWaitForStatus(instance, end)
|
||||
|
||||
// WindowsResult(instance, end)
|
||||
} catch (error) {
|
||||
throw new Error(`Can't elevate process ${error}`);
|
||||
} finally {
|
||||
// TODO: cleanup
|
||||
// // Remove(instance.path, function (errorRemove) {
|
||||
// // if (error) return callback(error)
|
||||
// // if (errorRemove) return callback(errorRemove)
|
||||
// // callback(undefined, stdout, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
function windowsNeedsCopyCmd(path: string) {
|
||||
const specialChars = ['&', '`', "'", '"', '<', '>', '|', '^'];
|
||||
for (const specialChar of specialChars) {
|
||||
if (path.includes(specialChar)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -29,6 +29,28 @@ import { getSourceMetadata } from './source-metadata';
|
||||
import type { DrivelistDrive } from '../shared/drive-constraints';
|
||||
import type { SourceMetadata } from '../shared/typings/source-selector';
|
||||
|
||||
// Utility to parse --key=value arguments into process.env if not already set
|
||||
function injectEnvFromArgs() {
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
const match = arg.match(/^--([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
const value = match[2];
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inject env vars from arguments if not already present
|
||||
injectEnvFromArgs();
|
||||
|
||||
console.log(
|
||||
'Etcher child process started with the following environment variables:',
|
||||
);
|
||||
console.log(JSON.stringify(process.env, null, 2));
|
||||
|
||||
const ETCHER_SERVER_ADDRESS = process.env.ETCHER_SERVER_ADDRESS as string;
|
||||
const ETCHER_SERVER_PORT = process.env.ETCHER_SERVER_PORT as string;
|
||||
// const ETCHER_SERVER_ID = process.env.ETCHER_SERVER_ID as string;
|
||||
|
1891
npm-shrinkwrap.json
generated
1891
npm-shrinkwrap.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"displayName": "balenaEtcher",
|
||||
"productName": "balenaEtcher",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.4",
|
||||
"packageType": "local",
|
||||
"main": ".webpack/main",
|
||||
"description": "Flash OS images to SD cards and USB drives, safely and easily.",
|
||||
@ -34,13 +34,12 @@
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"@ronomon/direct-io": "^3.0.1",
|
||||
"@sentry/electron": "^4.24.0",
|
||||
"analytics-client": "^2.0.1",
|
||||
"axios": "^1.6.8",
|
||||
"debug": "4.3.4",
|
||||
"drivelist": "^12.0.2",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"electron-updater": "6.1.8",
|
||||
"etcher-sdk": "9.1.2",
|
||||
"etcher-sdk": "10.0.0",
|
||||
"i18next": "23.11.2",
|
||||
"immutable": "3.8.2",
|
||||
"lodash": "4.17.21",
|
||||
@ -60,14 +59,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@balena/lint": "8.0.2",
|
||||
"@electron-forge/cli": "7.4.0",
|
||||
"@electron-forge/maker-deb": "7.4.0",
|
||||
"@electron-forge/maker-dmg": "7.4.0",
|
||||
"@electron-forge/maker-rpm": "7.4.0",
|
||||
"@electron-forge/maker-squirrel": "7.4.0",
|
||||
"@electron-forge/maker-zip": "7.4.0",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.4.0",
|
||||
"@electron-forge/plugin-webpack": "7.4.0",
|
||||
"@electron-forge/cli": "7.8.1",
|
||||
"@electron-forge/maker-deb": "7.8.1",
|
||||
"@electron-forge/maker-dmg": "7.8.1",
|
||||
"@electron-forge/maker-rpm": "7.8.1",
|
||||
"@electron-forge/maker-squirrel": "7.8.1",
|
||||
"@electron-forge/maker-zip": "7.8.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.8.1",
|
||||
"@electron-forge/plugin-webpack": "7.8.1",
|
||||
"@reforged/maker-appimage": "3.3.2",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@types/chai": "4.3.14",
|
||||
@ -88,7 +87,7 @@
|
||||
"catch-uncommitted": "^2.0.0",
|
||||
"chai": "4.3.10",
|
||||
"css-loader": "5.2.7",
|
||||
"electron": "30.0.1",
|
||||
"electron": "37.2.4",
|
||||
"file-loader": "6.2.0",
|
||||
"husky": "8.0.3",
|
||||
"native-addon-loader": "2.0.1",
|
||||
@ -144,7 +143,7 @@
|
||||
"node": ">=20 <21"
|
||||
},
|
||||
"versionist": {
|
||||
"publishedAt": "2025-05-05T17:19:50.915Z"
|
||||
"publishedAt": "2025-07-29T12:17:34.228Z"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8",
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,88 +0,0 @@
|
||||
/*
|
||||
* Copyright 2017 balena.io
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as os from 'os';
|
||||
import { stub } from 'sinon';
|
||||
|
||||
import * as permissions from '../../lib/shared/permissions';
|
||||
|
||||
describe('Shared: permissions', function () {
|
||||
describe('.createLaunchScript()', function () {
|
||||
describe('given windows', function () {
|
||||
beforeEach(function () {
|
||||
this.osPlatformStub = stub(os, 'platform');
|
||||
this.osPlatformStub.returns('win32');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
this.osPlatformStub.restore();
|
||||
});
|
||||
|
||||
it('should escape environment variables and arguments', function () {
|
||||
expect(
|
||||
permissions.createLaunchScript(
|
||||
'C:\\Users\\Alice & Bob\'s Laptop\\"what"\\balenaEtcher',
|
||||
['"a Laser"', 'arg1', "'&/ ^ \\", '" $ % *'],
|
||||
{
|
||||
key: 'value',
|
||||
key2: ' " \' ^ & = + $ % / \\',
|
||||
key3: '8',
|
||||
},
|
||||
),
|
||||
).to.equal(
|
||||
`chcp 65001${os.EOL}` +
|
||||
`set "key=value"${os.EOL}` +
|
||||
`set "key2= " ' ^ & = + $ % / \\"${os.EOL}` +
|
||||
`set "key3=8"${os.EOL}` +
|
||||
`"C:\\Users\\Alice & Bob's Laptop\\\\"what\\"\\balenaEtcher" "\\"a Laser\\"" "arg1" "'&/ ^ \\" "\\" $ % *"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
for (const platform of ['linux', 'darwin']) {
|
||||
describe(`given ${platform}`, function () {
|
||||
beforeEach(function () {
|
||||
this.osPlatformStub = stub(os, 'platform');
|
||||
this.osPlatformStub.returns(platform);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
this.osPlatformStub.restore();
|
||||
});
|
||||
|
||||
it('should escape environment variables and arguments', function () {
|
||||
expect(
|
||||
permissions.createLaunchScript(
|
||||
'/home/Alice & Bob\'s Laptop/"what"/balenaEtcher',
|
||||
['arg1', "'&/ ^ \\", '" $ % *'],
|
||||
{
|
||||
key: 'value',
|
||||
key2: ' " \' ^ & = + $ % / \\',
|
||||
key3: '8',
|
||||
},
|
||||
),
|
||||
).to.equal(
|
||||
`export key='value'${os.EOL}` +
|
||||
`export key2=' " '\\'' ^ & = + $ % / \\'${os.EOL}` +
|
||||
`export key3='8'${os.EOL}` +
|
||||
`'/home/Alice & Bob'\\''s Laptop/"what"/balenaEtcher' 'arg1' ''\\''&/ ^ \\' '" $ % *'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
@ -63,9 +63,6 @@ const rules: Required<ModuleOptions>['rules'] = [
|
||||
|
||||
const injectAnalyticsToken = new DefinePlugin({
|
||||
'process.env.SENTRY_TOKEN': JSON.stringify(process.env.SENTRY_TOKEN || ''),
|
||||
'process.env.AMPLITUDE_TOKEN': JSON.stringify(
|
||||
process.env.AMPLITUDE_TOKEN || '',
|
||||
),
|
||||
});
|
||||
|
||||
export const rendererConfig: Configuration = {
|
||||
|
Loading…
x
Reference in New Issue
Block a user