Compare commits

...

11 Commits

Author SHA1 Message Date
flowzone-app[bot]
a79db1db6b
v2.1.4 2025-07-29 12:17:36 +00:00
flowzone-app[bot]
7aa5db9408
Merge pull request #4495 from balena-io/aethernet/refactor-sudo
patch: refactor permission code
2025-07-29 12:16:37 +00:00
Edwin Joassart
c824a60e5d
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
2025-07-29 13:45:55 +02:00
Edwin Joassart
2a470f5e6c
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
2025-07-29 13:45:52 +02:00
Edwin Joassart
f3123f3cbe
patch: refactor permission code 2025-07-29 13:45:48 +02:00
flowzone-app[bot]
391164bf15
v2.1.3 2025-05-15 18:09:58 +00:00
Anton Belodedenko
7c2c2bc3d6
Merge pull request #4411 from balena-io/ab77/operational
Remove stale secrets
2025-05-15 11:09:03 -07:00
flowzone-app[bot]
c2d160f5c7
v2.1.2 2025-05-08 08:51:47 +00:00
flowzone-app[bot]
385bf45883
Merge pull request #4435 from balena-io/aethernet/remove-analytics
patch: remove analytics code
2025-05-08 08:50:54 +00:00
Edwin Joassart
aa6d526fea
patch: remove analytics 2025-05-07 14:57:50 +02:00
Anton Belodedenko
c2fc36971c Remove stale secrets
change-type: patch
2025-04-14 08:59:30 +00:00
43 changed files with 1165 additions and 2011 deletions

4
.gitattributes vendored
View File

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

View File

@ -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'

View File

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

View File

@ -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.

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

View File

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

View File

@ -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)

View File

@ -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/)

View File

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

View File

@ -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',

View File

@ -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,

View File

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

View File

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

View File

@ -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({

View File

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

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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',

View File

@ -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
*

View File

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

View File

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

View File

@ -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') {

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

@ -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(() => {

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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.

View File

@ -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' ''\\''&/ ^ \\' '" $ % *'`,
);
});
});
}
});
});

View File

@ -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 = {