mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-22 10:46:31 +00:00
Merge pull request #4185 from balena-io/reverse-control-flow
Patch: switch from node-ipc to ws
This commit is contained in:
commit
0a243caf35
@ -41,8 +41,8 @@ const config: ForgeConfig = {
|
||||
darwinDarkModeSupport: true,
|
||||
protocols: [{ name: 'etcher', schemes: ['etcher'] }],
|
||||
extraResource: [
|
||||
'lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js',
|
||||
'lib/shared/catalina-sudo/sudo-askpass.osascript-en.js',
|
||||
'lib/shared/sudo/sudo-askpass.osascript-zh.js',
|
||||
'lib/shared/sudo/sudo-askpass.osascript-en.js',
|
||||
],
|
||||
osxSign: {
|
||||
optionsForFile: () => ({
|
||||
|
@ -31,7 +31,7 @@ import * as flashState from './models/flash-state';
|
||||
import * as settings from './models/settings';
|
||||
import { Actions, observe, store } from './models/store';
|
||||
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 osDialog from './os/dialog';
|
||||
import * as windowProgress from './os/window-progress';
|
||||
@ -139,11 +139,11 @@ function setDrives(drives: Dictionary<DrivelistDrive>) {
|
||||
export let requestMetadata: any;
|
||||
|
||||
// start the api and spawn the child process
|
||||
startApiAndSpawnChild({
|
||||
spawnChildAndConnect({
|
||||
withPrivileges: false,
|
||||
}).then(({ emit, registerHandler }) => {
|
||||
// start scanning
|
||||
emit('scan');
|
||||
emit('scan', {});
|
||||
|
||||
// make the sourceMetada awaitable to be used on source selection
|
||||
requestMetadata = async (params: any): Promise<SourceMetadata> => {
|
||||
|
@ -12,19 +12,16 @@
|
||||
* - centralise the api for both the writer and the scanner instead of having two instances running
|
||||
*/
|
||||
|
||||
import * as ipc from 'node-ipc';
|
||||
import { spawn } from 'child_process';
|
||||
import WebSocket from 'ws'; // (no types for wrapper, this is expected)
|
||||
import { spawn, exec } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as packageJSON from '../../../../package.json';
|
||||
import * as permissions from '../../../shared/permissions';
|
||||
import * as errors from '../../../shared/errors';
|
||||
|
||||
const THREADS_PER_CPU = 16;
|
||||
|
||||
// NOTE: Ensure this isn't disabled, as it will cause
|
||||
// the stdout maxBuffer size to be exceeded when flashing
|
||||
ipc.config.silent = true;
|
||||
const connectionRetryDelay = 1000;
|
||||
const connectionRetryAttempts = 10;
|
||||
|
||||
async function writerArgv(): Promise<string[]> {
|
||||
let entryPoint = await window.etcher.getEtcherUtilPath();
|
||||
@ -45,15 +42,17 @@ async function writerArgv(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
function writerEnv(
|
||||
IPC_CLIENT_ID: string,
|
||||
IPC_SERVER_ID: string,
|
||||
IPC_SOCKET_ROOT: string,
|
||||
async function spawnChild(
|
||||
withPrivileges: boolean,
|
||||
etcherServerId: string,
|
||||
etcherServerAddress: string,
|
||||
etcherServerPort: string,
|
||||
) {
|
||||
return {
|
||||
IPC_SERVER_ID,
|
||||
IPC_CLIENT_ID,
|
||||
IPC_SOCKET_ROOT,
|
||||
const argv = await writerArgv();
|
||||
const env: any = {
|
||||
ETCHER_SERVER_ADDRESS: etcherServerAddress,
|
||||
ETCHER_SERVER_ID: etcherServerId,
|
||||
ETCHER_SERVER_PORT: etcherServerPort,
|
||||
UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(),
|
||||
// This environment variable prevents the AppImages
|
||||
// desktop integration script from presenting the
|
||||
@ -61,123 +60,192 @@ function writerEnv(
|
||||
SKIP: '1',
|
||||
...(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) {
|
||||
return await permissions.elevateCommand(argv, {
|
||||
console.log('... with privileges ...');
|
||||
return permissions.elevateCommand(argv, {
|
||||
applicationName: packageJSON.displayName,
|
||||
environment: env,
|
||||
});
|
||||
} else {
|
||||
const process = await spawn(argv[0], argv.slice(1), {
|
||||
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) {
|
||||
// Turns out we need to destroy all sockets for
|
||||
// the server to actually close. Otherwise, it
|
||||
// just stops receiving any further connections,
|
||||
// but remains open if there are active ones.
|
||||
// @ts-ignore (no Server.sockets in @types/node-ipc)
|
||||
for (const socket of 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;
|
||||
type ChildApi = {
|
||||
emit: (type: string, payload: any) => void;
|
||||
registerHandler: (event: string, handler: any) => void;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
async function connectToChildProcess(
|
||||
etcherServerAddress: string,
|
||||
etcherServerPort: string,
|
||||
etcherServerId: string,
|
||||
): Promise<ChildApi | { failed: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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
|
||||
ipc.server.on('log', (message: string) => {
|
||||
console.log(message);
|
||||
const url = `ws://${etcherServerAddress}:${etcherServerPort}`;
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
let heartbeat: any;
|
||||
|
||||
const startHeartbeat = (emit: any) => {
|
||||
console.log('start heartbeat');
|
||||
heartbeat = setInterval(() => {
|
||||
emit('heartbeat', {});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopHeartbeat = () => {
|
||||
console.log('stop heartbeat');
|
||||
clearInterval(heartbeat);
|
||||
};
|
||||
|
||||
ws.on('error', (error: any) => {
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
resolve({
|
||||
failed: true,
|
||||
});
|
||||
} else {
|
||||
stopHeartbeat();
|
||||
reject({
|
||||
failed: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('open', () => {
|
||||
const emit = (type: string, payload: any) => {
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
};
|
||||
|
||||
emit('ready', {});
|
||||
|
||||
// parse and route messages
|
||||
const messagesHandler: any = {
|
||||
log: (message: any) => {
|
||||
console.log(`CHILD LOG: ${message}`);
|
||||
},
|
||||
|
||||
error: (error: any) => {
|
||||
const errorObject = errors.fromJSON(error);
|
||||
console.error('CHILD ERROR', errorObject);
|
||||
stopHeartbeat();
|
||||
},
|
||||
|
||||
// once api is ready (means child process is connected) we pass the emit function to the caller
|
||||
ready: () => {
|
||||
console.log('CHILD READY');
|
||||
|
||||
startHeartbeat(emit);
|
||||
|
||||
resolve({
|
||||
failed: false,
|
||||
emit,
|
||||
registerHandler,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
ws.on('message', (jsonData: any) => {
|
||||
const data = JSON.parse(jsonData);
|
||||
const message = messagesHandler[data.type];
|
||||
if (message) {
|
||||
message(data.payload);
|
||||
} else {
|
||||
throw new Error(`Unknown message type: ${data.type}`);
|
||||
}
|
||||
});
|
||||
|
||||
// api to register more handlers with callbacks
|
||||
const registerHandler = (event: string, handler: any) => {
|
||||
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 };
|
||||
|
@ -24,7 +24,7 @@ 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 { startApiAndSpawnChild } from './api';
|
||||
import { spawnChildAndConnect } from './api';
|
||||
|
||||
/**
|
||||
* @summary Handle a flash error and log it to analytics
|
||||
@ -78,15 +78,14 @@ async function performWrite(
|
||||
): Promise<{ cancelled?: boolean }> {
|
||||
const { autoBlockmapping, decompressFirst } = await settings.getAll();
|
||||
|
||||
console.log({ image, drives });
|
||||
|
||||
// Spawn the child process with privileges and wait for the connection to be made
|
||||
const { emit, registerHandler, terminateServer } =
|
||||
await startApiAndSpawnChild({
|
||||
const { emit, registerHandler } = await spawnChildAndConnect({
|
||||
withPrivileges: true,
|
||||
});
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
// if the connection failed, reject the promise
|
||||
|
||||
const flashResults: FlashResults = {};
|
||||
|
||||
const analyticsData = {
|
||||
@ -108,25 +107,25 @@ async function performWrite(
|
||||
finish();
|
||||
};
|
||||
|
||||
const onDone = (event: any) => {
|
||||
console.log('done event');
|
||||
event.results.errors = event.results.errors.map(
|
||||
const onDone = (payload: any) => {
|
||||
console.log('CHILD: flash done', payload);
|
||||
payload.results.errors = payload.results.errors.map(
|
||||
(data: Dictionary<any> & { message: string }) => {
|
||||
return errors.fromJSON(data);
|
||||
},
|
||||
);
|
||||
flashResults.results = event.results;
|
||||
flashResults.results = payload.results;
|
||||
finish();
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
console.log('abort event');
|
||||
console.log('CHILD: flash aborted');
|
||||
flashResults.cancelled = true;
|
||||
finish();
|
||||
};
|
||||
|
||||
const onSkip = () => {
|
||||
console.log('skip event');
|
||||
console.log('CHILD: validation skipped');
|
||||
flashResults.skip = true;
|
||||
finish();
|
||||
};
|
||||
@ -151,8 +150,6 @@ async function performWrite(
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Terminating IPC server');
|
||||
terminateServer();
|
||||
resolve(flashResults);
|
||||
};
|
||||
|
||||
@ -162,7 +159,7 @@ async function performWrite(
|
||||
registerHandler('abort', onAbort);
|
||||
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
|
||||
const parameters = {
|
||||
@ -212,7 +209,9 @@ export async function flash(
|
||||
// start api and call the flasher
|
||||
try {
|
||||
const result = await write(image, drives, flashState.setProgressState);
|
||||
console.log('got results', result);
|
||||
await flashState.unsetFlashingFlag(result);
|
||||
console.log('removed flashing flag');
|
||||
} catch (error: any) {
|
||||
await flashState.unsetFlashingFlag({
|
||||
cancelled: false,
|
||||
|
@ -14,41 +14,27 @@
|
||||
* 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 { promises as fs } from 'fs';
|
||||
import { promisify } from 'util';
|
||||
import * as _ from 'lodash';
|
||||
import * as os from 'os';
|
||||
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';
|
||||
|
||||
const execAsync = promisify(childProcess.exec);
|
||||
const execFileAsync = promisify(childProcess.execFile);
|
||||
|
||||
type Std = string | Buffer | undefined;
|
||||
|
||||
function sudoExecAsync(
|
||||
cmd: string,
|
||||
options: { name: string },
|
||||
): Promise<{ stdout: Std; stderr: Std }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sudoPrompt.exec(
|
||||
cmd,
|
||||
options,
|
||||
(error: Error | undefined, stdout: Std, stderr: Std) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* @summary The user id of the UNIX "superuser"
|
||||
@ -125,10 +111,11 @@ export function createLaunchScript(
|
||||
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 sudoExecAsync(cmd, { name });
|
||||
await winSudo(cmd, name, env);
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
@ -137,7 +124,7 @@ async function elevateScriptUnix(
|
||||
name: string,
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const cmd = ['bash', escapeSh(path)].join(' ');
|
||||
await sudoExecAsync(cmd, { name });
|
||||
await linuxSudo(cmd, { name });
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
@ -146,7 +133,7 @@ async function elevateScriptCatalina(
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const cmd = ['bash', escapeSh(path)].join(' ');
|
||||
try {
|
||||
const { cancelled } = await catalinaSudo(cmd);
|
||||
const { cancelled } = await darwinSudo(cmd);
|
||||
return { cancelled };
|
||||
} catch (error: any) {
|
||||
throw errors.createError({ title: error.stderr });
|
||||
@ -156,13 +143,13 @@ async function elevateScriptCatalina(
|
||||
export async function elevateCommand(
|
||||
command: string[],
|
||||
options: {
|
||||
environment: _.Dictionary<string | undefined>;
|
||||
env: _.Dictionary<string | undefined>;
|
||||
applicationName: string;
|
||||
},
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
if (await isElevated()) {
|
||||
await execFileAsync(command[0], command.slice(1), {
|
||||
env: options.environment,
|
||||
spawn(command[0], command.slice(1), {
|
||||
env: options.env,
|
||||
});
|
||||
return { cancelled: false };
|
||||
}
|
||||
@ -170,7 +157,7 @@ export async function elevateCommand(
|
||||
const launchScript = createLaunchScript(
|
||||
command[0],
|
||||
command.slice(1),
|
||||
options.environment,
|
||||
options.env,
|
||||
);
|
||||
return await withTmpFile(
|
||||
{
|
||||
@ -181,7 +168,7 @@ export async function elevateCommand(
|
||||
async ({ path }) => {
|
||||
await fs.writeFile(path, launchScript);
|
||||
if (isWindows) {
|
||||
return elevateScriptWindows(path, options.applicationName);
|
||||
return elevateScriptWindows(path, options.applicationName, options.env);
|
||||
}
|
||||
if (
|
||||
os.platform() === 'darwin' &&
|
||||
@ -191,7 +178,7 @@ export async function elevateCommand(
|
||||
return elevateScriptCatalina(path);
|
||||
}
|
||||
try {
|
||||
return await elevateScriptUnix(path, options.applicationName);
|
||||
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
|
||||
|
@ -14,14 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { execFile } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { env } from 'process';
|
||||
import { promisify } from 'util';
|
||||
// import { promisify } from "util";
|
||||
|
||||
import { supportedLocales } from '../../gui/app/i18n';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
// const execFileAsync = promisify(execFile);
|
||||
|
||||
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
|
||||
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`;
|
||||
@ -48,22 +48,48 @@ export async function sudo(
|
||||
lang = 'en';
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
const elevateProcess = spawn(
|
||||
'sudo',
|
||||
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
// encoding: "utf8",
|
||||
env: {
|
||||
PATH: env.PATH,
|
||||
SUDO_ASKPASS: getAskPassScriptPath(lang),
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
cancelled: false,
|
||||
stdout: stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length),
|
||||
stderr,
|
||||
};
|
||||
|
||||
let elevated = 'pending';
|
||||
|
||||
elevateProcess.stdout.on('data', (data) => {
|
||||
if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
|
||||
// if the first data comming out of the sudo command is the expected marker we resolve the promise
|
||||
elevated = 'granted';
|
||||
} else {
|
||||
// if the first data comming out of the sudo command is not the expected marker we reject the promise
|
||||
elevated = 'rejected';
|
||||
}
|
||||
});
|
||||
|
||||
// we don't spawn or read stdout in the promise otherwise resolving stop the process
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkElevation = setInterval(() => {
|
||||
if (elevated === 'granted') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: false });
|
||||
} else if (elevated === 'rejected') {
|
||||
clearInterval(checkElevation);
|
||||
resolve({ cancelled: true });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// if the elevation didn't occured in 30 seconds we reject the promise
|
||||
setTimeout(() => {
|
||||
clearInterval(checkElevation);
|
||||
reject(new Error('Elevation timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === 1) {
|
||||
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {
|
142
lib/shared/sudo/linux.ts
Normal file
142
lib/shared/sudo/linux.ts
Normal 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
220
lib/shared/sudo/windows.ts
Normal 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;
|
||||
}
|
332
lib/util/api.ts
332
lib/util/api.ts
@ -14,122 +14,164 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as ipc from 'node-ipc';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { Dictionary, values } from 'lodash';
|
||||
|
||||
import type { MultiDestinationProgress } from 'etcher-sdk/build/multi-write';
|
||||
|
||||
import { toJSON } from '../shared/errors';
|
||||
import { GENERAL_ERROR, SUCCESS } from '../shared/exit-codes';
|
||||
import { delay } from '../shared/utils';
|
||||
import { WriteOptions } from './types/types';
|
||||
import { write, cleanup } from './child-writer';
|
||||
import { startScanning } from './scanner';
|
||||
import { getSourceMetadata } from './source-metadata';
|
||||
import { DrivelistDrive } from '../shared/drive-constraints';
|
||||
import { SourceMetadata } from '../shared/typings/source-selector';
|
||||
|
||||
ipc.config.id = process.env.IPC_CLIENT_ID as string;
|
||||
ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string;
|
||||
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;
|
||||
|
||||
// NOTE: Ensure this isn't disabled, as it will cause
|
||||
// the stdout maxBuffer size to be exceeded when flashing
|
||||
ipc.config.silent = true;
|
||||
const ETCHER_TERMINATE_TIMEOUT: number = parseInt(
|
||||
process.env.ETCHER_TERMINATE_TIMEOUT ?? '10000',
|
||||
10,
|
||||
);
|
||||
|
||||
// > If set to 0, the client will NOT try to reconnect.
|
||||
// See https://github.com/RIAEvangelist/node-ipc/
|
||||
//
|
||||
// The purpose behind this change is for this process
|
||||
// to emit a "disconnect" event as soon as the GUI
|
||||
// process is closed, so we can kill this process as well.
|
||||
const host = ETCHER_SERVER_ADDRESS ?? '127.0.0.1';
|
||||
const port = parseInt(ETCHER_SERVER_PORT || '3434', 10);
|
||||
// const path = ETCHER_SERVER_ID || "etcher";
|
||||
|
||||
// @ts-ignore (0 is a valid value for stopRetrying and is not the same as false)
|
||||
ipc.config.stopRetrying = 0;
|
||||
// TODO: use the path as cheap authentication
|
||||
|
||||
const DISCONNECT_DELAY = 100;
|
||||
const IPC_SERVER_ID = process.env.IPC_SERVER_ID as string;
|
||||
const wss = new WebSocketServer({ host, port });
|
||||
|
||||
/**
|
||||
* @summary Send a message to the IPC server
|
||||
*/
|
||||
function emit(channel: string, message?: any) {
|
||||
ipc.of[IPC_SERVER_ID].emit(channel, message);
|
||||
}
|
||||
// hold emit functions
|
||||
let emitLog: (message: string) => void | undefined;
|
||||
let emitState: (state: MultiDestinationProgress) => void | undefined;
|
||||
let emitFail: (data: any) => void | undefined;
|
||||
let emitDrives: (drives: Dictionary<DrivelistDrive>) => void | undefined;
|
||||
let emitSourceMetadata: (
|
||||
sourceMetadata: SourceMetadata | Record<string, never>,
|
||||
) => void | undefined; // Record<string, never> means an empty object
|
||||
|
||||
/**
|
||||
* @summary Send a log debug message to the IPC server
|
||||
*/
|
||||
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);
|
||||
// Terminate the child process
|
||||
async function terminate(exitCode?: number) {
|
||||
await cleanup(Date.now());
|
||||
process.nextTick(() => {
|
||||
process.exit(exitCode || SUCCESS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Handle errors
|
||||
*/
|
||||
async function handleError(error: Error) {
|
||||
emit('error', toJSON(error));
|
||||
await delay(DISCONNECT_DELAY);
|
||||
await terminate(GENERAL_ERROR);
|
||||
// kill the process if no initila connections or heartbeat for X sec (default 10)
|
||||
function setTerminateTimeout() {
|
||||
if (ETCHER_TERMINATE_TIMEOUT > 0) {
|
||||
return setTimeout(() => {
|
||||
console.log(
|
||||
`no connections or heartbeat for ${ETCHER_TERMINATE_TIMEOUT} ms, terminating`,
|
||||
);
|
||||
terminate();
|
||||
}, ETCHER_TERMINATE_TIMEOUT);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Abort handler
|
||||
* @example
|
||||
// terminate the process cleanly on SIGINT
|
||||
process.once('SIGINT', async () => {
|
||||
await terminate(SUCCESS);
|
||||
});
|
||||
|
||||
// terminate the process cleanly on SIGTERM
|
||||
process.once('SIGTERM', async () => {
|
||||
await terminate(SUCCESS);
|
||||
});
|
||||
|
||||
let terminateInterval = setTerminateTimeout();
|
||||
|
||||
interface EmitLog {
|
||||
emit: (channel: string, message: object | string) => void;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
function setup(): Promise<EmitLog> {
|
||||
return new Promise((resolve, reject) => {
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('connection established... setting up');
|
||||
|
||||
/**
|
||||
* @summary Send a message to the IPC server
|
||||
*/
|
||||
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');
|
||||
emit('abort');
|
||||
await delay(DISCONNECT_DELAY);
|
||||
await terminate(exitCode);
|
||||
};
|
||||
};
|
||||
|
||||
const onSkip = async (exitCode: number) => {
|
||||
/**
|
||||
* @summary Handle `skip` from client; skip validation
|
||||
*/
|
||||
const onSkip = async (exitCode: number) => {
|
||||
log('Skip validation');
|
||||
emit('skip');
|
||||
await delay(DISCONNECT_DELAY);
|
||||
await terminate(exitCode);
|
||||
};
|
||||
};
|
||||
|
||||
ipc.connectTo(IPC_SERVER_ID, () => {
|
||||
// Gracefully exit on the following cases. If the parent
|
||||
// process detects that child exit successfully but
|
||||
// no flashing information is available, then it will
|
||||
// assume that the child died halfway through.
|
||||
/**
|
||||
* @summary Handle `write` from client; start writing to the drives
|
||||
*/
|
||||
const onWrite = async (options: WriteOptions) => {
|
||||
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 () => {
|
||||
await terminate(SUCCESS);
|
||||
});
|
||||
let exitCode = SUCCESS;
|
||||
|
||||
process.once('SIGTERM', async () => {
|
||||
await terminate(SUCCESS);
|
||||
});
|
||||
// Write to the drives
|
||||
const results = await write(options);
|
||||
|
||||
// The IPC server failed. Abort.
|
||||
ipc.of[IPC_SERVER_ID].on('error', async () => {
|
||||
await terminate(SUCCESS);
|
||||
});
|
||||
// handle potential errors from the write process
|
||||
if (results.errors.length > 0) {
|
||||
results.errors = results.errors.map(toJSON);
|
||||
exitCode = GENERAL_ERROR;
|
||||
}
|
||||
|
||||
// The IPC server was disconnected. Abort.
|
||||
ipc.of[IPC_SERVER_ID].on('disconnect', async () => {
|
||||
await terminate(SUCCESS);
|
||||
});
|
||||
// send the results back to the client
|
||||
emit('done', { results });
|
||||
|
||||
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);
|
||||
try {
|
||||
const sourceMatadata = await getSourceMetadata(
|
||||
@ -141,63 +183,109 @@ ipc.connectTo(IPC_SERVER_ID, () => {
|
||||
} catch (error: any) {
|
||||
emitFail(error);
|
||||
}
|
||||
};
|
||||
|
||||
// handle uncaught exceptions
|
||||
process.once('uncaughtException', handleError);
|
||||
|
||||
// terminate the process if the connection is closed
|
||||
ws.on('error', async () => {
|
||||
await terminate(SUCCESS);
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
// 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
|
||||
ipc.of[IPC_SERVER_ID].on('write', async (options: WriteOptions) => {
|
||||
// Remove leftover tmp files older than 1 hour
|
||||
cleanup(Date.now() - 60 * 60 * 1000);
|
||||
// inform the client that the server is ready to receive messages
|
||||
emit('ready', {});
|
||||
|
||||
let exitCode = SUCCESS;
|
||||
|
||||
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);
|
||||
ws.on('error', (error) => {
|
||||
reject(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 });
|
||||
await delay(DISCONNECT_DELAY);
|
||||
await terminate(exitCode);
|
||||
});
|
||||
console.log('waiting for instruction...');
|
||||
|
||||
ipc.of[IPC_SERVER_ID].on('connect', () => {
|
||||
log(
|
||||
`Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`,
|
||||
);
|
||||
emit('ready', {});
|
||||
});
|
||||
// set the exportable emit functions
|
||||
emitLog = (message) => {
|
||||
log(message);
|
||||
};
|
||||
|
||||
emitState = (state) => {
|
||||
emit('state', state);
|
||||
};
|
||||
|
||||
emitFail = (data) => {
|
||||
emit('fail', data);
|
||||
};
|
||||
|
||||
emitDrives = (drives) => {
|
||||
emit('drives', JSON.stringify(values(drives)));
|
||||
};
|
||||
|
||||
emitSourceMetadata = (sourceMetadata) => {
|
||||
emit('sourceMetadata', JSON.stringify(sourceMetadata));
|
||||
};
|
||||
});
|
||||
|
||||
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 };
|
||||
|
@ -146,7 +146,7 @@ export async function cleanup(until: number) {
|
||||
* @param {Boolean} autoBlockmapping - whether to trim ext partitions before writing
|
||||
* @param {Function} onProgress - function to call on progress
|
||||
* @param {Function} onFail - function to call on fail
|
||||
* @returns {Promise<{ bytesWritten, devices, errors} >}
|
||||
* @returns {Promise<{ bytesWritten, devices, errors }>}
|
||||
*/
|
||||
async function writeAndValidate({
|
||||
source,
|
||||
|
@ -68,7 +68,8 @@ async function getSourceMetadata(
|
||||
selected: string | DrivelistDrive,
|
||||
SourceType: Source,
|
||||
auth?: Authentication,
|
||||
) {
|
||||
): Promise<SourceMetadata | Record<string, never>> {
|
||||
// `Record<string, never>` means an empty object
|
||||
if (isString(selected)) {
|
||||
const source = await createSource(selected, SourceType, auth);
|
||||
|
||||
@ -80,13 +81,12 @@ async function getSourceMetadata(
|
||||
return metadata;
|
||||
} catch (error: any) {
|
||||
// TODO: handle error
|
||||
return {};
|
||||
} finally {
|
||||
try {
|
||||
await source.close();
|
||||
} catch (error: any) {
|
||||
// Noop
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
235
npm-shrinkwrap.json
generated
235
npm-shrinkwrap.json
generated
@ -9,20 +9,20 @@
|
||||
"version": "1.19.9",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@balena/sudo-prompt": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
|
||||
"@electron/remote": "^2.1.0",
|
||||
"@fortawesome/fontawesome-free": "6.5.1",
|
||||
"@ronomon/direct-io": "^3.0.1",
|
||||
"@sentry/electron": "^4.15.1",
|
||||
"analytics-client": "^2.0.1",
|
||||
"axios": "^1.6.0",
|
||||
"debug": "4.3.4",
|
||||
"drivelist": "^12.0.2",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"electron-updater": "6.1.7",
|
||||
"etcher-sdk": "9.0.0",
|
||||
"etcher-sdk": "9.0.7",
|
||||
"i18next": "23.7.8",
|
||||
"immutable": "3.8.2",
|
||||
"lodash": "4.17.21",
|
||||
"node-ipc": "9.2.1",
|
||||
"outdent": "0.8.0",
|
||||
"path-is-inside": "1.0.2",
|
||||
"pretty-bytes": "5.6.0",
|
||||
@ -34,7 +34,8 @@
|
||||
"semver": "7.5.4",
|
||||
"styled-components": "5.3.6",
|
||||
"sys-class-rgb-led": "3.0.1",
|
||||
"uuid": "9.0.1"
|
||||
"uuid": "9.0.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@balena/lint": "7.2.4",
|
||||
@ -53,7 +54,6 @@
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/mocha": "^10.0.6",
|
||||
"@types/node": "^20.11.6",
|
||||
"@types/node-ipc": "9.2.3",
|
||||
"@types/react": "17.0.2",
|
||||
"@types/react-dom": "17.0.2",
|
||||
"@types/semver": "7.5.6",
|
||||
@ -83,7 +83,11 @@
|
||||
"xvfb-maybe": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18 <20"
|
||||
"node": ">=20 <21"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8",
|
||||
"utf-8-validate": "^5.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
@ -2608,10 +2612,6 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@balena/sudo-prompt": {
|
||||
"version": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@balena/udif": {
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
@ -5101,8 +5101,9 @@
|
||||
},
|
||||
"node_modules/@ronomon/direct-io": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@ronomon/direct-io/-/direct-io-3.0.1.tgz",
|
||||
"integrity": "sha512-NkKB32bjq7RfMdAMiWayphMlVWzsfPiKelK+btXLqggv1vDVgv2xELqeo0z4uYLLt86fVReLPxQj7qpg0zWvow==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ronomon/queue": "^3.0.1"
|
||||
}
|
||||
@ -6380,14 +6381,6 @@
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
@ -8505,6 +8498,19 @@
|
||||
"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": {
|
||||
"version": "9.2.3",
|
||||
"license": "MIT",
|
||||
@ -10360,17 +10366,26 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"debug": "^4.3.4",
|
||||
"node-addon-api": "^5.0.0",
|
||||
"node-addon-api": "^8.0.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"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": {
|
||||
@ -10392,13 +10407,6 @@
|
||||
"version": "0.2.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/easy-stack": {
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/easymde": {
|
||||
"version": "2.18.0",
|
||||
"license": "MIT",
|
||||
@ -11674,8 +11682,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/etcher-sdk": {
|
||||
"version": "9.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"version": "9.0.7",
|
||||
"resolved": "https://registry.npmjs.org/etcher-sdk/-/etcher-sdk-9.0.7.tgz",
|
||||
"integrity": "sha512-RyTYtZXk2hTg9ZjVu9h6yQ5qFgGD7EraO+BhAElKWP9v0CKScHoVHLL0cWcoqWUBGecriqgu26XMrC3r0obexA==",
|
||||
"dependencies": {
|
||||
"@balena/node-beaglebone-usbboot": "^3.0.0",
|
||||
"@balena/udif": "^1.1.2",
|
||||
@ -11687,7 +11696,7 @@
|
||||
"check-disk-space": "^3.4.0",
|
||||
"cyclic-32": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"drivelist": "^11.1.0",
|
||||
"drivelist": "^11.2.0",
|
||||
"file-disk": "^8.0.1",
|
||||
"file-type": "^16.0.0",
|
||||
"glob": "^10.3.10",
|
||||
@ -11720,6 +11729,21 @@
|
||||
"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": {
|
||||
"version": "10.3.10",
|
||||
"license": "ISC",
|
||||
@ -11761,13 +11785,6 @@
|
||||
"es5-ext": "~0.10.14"
|
||||
}
|
||||
},
|
||||
"node_modules/event-pubsub": {
|
||||
"version": "4.3.0",
|
||||
"license": "Unlicense",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"dev": true,
|
||||
@ -12527,6 +12544,7 @@
|
||||
"node_modules/fs-xattr": {
|
||||
"version": "0.3.1",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -14672,23 +14690,6 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT"
|
||||
@ -15937,6 +15938,7 @@
|
||||
"node_modules/macos-alias": {
|
||||
"version": "0.2.11",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -17624,18 +17626,6 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
@ -22552,6 +22542,19 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"license": "MIT"
|
||||
@ -23265,9 +23268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.13.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
|
||||
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
@ -25227,9 +25230,6 @@
|
||||
"@balena/node-crc-utils": {
|
||||
"version": "3.0.0"
|
||||
},
|
||||
"@balena/sudo-prompt": {
|
||||
"version": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534"
|
||||
},
|
||||
"@balena/udif": {
|
||||
"version": "1.1.2",
|
||||
"requires": {
|
||||
@ -26862,6 +26862,8 @@
|
||||
},
|
||||
"@ronomon/direct-io": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@ronomon/direct-io/-/direct-io-3.0.1.tgz",
|
||||
"integrity": "sha512-NkKB32bjq7RfMdAMiWayphMlVWzsfPiKelK+btXLqggv1vDVgv2xELqeo0z4uYLLt86fVReLPxQj7qpg0zWvow==",
|
||||
"requires": {
|
||||
"@ronomon/queue": "^3.0.1"
|
||||
}
|
||||
@ -27719,13 +27721,6 @@
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"@types/node-ipc": {
|
||||
"version": "9.2.3",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
@ -29211,6 +29206,15 @@
|
||||
"buffers": {
|
||||
"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": {
|
||||
"version": "9.2.3",
|
||||
"requires": {
|
||||
@ -30399,12 +30403,21 @@
|
||||
"version": "5.0.1"
|
||||
},
|
||||
"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": {
|
||||
"bindings": "^1.5.0",
|
||||
"debug": "^4.3.4",
|
||||
"node-addon-api": "^5.0.0",
|
||||
"node-addon-api": "^8.0.0",
|
||||
"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": {
|
||||
@ -30423,9 +30436,6 @@
|
||||
"eastasianwidth": {
|
||||
"version": "0.2.0"
|
||||
},
|
||||
"easy-stack": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"easymde": {
|
||||
"version": "2.18.0",
|
||||
"requires": {
|
||||
@ -31348,7 +31358,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"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": {
|
||||
"@balena/node-beaglebone-usbboot": "^3.0.0",
|
||||
"@balena/udif": "^1.1.2",
|
||||
@ -31360,7 +31372,7 @@
|
||||
"check-disk-space": "^3.4.0",
|
||||
"cyclic-32": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"drivelist": "^11.1.0",
|
||||
"drivelist": "^11.2.0",
|
||||
"file-disk": "^8.0.1",
|
||||
"file-type": "^16.0.0",
|
||||
"glob": "^10.3.10",
|
||||
@ -31387,6 +31399,17 @@
|
||||
"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": {
|
||||
"version": "10.3.10",
|
||||
"requires": {
|
||||
@ -31412,9 +31435,6 @@
|
||||
"es5-ext": "~0.10.14"
|
||||
}
|
||||
},
|
||||
"event-pubsub": {
|
||||
"version": "4.3.0"
|
||||
},
|
||||
"eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"dev": true
|
||||
@ -33199,15 +33219,6 @@
|
||||
"js-cookie": {
|
||||
"version": "3.0.5"
|
||||
},
|
||||
"js-message": {
|
||||
"version": "1.0.7"
|
||||
},
|
||||
"js-queue": {
|
||||
"version": "2.0.2",
|
||||
"requires": {
|
||||
"easy-stack": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"js-tokens": {
|
||||
"version": "4.0.0"
|
||||
},
|
||||
@ -35157,14 +35168,6 @@
|
||||
"node-gyp-build": {
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"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": {
|
||||
"version": "1.0.2"
|
||||
},
|
||||
@ -38798,8 +38810,9 @@
|
||||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "8.13.0",
|
||||
"dev": true,
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
|
||||
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"xdg-basedir": {
|
||||
|
20
package.json
20
package.json
@ -31,20 +31,20 @@
|
||||
"author": "Balena Ltd. <hello@balena.io>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@balena/sudo-prompt": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
|
||||
"@electron/remote": "^2.1.0",
|
||||
"@fortawesome/fontawesome-free": "6.5.1",
|
||||
"@ronomon/direct-io": "^3.0.1",
|
||||
"@sentry/electron": "^4.15.1",
|
||||
"analytics-client": "^2.0.1",
|
||||
"axios": "^1.6.0",
|
||||
"debug": "4.3.4",
|
||||
"drivelist": "^12.0.2",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"electron-updater": "6.1.7",
|
||||
"etcher-sdk": "9.0.0",
|
||||
"etcher-sdk": "9.0.7",
|
||||
"i18next": "23.7.8",
|
||||
"immutable": "3.8.2",
|
||||
"lodash": "4.17.21",
|
||||
"node-ipc": "9.2.1",
|
||||
"outdent": "0.8.0",
|
||||
"path-is-inside": "1.0.2",
|
||||
"pretty-bytes": "5.6.0",
|
||||
@ -56,7 +56,8 @@
|
||||
"semver": "7.5.4",
|
||||
"styled-components": "5.3.6",
|
||||
"sys-class-rgb-led": "3.0.1",
|
||||
"uuid": "9.0.1"
|
||||
"uuid": "9.0.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@balena/lint": "7.2.4",
|
||||
@ -75,13 +76,13 @@
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/mocha": "^10.0.6",
|
||||
"@types/node": "^20.11.6",
|
||||
"@types/node-ipc": "9.2.3",
|
||||
"@types/react": "17.0.2",
|
||||
"@types/react-dom": "17.0.2",
|
||||
"@types/semver": "7.5.6",
|
||||
"@types/sinon": "17.0.2",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@vercel/webpack-asset-relocator-loader": "1.7.3",
|
||||
"@yao-pkg/pkg": "^5.11.1",
|
||||
"catch-uncommitted": "^2.0.0",
|
||||
"chai": "4.3.10",
|
||||
"css-loader": "5.2.7",
|
||||
@ -93,7 +94,6 @@
|
||||
"native-addon-loader": "2.0.1",
|
||||
"node-loader": "^2.0.0",
|
||||
"omit-deep-lodash": "1.1.7",
|
||||
"@yao-pkg/pkg": "^5.11.1",
|
||||
"sinon": "17.0.1",
|
||||
"string-replace-loader": "3.1.0",
|
||||
"style-loader": "3.3.3",
|
||||
@ -144,9 +144,13 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18 <20"
|
||||
"node": ">=20 <21"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
182
test-wrapper.ts
182
test-wrapper.ts
@ -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();
|
@ -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;
|
||||
});
|
||||
});
|
@ -17,7 +17,6 @@
|
||||
import { expect } from 'chai';
|
||||
import { Drive as DrivelistDrive } from 'drivelist';
|
||||
import { sourceDestination } from 'etcher-sdk';
|
||||
import * as ipc from 'node-ipc';
|
||||
import { assert, SinonStub, stub } from 'sinon';
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { Configuration, ModuleOptions } from 'webpack';
|
||||
import { resolve } from 'path';
|
||||
|
||||
import {
|
||||
BannerPlugin,
|
||||
@ -112,8 +113,13 @@ export const rendererConfig: Configuration = {
|
||||
raw: true,
|
||||
}),
|
||||
],
|
||||
|
||||
resolve: {
|
||||
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'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user