Merge pull request #4185 from balena-io/reverse-control-flow

Patch: switch from node-ipc to ws
This commit is contained in:
flowzone-app[bot] 2024-04-23 10:27:14 +00:00 committed by GitHub
commit 0a243caf35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1015 additions and 678 deletions

View File

@ -41,8 +41,8 @@ const config: ForgeConfig = {
darwinDarkModeSupport: true, darwinDarkModeSupport: true,
protocols: [{ name: 'etcher', schemes: ['etcher'] }], protocols: [{ name: 'etcher', schemes: ['etcher'] }],
extraResource: [ extraResource: [
'lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js', 'lib/shared/sudo/sudo-askpass.osascript-zh.js',
'lib/shared/catalina-sudo/sudo-askpass.osascript-en.js', 'lib/shared/sudo/sudo-askpass.osascript-en.js',
], ],
osxSign: { osxSign: {
optionsForFile: () => ({ optionsForFile: () => ({

View File

@ -31,7 +31,7 @@ import * as flashState from './models/flash-state';
import * as settings from './models/settings'; import * as settings from './models/settings';
import { Actions, observe, store } from './models/store'; import { Actions, observe, store } from './models/store';
import * as analytics from './modules/analytics'; import * as analytics from './modules/analytics';
import { startApiAndSpawnChild } from './modules/api'; import { spawnChildAndConnect } from './modules/api';
import * as exceptionReporter from './modules/exception-reporter'; import * as exceptionReporter from './modules/exception-reporter';
import * as osDialog from './os/dialog'; import * as osDialog from './os/dialog';
import * as windowProgress from './os/window-progress'; import * as windowProgress from './os/window-progress';
@ -139,11 +139,11 @@ function setDrives(drives: Dictionary<DrivelistDrive>) {
export let requestMetadata: any; export let requestMetadata: any;
// start the api and spawn the child process // start the api and spawn the child process
startApiAndSpawnChild({ spawnChildAndConnect({
withPrivileges: false, withPrivileges: false,
}).then(({ emit, registerHandler }) => { }).then(({ emit, registerHandler }) => {
// start scanning // start scanning
emit('scan'); emit('scan', {});
// make the sourceMetada awaitable to be used on source selection // make the sourceMetada awaitable to be used on source selection
requestMetadata = async (params: any): Promise<SourceMetadata> => { requestMetadata = async (params: any): Promise<SourceMetadata> => {

View File

@ -12,19 +12,16 @@
* - centralise the api for both the writer and the scanner instead of having two instances running * - centralise the api for both the writer and the scanner instead of having two instances running
*/ */
import * as ipc from 'node-ipc'; import WebSocket from 'ws'; // (no types for wrapper, this is expected)
import { spawn } from 'child_process'; import { spawn, exec } from 'child_process';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path';
import * as packageJSON from '../../../../package.json'; import * as packageJSON from '../../../../package.json';
import * as permissions from '../../../shared/permissions'; import * as permissions from '../../../shared/permissions';
import * as errors from '../../../shared/errors'; import * as errors from '../../../shared/errors';
const THREADS_PER_CPU = 16; const THREADS_PER_CPU = 16;
const connectionRetryDelay = 1000;
// NOTE: Ensure this isn't disabled, as it will cause const connectionRetryAttempts = 10;
// the stdout maxBuffer size to be exceeded when flashing
ipc.config.silent = true;
async function writerArgv(): Promise<string[]> { async function writerArgv(): Promise<string[]> {
let entryPoint = await window.etcher.getEtcherUtilPath(); let entryPoint = await window.etcher.getEtcherUtilPath();
@ -45,15 +42,17 @@ async function writerArgv(): Promise<string[]> {
} }
} }
function writerEnv( async function spawnChild(
IPC_CLIENT_ID: string, withPrivileges: boolean,
IPC_SERVER_ID: string, etcherServerId: string,
IPC_SOCKET_ROOT: string, etcherServerAddress: string,
etcherServerPort: string,
) { ) {
return { const argv = await writerArgv();
IPC_SERVER_ID, const env: any = {
IPC_CLIENT_ID, ETCHER_SERVER_ADDRESS: etcherServerAddress,
IPC_SOCKET_ROOT, ETCHER_SERVER_ID: etcherServerId,
ETCHER_SERVER_PORT: etcherServerPort,
UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(), UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(),
// This environment variable prevents the AppImages // This environment variable prevents the AppImages
// desktop integration script from presenting the // desktop integration script from presenting the
@ -61,123 +60,192 @@ function writerEnv(
SKIP: '1', SKIP: '1',
...(process.platform === 'win32' ? {} : process.env), ...(process.platform === 'win32' ? {} : process.env),
}; };
}
async function spawnChild({
withPrivileges,
IPC_CLIENT_ID,
IPC_SERVER_ID,
IPC_SOCKET_ROOT,
}: {
withPrivileges: boolean;
IPC_CLIENT_ID: string;
IPC_SERVER_ID: string;
IPC_SOCKET_ROOT: string;
}) {
const argv = await writerArgv();
const env = writerEnv(IPC_CLIENT_ID, IPC_SERVER_ID, IPC_SOCKET_ROOT);
if (withPrivileges) { if (withPrivileges) {
return await permissions.elevateCommand(argv, { console.log('... with privileges ...');
return permissions.elevateCommand(argv, {
applicationName: packageJSON.displayName, applicationName: packageJSON.displayName,
environment: env,
});
} else {
const process = await spawn(argv[0], argv.slice(1), {
env, env,
}); });
return { cancelled: false, process }; } else {
if (process.platform === 'win32') {
// we need to ensure we reset the env as a previous elevation process might have kept them in a wrong state
const envCommand = [];
for (const key in env) {
if (Object.prototype.hasOwnProperty.call(env, key)) {
envCommand.push(`set ${key}=${env[key]}`);
}
}
await exec(envCommand.join(' && '));
}
const spawned = await spawn(argv[0], argv.slice(1), {
env,
});
return { cancelled: false, spawned };
} }
} }
function terminateServer(server: any) { type ChildApi = {
// Turns out we need to destroy all sockets for emit: (type: string, payload: any) => void;
// the server to actually close. Otherwise, it registerHandler: (event: string, handler: any) => void;
// just stops receiving any further connections, failed: boolean;
// but remains open if there are active ones. };
// @ts-ignore (no Server.sockets in @types/node-ipc)
for (const socket of server.sockets) {
socket.destroy();
}
server.stop();
}
// TODO: replace the custom ipc events by one generic "message" for all communication with the backend
function startApiAndSpawnChild({
withPrivileges,
}: {
withPrivileges: boolean;
}): Promise<any> {
// There might be multiple Etcher instances running at
// the same time, also we might spawn multiple child and api so we must ensure each IPC
// server/client has a different name.
const IPC_SERVER_ID = `etcher-server-${process.pid}-${Date.now()}-${
withPrivileges ? 'privileged' : 'unprivileged'
}`;
const IPC_CLIENT_ID = `etcher-client-${process.pid}-${Date.now()}-${
withPrivileges ? 'privileged' : 'unprivileged'
}`;
const IPC_SOCKET_ROOT = path.join(
process.env.XDG_RUNTIME_DIR || os.tmpdir(),
path.sep,
);
ipc.config.id = IPC_SERVER_ID;
ipc.config.socketRoot = IPC_SOCKET_ROOT;
async function connectToChildProcess(
etcherServerAddress: string,
etcherServerPort: string,
etcherServerId: string,
): Promise<ChildApi | { failed: boolean }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ipc.serve(); // TODO: default to IPC connections https://github.com/websockets/ws/blob/master/doc/ws.md#ipc-connections
// TOOD: use the path as cheap authentication
console.log(etcherServerId);
// log is special message which brings back the logs from the child process and prints them to the console const url = `ws://${etcherServerAddress}:${etcherServerPort}`;
ipc.server.on('log', (message: string) => {
console.log(message); const ws = new WebSocket(url);
let heartbeat: any;
const startHeartbeat = (emit: any) => {
console.log('start heartbeat');
heartbeat = setInterval(() => {
emit('heartbeat', {});
}, 1000);
};
const stopHeartbeat = () => {
console.log('stop heartbeat');
clearInterval(heartbeat);
};
ws.on('error', (error: any) => {
if (error.code === 'ECONNREFUSED') {
resolve({
failed: true,
});
} else {
stopHeartbeat();
reject({
failed: true,
});
}
});
ws.on('open', () => {
const emit = (type: string, payload: any) => {
ws.send(JSON.stringify({ type, payload }));
};
emit('ready', {});
// parse and route messages
const messagesHandler: any = {
log: (message: any) => {
console.log(`CHILD LOG: ${message}`);
},
error: (error: any) => {
const errorObject = errors.fromJSON(error);
console.error('CHILD ERROR', errorObject);
stopHeartbeat();
},
// once api is ready (means child process is connected) we pass the emit function to the caller
ready: () => {
console.log('CHILD READY');
startHeartbeat(emit);
resolve({
failed: false,
emit,
registerHandler,
});
},
};
ws.on('message', (jsonData: any) => {
const data = JSON.parse(jsonData);
const message = messagesHandler[data.type];
if (message) {
message(data.payload);
} else {
throw new Error(`Unknown message type: ${data.type}`);
}
}); });
// api to register more handlers with callbacks // api to register more handlers with callbacks
const registerHandler = (event: string, handler: any) => { const registerHandler = (event: string, handler: any) => {
ipc.server.on(event, handler); messagesHandler[event] = handler;
}; };
// once api is ready (means child process is connected) we pass the emit and terminate function to the caller
ipc.server.on('ready', (_: any, socket) => {
const emit = (channel: string, data: any) => {
ipc.server.emit(socket, channel, data);
};
resolve({
emit,
terminateServer: () => terminateServer(ipc.server),
registerHandler,
}); });
}); });
// on api error we terminate
ipc.server.on('error', (error: any) => {
terminateServer(ipc.server);
const errorObject = errors.fromJSON(error);
reject(errorObject);
});
// when the api is started we spawn the child process
ipc.server.on('start', async () => {
try {
const results = await spawnChild({
withPrivileges,
IPC_CLIENT_ID,
IPC_SERVER_ID,
IPC_SOCKET_ROOT,
});
// this will happen if the child is spawned withPrivileges and privileges has been rejected
if (results.cancelled) {
reject();
}
} catch (error) {
reject(error);
}
});
// start the server
ipc.server.start();
});
} }
export { startApiAndSpawnChild }; async function spawnChildAndConnect({
withPrivileges,
}: {
withPrivileges: boolean;
}): Promise<ChildApi> {
const etcherServerAddress = process.env.ETCHER_SERVER_ADDRESS ?? '127.0.0.1'; // localhost
const etcherServerPort =
process.env.ETCHER_SERVER_PORT ?? withPrivileges ? '3435' : '3434';
const etcherServerId =
process.env.ETCHER_SERVER_ID ??
`etcher-${Math.random().toString(36).substring(7)}`;
console.log(
`Spawning ${
withPrivileges ? 'priviledged' : 'unpriviledged'
} sidecar on port ${etcherServerPort}`,
);
// spawn the child process, which will act as the ws server
// ETCHER_NO_SPAWN_UTIL can be set to launch a GUI only version of etcher, in that case you'll probably want to set other ENV to match your setup
if (!process.env.ETCHER_NO_SPAWN_UTIL) {
try {
const result = await spawnChild(
withPrivileges,
etcherServerId,
etcherServerAddress,
etcherServerPort,
);
if (result.cancelled) {
throw new Error('Spwaning the child process was cancelled');
}
} catch (error) {
console.error('Error spawning child process', error);
throw new Error('Error spawning the child process');
}
}
// try to connect to the ws server, retrying if necessary, until the connection is established
try {
let retry = 0;
while (retry < connectionRetryAttempts) {
const { emit, registerHandler, failed } = await connectToChildProcess(
etcherServerAddress,
etcherServerPort,
etcherServerId,
);
if (failed) {
retry++;
console.log(
`Retrying to connect to child process in ${connectionRetryDelay}... ${retry} / ${connectionRetryAttempts}`,
);
await new Promise((resolve) =>
setTimeout(resolve, connectionRetryDelay),
);
continue;
}
return { failed, emit, registerHandler };
}
throw new Error('Connection to etcher-util timed out');
} catch (error) {
console.error('Error connecting to child process', error);
throw new Error('Connection to etcher-util failed');
}
}
export { spawnChildAndConnect };

View File

@ -24,7 +24,7 @@ import * as selectionState from '../models/selection-state';
import * as settings from '../models/settings'; import * as settings from '../models/settings';
import * as analytics from '../modules/analytics'; import * as analytics from '../modules/analytics';
import * as windowProgress from '../os/window-progress'; import * as windowProgress from '../os/window-progress';
import { startApiAndSpawnChild } from './api'; import { spawnChildAndConnect } from './api';
/** /**
* @summary Handle a flash error and log it to analytics * @summary Handle a flash error and log it to analytics
@ -78,15 +78,14 @@ async function performWrite(
): Promise<{ cancelled?: boolean }> { ): Promise<{ cancelled?: boolean }> {
const { autoBlockmapping, decompressFirst } = await settings.getAll(); const { autoBlockmapping, decompressFirst } = await settings.getAll();
console.log({ image, drives });
// Spawn the child process with privileges and wait for the connection to be made // Spawn the child process with privileges and wait for the connection to be made
const { emit, registerHandler, terminateServer } = const { emit, registerHandler } = await spawnChildAndConnect({
await startApiAndSpawnChild({
withPrivileges: true, withPrivileges: true,
}); });
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
// if the connection failed, reject the promise
const flashResults: FlashResults = {}; const flashResults: FlashResults = {};
const analyticsData = { const analyticsData = {
@ -108,25 +107,25 @@ async function performWrite(
finish(); finish();
}; };
const onDone = (event: any) => { const onDone = (payload: any) => {
console.log('done event'); console.log('CHILD: flash done', payload);
event.results.errors = event.results.errors.map( payload.results.errors = payload.results.errors.map(
(data: Dictionary<any> & { message: string }) => { (data: Dictionary<any> & { message: string }) => {
return errors.fromJSON(data); return errors.fromJSON(data);
}, },
); );
flashResults.results = event.results; flashResults.results = payload.results;
finish(); finish();
}; };
const onAbort = () => { const onAbort = () => {
console.log('abort event'); console.log('CHILD: flash aborted');
flashResults.cancelled = true; flashResults.cancelled = true;
finish(); finish();
}; };
const onSkip = () => { const onSkip = () => {
console.log('skip event'); console.log('CHILD: validation skipped');
flashResults.skip = true; flashResults.skip = true;
finish(); finish();
}; };
@ -151,8 +150,6 @@ async function performWrite(
); );
} }
console.log('Terminating IPC server');
terminateServer();
resolve(flashResults); resolve(flashResults);
}; };
@ -162,7 +159,7 @@ async function performWrite(
registerHandler('abort', onAbort); registerHandler('abort', onAbort);
registerHandler('skip', onSkip); registerHandler('skip', onSkip);
cancelEmitter = (cancelStatus: string) => emit(cancelStatus); cancelEmitter = (cancelStatus: string) => emit('cancel', cancelStatus);
// Now that we know we're connected we can instruct the child process to start the write // Now that we know we're connected we can instruct the child process to start the write
const parameters = { const parameters = {
@ -212,7 +209,9 @@ export async function flash(
// start api and call the flasher // start api and call the flasher
try { try {
const result = await write(image, drives, flashState.setProgressState); const result = await write(image, drives, flashState.setProgressState);
console.log('got results', result);
await flashState.unsetFlashingFlag(result); await flashState.unsetFlashingFlag(result);
console.log('removed flashing flag');
} catch (error: any) { } catch (error: any) {
await flashState.unsetFlashingFlag({ await flashState.unsetFlashingFlag({
cancelled: false, cancelled: false,

View File

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

View File

@ -14,14 +14,14 @@
* limitations under the License. * limitations under the License.
*/ */
import { execFile } from 'child_process'; import { spawn } from 'child_process';
import { join } from 'path'; import { join } from 'path';
import { env } from 'process'; import { env } from 'process';
import { promisify } from 'util'; // import { promisify } from "util";
import { supportedLocales } from '../../gui/app/i18n'; import { supportedLocales } from '../../gui/app/i18n';
const execFileAsync = promisify(execFile); // const execFileAsync = promisify(execFile);
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED'; const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`; const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`;
@ -48,22 +48,48 @@ export async function sudo(
lang = 'en'; lang = 'en';
} }
const { stdout, stderr } = await execFileAsync( const elevateProcess = spawn(
'sudo', 'sudo',
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`], ['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`],
{ {
encoding: 'utf8', // encoding: "utf8",
env: { env: {
PATH: env.PATH, PATH: env.PATH,
SUDO_ASKPASS: getAskPassScriptPath(lang), SUDO_ASKPASS: getAskPassScriptPath(lang),
}, },
}, },
); );
return {
cancelled: false, let elevated = 'pending';
stdout: stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length),
stderr, elevateProcess.stdout.on('data', (data) => {
}; if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
// if the first data comming out of the sudo command is the expected marker we resolve the promise
elevated = 'granted';
} else {
// if the first data comming out of the sudo command is not the expected marker we reject the promise
elevated = 'rejected';
}
});
// we don't spawn or read stdout in the promise otherwise resolving stop the process
return new Promise((resolve, reject) => {
const checkElevation = setInterval(() => {
if (elevated === 'granted') {
clearInterval(checkElevation);
resolve({ cancelled: false });
} else if (elevated === 'rejected') {
clearInterval(checkElevation);
resolve({ cancelled: true });
}
}, 300);
// if the elevation didn't occured in 30 seconds we reject the promise
setTimeout(() => {
clearInterval(checkElevation);
reject(new Error('Elevation timeout'));
}, 30000);
});
} catch (error: any) { } catch (error: any) {
if (error.code === 1) { if (error.code === 1) {
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) { if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {

142
lib/shared/sudo/linux.ts Normal file
View File

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

220
lib/shared/sudo/windows.ts Normal file
View File

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

View File

@ -14,122 +14,164 @@
* limitations under the License. * limitations under the License.
*/ */
import * as ipc from 'node-ipc'; import { WebSocketServer } from 'ws';
import { Dictionary, values } from 'lodash'; import { Dictionary, values } from 'lodash';
import type { MultiDestinationProgress } from 'etcher-sdk/build/multi-write'; import type { MultiDestinationProgress } from 'etcher-sdk/build/multi-write';
import { toJSON } from '../shared/errors'; import { toJSON } from '../shared/errors';
import { GENERAL_ERROR, SUCCESS } from '../shared/exit-codes'; import { GENERAL_ERROR, SUCCESS } from '../shared/exit-codes';
import { delay } from '../shared/utils';
import { WriteOptions } from './types/types'; import { WriteOptions } from './types/types';
import { write, cleanup } from './child-writer'; import { write, cleanup } from './child-writer';
import { startScanning } from './scanner'; import { startScanning } from './scanner';
import { getSourceMetadata } from './source-metadata'; import { getSourceMetadata } from './source-metadata';
import { DrivelistDrive } from '../shared/drive-constraints'; import { DrivelistDrive } from '../shared/drive-constraints';
import { SourceMetadata } from '../shared/typings/source-selector';
ipc.config.id = process.env.IPC_CLIENT_ID as string; const ETCHER_SERVER_ADDRESS = process.env.ETCHER_SERVER_ADDRESS as string;
ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string; const ETCHER_SERVER_PORT = process.env.ETCHER_SERVER_PORT as string;
// const ETCHER_SERVER_ID = process.env.ETCHER_SERVER_ID as string;
// NOTE: Ensure this isn't disabled, as it will cause const ETCHER_TERMINATE_TIMEOUT: number = parseInt(
// the stdout maxBuffer size to be exceeded when flashing process.env.ETCHER_TERMINATE_TIMEOUT ?? '10000',
ipc.config.silent = true; 10,
);
// > If set to 0, the client will NOT try to reconnect. const host = ETCHER_SERVER_ADDRESS ?? '127.0.0.1';
// See https://github.com/RIAEvangelist/node-ipc/ const port = parseInt(ETCHER_SERVER_PORT || '3434', 10);
// // const path = ETCHER_SERVER_ID || "etcher";
// The purpose behind this change is for this process
// to emit a "disconnect" event as soon as the GUI
// process is closed, so we can kill this process as well.
// @ts-ignore (0 is a valid value for stopRetrying and is not the same as false) // TODO: use the path as cheap authentication
ipc.config.stopRetrying = 0;
const DISCONNECT_DELAY = 100; const wss = new WebSocketServer({ host, port });
const IPC_SERVER_ID = process.env.IPC_SERVER_ID as string;
/** // hold emit functions
* @summary Send a message to the IPC server let emitLog: (message: string) => void | undefined;
*/ let emitState: (state: MultiDestinationProgress) => void | undefined;
function emit(channel: string, message?: any) { let emitFail: (data: any) => void | undefined;
ipc.of[IPC_SERVER_ID].emit(channel, message); let emitDrives: (drives: Dictionary<DrivelistDrive>) => void | undefined;
} let emitSourceMetadata: (
sourceMetadata: SourceMetadata | Record<string, never>,
) => void | undefined; // Record<string, never> means an empty object
/** // Terminate the child process
* @summary Send a log debug message to the IPC server async function terminate(exitCode?: number) {
*/
function log(message: string) {
if (console?.log) {
console.log(message);
}
emit('log', message);
}
/**
* @summary Terminate the child process
*/
async function terminate(exitCode: number) {
ipc.disconnect(IPC_SERVER_ID);
await cleanup(Date.now()); await cleanup(Date.now());
process.nextTick(() => { process.nextTick(() => {
process.exit(exitCode || SUCCESS); process.exit(exitCode || SUCCESS);
}); });
} }
/** // kill the process if no initila connections or heartbeat for X sec (default 10)
* @summary Handle errors function setTerminateTimeout() {
*/ if (ETCHER_TERMINATE_TIMEOUT > 0) {
async function handleError(error: Error) { return setTimeout(() => {
emit('error', toJSON(error)); console.log(
await delay(DISCONNECT_DELAY); `no connections or heartbeat for ${ETCHER_TERMINATE_TIMEOUT} ms, terminating`,
await terminate(GENERAL_ERROR); );
terminate();
}, ETCHER_TERMINATE_TIMEOUT);
} else {
return null;
}
} }
/** // terminate the process cleanly on SIGINT
* @summary Abort handler process.once('SIGINT', async () => {
* @example await terminate(SUCCESS);
});
// terminate the process cleanly on SIGTERM
process.once('SIGTERM', async () => {
await terminate(SUCCESS);
});
let terminateInterval = setTerminateTimeout();
interface EmitLog {
emit: (channel: string, message: object | string) => void;
log: (message: string) => void;
}
function setup(): Promise<EmitLog> {
return new Promise((resolve, reject) => {
wss.on('connection', (ws) => {
console.log('connection established... setting up');
/**
* @summary Send a message to the IPC server
*/ */
const onAbort = async (exitCode: number) => { function emit(type: string, payload?: object | string) {
ws.send(JSON.stringify({ type, payload }));
// ipc.of[IPC_SERVER_ID].emit("message", { type, payload });
}
/**
* @summary Print logs and send them back to client
*/
function log(message: string) {
console.log(message);
emit('log', message);
}
/**
* @summary Handle `errors`
*/
async function handleError(error: Error) {
emit('error', toJSON(error));
await terminate(GENERAL_ERROR);
}
/**
* @summary Handle `abort` from client
*/
const onAbort = async (exitCode: number) => {
log('Abort'); log('Abort');
emit('abort'); emit('abort');
await delay(DISCONNECT_DELAY);
await terminate(exitCode); await terminate(exitCode);
}; };
const onSkip = async (exitCode: number) => { /**
* @summary Handle `skip` from client; skip validation
*/
const onSkip = async (exitCode: number) => {
log('Skip validation'); log('Skip validation');
emit('skip'); emit('skip');
await delay(DISCONNECT_DELAY);
await terminate(exitCode); await terminate(exitCode);
}; };
ipc.connectTo(IPC_SERVER_ID, () => { /**
// Gracefully exit on the following cases. If the parent * @summary Handle `write` from client; start writing to the drives
// process detects that child exit successfully but */
// no flashing information is available, then it will const onWrite = async (options: WriteOptions) => {
// assume that the child died halfway through. log('write requested');
process.once('uncaughtException', handleError); // Remove leftover tmp files older than 1 hour
cleanup(Date.now() - 60 * 60 * 1000);
process.once('SIGINT', async () => { let exitCode = SUCCESS;
await terminate(SUCCESS);
});
process.once('SIGTERM', async () => { // Write to the drives
await terminate(SUCCESS); const results = await write(options);
});
// The IPC server failed. Abort. // handle potential errors from the write process
ipc.of[IPC_SERVER_ID].on('error', async () => { if (results.errors.length > 0) {
await terminate(SUCCESS); results.errors = results.errors.map(toJSON);
}); exitCode = GENERAL_ERROR;
}
// The IPC server was disconnected. Abort. // send the results back to the client
ipc.of[IPC_SERVER_ID].on('disconnect', async () => { emit('done', { results });
await terminate(SUCCESS);
});
ipc.of[IPC_SERVER_ID].on('sourceMetadata', async (params) => { // terminate this process
await terminate(exitCode);
};
/**
* @summary Handle `sourceMetadata` from client; get source metadata
*/
const onSourceMetadata = async (params: any) => {
log('sourceMetadata requested');
const { selected, SourceType, auth } = JSON.parse(params); const { selected, SourceType, auth } = JSON.parse(params);
try { try {
const sourceMatadata = await getSourceMetadata( const sourceMatadata = await getSourceMetadata(
@ -141,63 +183,109 @@ ipc.connectTo(IPC_SERVER_ID, () => {
} catch (error: any) { } catch (error: any) {
emitFail(error); emitFail(error);
} }
};
// handle uncaught exceptions
process.once('uncaughtException', handleError);
// terminate the process if the connection is closed
ws.on('error', async () => {
await terminate(SUCCESS);
}); });
ipc.of[IPC_SERVER_ID].on('scan', async () => { // route messages from the client by `type`
const messagesHandler: any = {
// terminate the process
terminate: () => terminate(SUCCESS),
/*
receive a `heartbeat`, reset the terminate timeout
this mechanism ensure the process will be terminated if the client is disconnected
*/
heartbeat: () => {
if (terminateInterval) {
clearTimeout(terminateInterval);
}
terminateInterval = setTerminateTimeout();
},
// resolve the setup promise when the client is ready
ready: () => {
log('Ready ...');
resolve({ emit, log });
},
// start scanning for drives
scan: () => {
log('Scan requested');
startScanning(); startScanning();
},
// route `cancel` from client
cancel: () => onAbort(GENERAL_ERROR),
// route `skip` from client
skip: () => onSkip(GENERAL_ERROR),
// route `write` from client
write: async (options: WriteOptions) => onWrite(options),
// route `sourceMetadata` from client
sourceMetadata: async (params: any) => onSourceMetadata(params),
};
// message handler, parse and route messages coming on WS
ws.on('message', async (jsonData: any) => {
const data = JSON.parse(jsonData);
const message = messagesHandler[data.type];
if (message) {
await message(data.payload);
} else {
throw new Error(`Unknown message type: ${data.type}`);
}
}); });
// write handler // inform the client that the server is ready to receive messages
ipc.of[IPC_SERVER_ID].on('write', async (options: WriteOptions) => { emit('ready', {});
// Remove leftover tmp files older than 1 hour
cleanup(Date.now() - 60 * 60 * 1000);
let exitCode = SUCCESS; ws.on('error', (error) => {
reject(error);
ipc.of[IPC_SERVER_ID].on('cancel', () => onAbort(exitCode));
ipc.of[IPC_SERVER_ID].on('skip', () => onSkip(exitCode));
const results = await write(options);
if (results.errors.length > 0) {
results.errors = results.errors.map((error: any) => {
return toJSON(error);
}); });
exitCode = GENERAL_ERROR; });
});
}
// setTimeout(() => console.log('wss', wss.address()), 1000);
console.log('waiting for connection...');
setup().then(({ emit, log }: EmitLog) => {
// connection is established, clear initial terminate timeout
if (terminateInterval) {
clearInterval(terminateInterval);
} }
emit('done', { results }); console.log('waiting for instruction...');
await delay(DISCONNECT_DELAY);
await terminate(exitCode);
});
ipc.of[IPC_SERVER_ID].on('connect', () => { // set the exportable emit functions
log( emitLog = (message) => {
`Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`, log(message);
); };
emit('ready', {});
}); emitState = (state) => {
emit('state', state);
};
emitFail = (data) => {
emit('fail', data);
};
emitDrives = (drives) => {
emit('drives', JSON.stringify(values(drives)));
};
emitSourceMetadata = (sourceMetadata) => {
emit('sourceMetadata', JSON.stringify(sourceMetadata));
};
}); });
function emitLog(message: string) {
log(message);
}
function emitState(state: MultiDestinationProgress) {
emit('state', state);
}
function emitFail(data: any) {
emit('fail', data);
}
function emitDrives(drives: Dictionary<DrivelistDrive>) {
emit('drives', JSON.stringify(values(drives)));
}
function emitSourceMetadata(sourceMetadata: any) {
emit('sourceMetadata', JSON.stringify(sourceMetadata));
}
export { emitLog, emitState, emitFail, emitDrives, emitSourceMetadata }; export { emitLog, emitState, emitFail, emitDrives, emitSourceMetadata };

View File

@ -146,7 +146,7 @@ export async function cleanup(until: number) {
* @param {Boolean} autoBlockmapping - whether to trim ext partitions before writing * @param {Boolean} autoBlockmapping - whether to trim ext partitions before writing
* @param {Function} onProgress - function to call on progress * @param {Function} onProgress - function to call on progress
* @param {Function} onFail - function to call on fail * @param {Function} onFail - function to call on fail
* @returns {Promise<{ bytesWritten, devices, errors} >} * @returns {Promise<{ bytesWritten, devices, errors }>}
*/ */
async function writeAndValidate({ async function writeAndValidate({
source, source,

View File

@ -68,7 +68,8 @@ async function getSourceMetadata(
selected: string | DrivelistDrive, selected: string | DrivelistDrive,
SourceType: Source, SourceType: Source,
auth?: Authentication, auth?: Authentication,
) { ): Promise<SourceMetadata | Record<string, never>> {
// `Record<string, never>` means an empty object
if (isString(selected)) { if (isString(selected)) {
const source = await createSource(selected, SourceType, auth); const source = await createSource(selected, SourceType, auth);
@ -80,13 +81,12 @@ async function getSourceMetadata(
return metadata; return metadata;
} catch (error: any) { } catch (error: any) {
// TODO: handle error // TODO: handle error
return {};
} finally { } finally {
try {
await source.close(); await source.close();
} catch (error: any) {
// Noop
}
} }
} else {
return {};
} }
} }

235
npm-shrinkwrap.json generated
View File

@ -9,20 +9,20 @@
"version": "1.19.9", "version": "1.19.9",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@balena/sudo-prompt": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
"@electron/remote": "^2.1.0", "@electron/remote": "^2.1.0",
"@fortawesome/fontawesome-free": "6.5.1", "@fortawesome/fontawesome-free": "6.5.1",
"@ronomon/direct-io": "^3.0.1",
"@sentry/electron": "^4.15.1", "@sentry/electron": "^4.15.1",
"analytics-client": "^2.0.1", "analytics-client": "^2.0.1",
"axios": "^1.6.0", "axios": "^1.6.0",
"debug": "4.3.4", "debug": "4.3.4",
"drivelist": "^12.0.2",
"electron-squirrel-startup": "^1.0.0", "electron-squirrel-startup": "^1.0.0",
"electron-updater": "6.1.7", "electron-updater": "6.1.7",
"etcher-sdk": "9.0.0", "etcher-sdk": "9.0.7",
"i18next": "23.7.8", "i18next": "23.7.8",
"immutable": "3.8.2", "immutable": "3.8.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"node-ipc": "9.2.1",
"outdent": "0.8.0", "outdent": "0.8.0",
"path-is-inside": "1.0.2", "path-is-inside": "1.0.2",
"pretty-bytes": "5.6.0", "pretty-bytes": "5.6.0",
@ -34,7 +34,8 @@
"semver": "7.5.4", "semver": "7.5.4",
"styled-components": "5.3.6", "styled-components": "5.3.6",
"sys-class-rgb-led": "3.0.1", "sys-class-rgb-led": "3.0.1",
"uuid": "9.0.1" "uuid": "9.0.1",
"ws": "^8.16.0"
}, },
"devDependencies": { "devDependencies": {
"@balena/lint": "7.2.4", "@balena/lint": "7.2.4",
@ -53,7 +54,6 @@
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/mocha": "^10.0.6", "@types/mocha": "^10.0.6",
"@types/node": "^20.11.6", "@types/node": "^20.11.6",
"@types/node-ipc": "9.2.3",
"@types/react": "17.0.2", "@types/react": "17.0.2",
"@types/react-dom": "17.0.2", "@types/react-dom": "17.0.2",
"@types/semver": "7.5.6", "@types/semver": "7.5.6",
@ -83,7 +83,11 @@
"xvfb-maybe": "^0.2.1" "xvfb-maybe": "^0.2.1"
}, },
"engines": { "engines": {
"node": ">=18 <20" "node": ">=20 <21"
},
"optionalDependencies": {
"bufferutil": "^4.0.8",
"utf-8-validate": "^5.0.10"
} }
}, },
"node_modules/@aashutoshrathi/word-wrap": { "node_modules/@aashutoshrathi/word-wrap": {
@ -2608,10 +2612,6 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/@balena/sudo-prompt": {
"version": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
"license": "MIT"
},
"node_modules/@balena/udif": { "node_modules/@balena/udif": {
"version": "1.1.2", "version": "1.1.2",
"license": "MIT", "license": "MIT",
@ -5101,8 +5101,9 @@
}, },
"node_modules/@ronomon/direct-io": { "node_modules/@ronomon/direct-io": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@ronomon/direct-io/-/direct-io-3.0.1.tgz",
"integrity": "sha512-NkKB32bjq7RfMdAMiWayphMlVWzsfPiKelK+btXLqggv1vDVgv2xELqeo0z4uYLLt86fVReLPxQj7qpg0zWvow==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@ronomon/queue": "^3.0.1" "@ronomon/queue": "^3.0.1"
} }
@ -6380,14 +6381,6 @@
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"node_modules/@types/node-ipc": {
"version": "9.2.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-json": { "node_modules/@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@ -8505,6 +8498,19 @@
"node": ">=0.2.0" "node": ">=0.2.0"
} }
}, },
"node_modules/bufferutil": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz",
"integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/builder-util-runtime": { "node_modules/builder-util-runtime": {
"version": "9.2.3", "version": "9.2.3",
"license": "MIT", "license": "MIT",
@ -10360,17 +10366,26 @@
} }
}, },
"node_modules/drivelist": { "node_modules/drivelist": {
"version": "11.1.0", "version": "12.0.2",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-12.0.2.tgz",
"integrity": "sha512-Nps4pc1ukIqDj7v00wGgBkS7P3VVEZZKcaTPVcE1Yl+dLojXuEv76BuSg6HgmhjeOFIIMz8q7Y+2tux6gYqCvg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"node-addon-api": "^5.0.0", "node-addon-api": "^8.0.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
}, },
"engines": { "engines": {
"node": ">=16 < 19" "node": ">=18"
}
},
"node_modules/drivelist/node_modules/node-addon-api": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.0.0.tgz",
"integrity": "sha512-ipO7rsHEBqa9STO5C5T10fj732ml+5kLN1cAG8/jdHd56ldQeGj3Q7+scUS+VHK/qy1zLEwC4wMK5+yM0btPvw==",
"engines": {
"node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/ds-store": { "node_modules/ds-store": {
@ -10392,13 +10407,6 @@
"version": "0.2.0", "version": "0.2.0",
"license": "MIT" "license": "MIT"
}, },
"node_modules/easy-stack": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/easymde": { "node_modules/easymde": {
"version": "2.18.0", "version": "2.18.0",
"license": "MIT", "license": "MIT",
@ -11674,8 +11682,9 @@
} }
}, },
"node_modules/etcher-sdk": { "node_modules/etcher-sdk": {
"version": "9.0.0", "version": "9.0.7",
"license": "Apache-2.0", "resolved": "https://registry.npmjs.org/etcher-sdk/-/etcher-sdk-9.0.7.tgz",
"integrity": "sha512-RyTYtZXk2hTg9ZjVu9h6yQ5qFgGD7EraO+BhAElKWP9v0CKScHoVHLL0cWcoqWUBGecriqgu26XMrC3r0obexA==",
"dependencies": { "dependencies": {
"@balena/node-beaglebone-usbboot": "^3.0.0", "@balena/node-beaglebone-usbboot": "^3.0.0",
"@balena/udif": "^1.1.2", "@balena/udif": "^1.1.2",
@ -11687,7 +11696,7 @@
"check-disk-space": "^3.4.0", "check-disk-space": "^3.4.0",
"cyclic-32": "^1.1.0", "cyclic-32": "^1.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"drivelist": "^11.1.0", "drivelist": "^11.2.0",
"file-disk": "^8.0.1", "file-disk": "^8.0.1",
"file-type": "^16.0.0", "file-type": "^16.0.0",
"glob": "^10.3.10", "glob": "^10.3.10",
@ -11720,6 +11729,21 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/etcher-sdk/node_modules/drivelist": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-11.2.2.tgz",
"integrity": "sha512-shzkC4h3Q6sVkF9v9lbT1j49LN47O7h0GJk9E4VtJe81Xp6GF1O36gpnWpqRL6VvFya086eu4XcBEOwSXHHjeQ==",
"hasInstallScript": true,
"dependencies": {
"bindings": "^1.5.0",
"debug": "^4.3.4",
"node-addon-api": "^5.0.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": ">=16"
}
},
"node_modules/etcher-sdk/node_modules/glob": { "node_modules/etcher-sdk/node_modules/glob": {
"version": "10.3.10", "version": "10.3.10",
"license": "ISC", "license": "ISC",
@ -11761,13 +11785,6 @@
"es5-ext": "~0.10.14" "es5-ext": "~0.10.14"
} }
}, },
"node_modules/event-pubsub": {
"version": "4.3.0",
"license": "Unlicense",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "4.0.7", "version": "4.0.7",
"dev": true, "dev": true,
@ -12527,6 +12544,7 @@
"node_modules/fs-xattr": { "node_modules/fs-xattr": {
"version": "0.3.1", "version": "0.3.1",
"dev": true, "dev": true,
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -14672,23 +14690,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/js-message": {
"version": "1.0.7",
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/js-queue": {
"version": "2.0.2",
"license": "MIT",
"dependencies": {
"easy-stack": "^1.0.1"
},
"engines": {
"node": ">=1.0.0"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"license": "MIT" "license": "MIT"
@ -15937,6 +15938,7 @@
"node_modules/macos-alias": { "node_modules/macos-alias": {
"version": "0.2.11", "version": "0.2.11",
"dev": true, "dev": true,
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -17624,18 +17626,6 @@
"node-gyp-build-test": "build-test.js" "node-gyp-build-test": "build-test.js"
} }
}, },
"node_modules/node-ipc": {
"version": "9.2.1",
"license": "MIT",
"dependencies": {
"event-pubsub": "4.3.0",
"js-message": "1.0.7",
"js-queue": "2.0.2"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/node-loader": { "node_modules/node-loader": {
"version": "2.0.0", "version": "2.0.0",
"dev": true, "dev": true,
@ -22552,6 +22542,19 @@
"which": "bin/which" "which": "bin/which"
} }
}, },
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"license": "MIT" "license": "MIT"
@ -23265,9 +23268,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.13.0", "version": "8.16.0",
"dev": true, "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
"license": "MIT", "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },
@ -25227,9 +25230,6 @@
"@balena/node-crc-utils": { "@balena/node-crc-utils": {
"version": "3.0.0" "version": "3.0.0"
}, },
"@balena/sudo-prompt": {
"version": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534"
},
"@balena/udif": { "@balena/udif": {
"version": "1.1.2", "version": "1.1.2",
"requires": { "requires": {
@ -26862,6 +26862,8 @@
}, },
"@ronomon/direct-io": { "@ronomon/direct-io": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@ronomon/direct-io/-/direct-io-3.0.1.tgz",
"integrity": "sha512-NkKB32bjq7RfMdAMiWayphMlVWzsfPiKelK+btXLqggv1vDVgv2xELqeo0z4uYLLt86fVReLPxQj7qpg0zWvow==",
"requires": { "requires": {
"@ronomon/queue": "^3.0.1" "@ronomon/queue": "^3.0.1"
} }
@ -27719,13 +27721,6 @@
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"@types/node-ipc": {
"version": "9.2.3",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/parse-json": { "@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@ -29211,6 +29206,15 @@
"buffers": { "buffers": {
"version": "0.1.1" "version": "0.1.1"
}, },
"bufferutil": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz",
"integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
"optional": true,
"requires": {
"node-gyp-build": "^4.3.0"
}
},
"builder-util-runtime": { "builder-util-runtime": {
"version": "9.2.3", "version": "9.2.3",
"requires": { "requires": {
@ -30399,12 +30403,21 @@
"version": "5.0.1" "version": "5.0.1"
}, },
"drivelist": { "drivelist": {
"version": "11.1.0", "version": "12.0.2",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-12.0.2.tgz",
"integrity": "sha512-Nps4pc1ukIqDj7v00wGgBkS7P3VVEZZKcaTPVcE1Yl+dLojXuEv76BuSg6HgmhjeOFIIMz8q7Y+2tux6gYqCvg==",
"requires": { "requires": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"node-addon-api": "^5.0.0", "node-addon-api": "^8.0.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
},
"dependencies": {
"node-addon-api": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.0.0.tgz",
"integrity": "sha512-ipO7rsHEBqa9STO5C5T10fj732ml+5kLN1cAG8/jdHd56ldQeGj3Q7+scUS+VHK/qy1zLEwC4wMK5+yM0btPvw=="
}
} }
}, },
"ds-store": { "ds-store": {
@ -30423,9 +30436,6 @@
"eastasianwidth": { "eastasianwidth": {
"version": "0.2.0" "version": "0.2.0"
}, },
"easy-stack": {
"version": "1.0.1"
},
"easymde": { "easymde": {
"version": "2.18.0", "version": "2.18.0",
"requires": { "requires": {
@ -31348,7 +31358,9 @@
"dev": true "dev": true
}, },
"etcher-sdk": { "etcher-sdk": {
"version": "9.0.0", "version": "9.0.7",
"resolved": "https://registry.npmjs.org/etcher-sdk/-/etcher-sdk-9.0.7.tgz",
"integrity": "sha512-RyTYtZXk2hTg9ZjVu9h6yQ5qFgGD7EraO+BhAElKWP9v0CKScHoVHLL0cWcoqWUBGecriqgu26XMrC3r0obexA==",
"requires": { "requires": {
"@balena/node-beaglebone-usbboot": "^3.0.0", "@balena/node-beaglebone-usbboot": "^3.0.0",
"@balena/udif": "^1.1.2", "@balena/udif": "^1.1.2",
@ -31360,7 +31372,7 @@
"check-disk-space": "^3.4.0", "check-disk-space": "^3.4.0",
"cyclic-32": "^1.1.0", "cyclic-32": "^1.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"drivelist": "^11.1.0", "drivelist": "^11.2.0",
"file-disk": "^8.0.1", "file-disk": "^8.0.1",
"file-type": "^16.0.0", "file-type": "^16.0.0",
"glob": "^10.3.10", "glob": "^10.3.10",
@ -31387,6 +31399,17 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"drivelist": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-11.2.2.tgz",
"integrity": "sha512-shzkC4h3Q6sVkF9v9lbT1j49LN47O7h0GJk9E4VtJe81Xp6GF1O36gpnWpqRL6VvFya086eu4XcBEOwSXHHjeQ==",
"requires": {
"bindings": "^1.5.0",
"debug": "^4.3.4",
"node-addon-api": "^5.0.0",
"prebuild-install": "^7.1.1"
}
},
"glob": { "glob": {
"version": "10.3.10", "version": "10.3.10",
"requires": { "requires": {
@ -31412,9 +31435,6 @@
"es5-ext": "~0.10.14" "es5-ext": "~0.10.14"
} }
}, },
"event-pubsub": {
"version": "4.3.0"
},
"eventemitter3": { "eventemitter3": {
"version": "4.0.7", "version": "4.0.7",
"dev": true "dev": true
@ -33199,15 +33219,6 @@
"js-cookie": { "js-cookie": {
"version": "3.0.5" "version": "3.0.5"
}, },
"js-message": {
"version": "1.0.7"
},
"js-queue": {
"version": "2.0.2",
"requires": {
"easy-stack": "^1.0.1"
}
},
"js-tokens": { "js-tokens": {
"version": "4.0.0" "version": "4.0.0"
}, },
@ -35157,14 +35168,6 @@
"node-gyp-build": { "node-gyp-build": {
"version": "4.6.1" "version": "4.6.1"
}, },
"node-ipc": {
"version": "9.2.1",
"requires": {
"event-pubsub": "4.3.0",
"js-message": "1.0.7",
"js-queue": "2.0.2"
}
},
"node-loader": { "node-loader": {
"version": "2.0.0", "version": "2.0.0",
"dev": true, "dev": true,
@ -38320,6 +38323,15 @@
} }
} }
}, },
"utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"optional": true,
"requires": {
"node-gyp-build": "^4.3.0"
}
},
"util-deprecate": { "util-deprecate": {
"version": "1.0.2" "version": "1.0.2"
}, },
@ -38798,8 +38810,9 @@
} }
}, },
"ws": { "ws": {
"version": "8.13.0", "version": "8.16.0",
"dev": true, "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
"requires": {} "requires": {}
}, },
"xdg-basedir": { "xdg-basedir": {

View File

@ -31,20 +31,20 @@
"author": "Balena Ltd. <hello@balena.io>", "author": "Balena Ltd. <hello@balena.io>",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@balena/sudo-prompt": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
"@electron/remote": "^2.1.0", "@electron/remote": "^2.1.0",
"@fortawesome/fontawesome-free": "6.5.1", "@fortawesome/fontawesome-free": "6.5.1",
"@ronomon/direct-io": "^3.0.1",
"@sentry/electron": "^4.15.1", "@sentry/electron": "^4.15.1",
"analytics-client": "^2.0.1", "analytics-client": "^2.0.1",
"axios": "^1.6.0", "axios": "^1.6.0",
"debug": "4.3.4", "debug": "4.3.4",
"drivelist": "^12.0.2",
"electron-squirrel-startup": "^1.0.0", "electron-squirrel-startup": "^1.0.0",
"electron-updater": "6.1.7", "electron-updater": "6.1.7",
"etcher-sdk": "9.0.0", "etcher-sdk": "9.0.7",
"i18next": "23.7.8", "i18next": "23.7.8",
"immutable": "3.8.2", "immutable": "3.8.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"node-ipc": "9.2.1",
"outdent": "0.8.0", "outdent": "0.8.0",
"path-is-inside": "1.0.2", "path-is-inside": "1.0.2",
"pretty-bytes": "5.6.0", "pretty-bytes": "5.6.0",
@ -56,7 +56,8 @@
"semver": "7.5.4", "semver": "7.5.4",
"styled-components": "5.3.6", "styled-components": "5.3.6",
"sys-class-rgb-led": "3.0.1", "sys-class-rgb-led": "3.0.1",
"uuid": "9.0.1" "uuid": "9.0.1",
"ws": "^8.16.0"
}, },
"devDependencies": { "devDependencies": {
"@balena/lint": "7.2.4", "@balena/lint": "7.2.4",
@ -75,13 +76,13 @@
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/mocha": "^10.0.6", "@types/mocha": "^10.0.6",
"@types/node": "^20.11.6", "@types/node": "^20.11.6",
"@types/node-ipc": "9.2.3",
"@types/react": "17.0.2", "@types/react": "17.0.2",
"@types/react-dom": "17.0.2", "@types/react-dom": "17.0.2",
"@types/semver": "7.5.6", "@types/semver": "7.5.6",
"@types/sinon": "17.0.2", "@types/sinon": "17.0.2",
"@types/tmp": "0.2.6", "@types/tmp": "0.2.6",
"@vercel/webpack-asset-relocator-loader": "1.7.3", "@vercel/webpack-asset-relocator-loader": "1.7.3",
"@yao-pkg/pkg": "^5.11.1",
"catch-uncommitted": "^2.0.0", "catch-uncommitted": "^2.0.0",
"chai": "4.3.10", "chai": "4.3.10",
"css-loader": "5.2.7", "css-loader": "5.2.7",
@ -93,7 +94,6 @@
"native-addon-loader": "2.0.1", "native-addon-loader": "2.0.1",
"node-loader": "^2.0.0", "node-loader": "^2.0.0",
"omit-deep-lodash": "1.1.7", "omit-deep-lodash": "1.1.7",
"@yao-pkg/pkg": "^5.11.1",
"sinon": "17.0.1", "sinon": "17.0.1",
"string-replace-loader": "3.1.0", "string-replace-loader": "3.1.0",
"style-loader": "3.3.3", "style-loader": "3.3.3",
@ -144,9 +144,13 @@
] ]
}, },
"engines": { "engines": {
"node": ">=18 <20" "node": ">=20 <21"
}, },
"versionist": { "versionist": {
"publishedAt": "2024-04-22T10:20:10.994Z" "publishedAt": "2024-01-26T17:29:27.845Z"
},
"optionalDependencies": {
"bufferutil": "^4.0.8",
"utf-8-validate": "^5.0.10"
} }
} }

View File

@ -1,182 +0,0 @@
/*
* This is a test wrapper for etcher-utils.
* The only use for this file is debugging while developing etcher-utils.
* It will create a IPC server, spawn the cli version of etcher-writer, and wait for it to connect.
* Requires elevated privileges to work (launch with sudo)
* Note that you'll need to to edit `ipc.server.on('ready', ...` function based on what you want to test.
*/
import * as ipc from 'node-ipc';
import * as os from 'os';
import * as path from 'path';
import * as packageJSON from './package.json';
import * as permissions from './lib/shared/permissions';
// if (process.argv.length !== 3) {
// console.error('Expects an image to flash as only arg!');
// process.exit(1);
// }
const THREADS_PER_CPU = 16;
// There might be multiple Etcher instances running at
// the same time, therefore we must ensure each IPC
// server/client has a different name.
const IPC_SERVER_ID = `etcher-server-${process.pid}`;
const IPC_CLIENT_ID = `etcher-client-${process.pid}`;
ipc.config.id = IPC_SERVER_ID;
ipc.config.socketRoot = path.join(
process.env.XDG_RUNTIME_DIR || os.tmpdir(),
path.sep,
);
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
ipc.config.silent = true;
function writerArgv(): string[] {
const entryPoint = path.join('./generated/etcher-util');
return [entryPoint];
}
function writerEnv() {
return {
IPC_SERVER_ID,
IPC_CLIENT_ID,
IPC_SOCKET_ROOT: ipc.config.socketRoot,
UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(),
// This environment variable prevents the AppImages
// desktop integration script from presenting the
// "installation" dialog
SKIP: '1',
...(process.platform === 'win32' ? {} : process.env),
};
}
async function start(): Promise<any> {
ipc.serve();
return await new Promise((resolve, reject) => {
ipc.server.on('error', (message) => {
console.log('IPC server error', message);
});
ipc.server.on('log', (message) => {
console.log('log', message);
});
ipc.server.on('fail', ({ device, error }) => {
console.log('failure', error, device);
});
ipc.server.on('done', (event) => {
console.log('done', event);
});
ipc.server.on('abort', () => {
console.log('abort');
});
ipc.server.on('skip', () => {
console.log('skip');
});
ipc.server.on('state', (progress) => {
console.log('progress', progress);
});
ipc.server.on('drives', (drives) => {
console.log('drives', drives);
});
ipc.server.on('ready', (_data, socket) => {
console.log('ready');
ipc.server.emit(socket, 'scan', {});
// ipc.server.emit(socket, "hello", { message: "world" });
// ipc.server.emit(socket, "write", {
// image: {
// path: process.argv[2],
// displayName: "Random image for test",
// description: "Random image for test",
// SourceType: "File",
// },
// destinations: [
// {
// size: 15938355200,
// isVirtual: false,
// enumerator: "DiskArbitration",
// logicalBlockSize: 512,
// raw: "/dev/rdisk4",
// error: null,
// isReadOnly: false,
// displayName: "/dev/disk4",
// blockSize: 512,
// isSCSI: false,
// isRemovable: true,
// device: "/dev/disk4",
// busVersion: null,
// isSystem: false,
// busType: "USB",
// isCard: false,
// isUSB: true,
// devicePath:
// "IODeviceTree:/arm-io@10F00000/usb-drd1@2280000/usb-drd1-port-hs@01100000",
// mountpoints: [
// {
// path: "/Volumes/flash-rootB",
// label: "flash-rootB",
// },
// {
// path: "/Volumes/flash-rootA",
// label: "flash-rootA",
// },
// {
// path: "/Volumes/flash-boot",
// label: "flash-boot",
// },
// ],
// description: "Generic Flash Disk Media",
// isUAS: null,
// partitionTableType: "mbr",
// },
// ],
// SourceType: "File",
// autoBlockmapping: true,
// decompressFirst: true,
// });
});
const argv = writerArgv();
ipc.server.on('start', async () => {
console.log(`Elevating command: ${argv.join(' ')}`);
const env = writerEnv();
try {
await permissions.elevateCommand(argv, {
applicationName: packageJSON.displayName,
environment: env,
});
} catch (error: any) {
console.log('error', error);
// This happens when the child is killed using SIGKILL
const SIGKILL_EXIT_CODE = 137;
if (error.code === SIGKILL_EXIT_CODE) {
error.code = 'ECHILDDIED';
}
reject(error);
} finally {
console.log('Terminating IPC server');
}
resolve(true);
});
// Clear the update lock timer to prevent longer
// flashing timing it out, and releasing the lock
ipc.server.start();
});
}
start();

View File

@ -1,26 +0,0 @@
/*
* Copyright 2018 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { expect } from 'chai';
import * as ipc from 'node-ipc';
import('../../../lib/gui/modules/child-writer');
describe('Browser: childWriter', function () {
it('should have the ipc config set to silent', function () {
expect(ipc.config.silent).to.be.true;
});
});

View File

@ -17,7 +17,6 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { Drive as DrivelistDrive } from 'drivelist'; import { Drive as DrivelistDrive } from 'drivelist';
import { sourceDestination } from 'etcher-sdk'; import { sourceDestination } from 'etcher-sdk';
import * as ipc from 'node-ipc';
import { assert, SinonStub, stub } from 'sinon'; import { assert, SinonStub, stub } from 'sinon';
import { SourceMetadata } from '../../../lib/gui/app/components/source-selector/source-selector'; import { SourceMetadata } from '../../../lib/gui/app/components/source-selector/source-selector';
@ -140,11 +139,4 @@ describe('Browser: imageWriter', () => {
}); });
}); });
}); });
describe('.performWrite()', function () {
it('should set the ipc config to silent', function () {
// Reset this value as it can persist from other tests
expect(ipc.config.silent).to.be.true;
});
});
}); });

View File

@ -15,6 +15,7 @@
*/ */
import type { Configuration, ModuleOptions } from 'webpack'; import type { Configuration, ModuleOptions } from 'webpack';
import { resolve } from 'path';
import { import {
BannerPlugin, BannerPlugin,
@ -112,8 +113,13 @@ export const rendererConfig: Configuration = {
raw: true, raw: true,
}), }),
], ],
resolve: { resolve: {
extensions: ['.js', '.ts', '.jsx', '.tsx', '.css'], extensions: ['.js', '.ts', '.jsx', '.tsx', '.css'],
alias: {
// need to alias ws to the wrapper to avoid the browser fake version to be used
ws: resolve(__dirname, 'node_modules/ws/wrapper.mjs'),
},
}, },
}; };