diff --git a/forge.config.ts b/forge.config.ts index 5b69bbe563..3c5aea80ca 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -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: () => ({ diff --git a/lib/gui/app/app.ts b/lib/gui/app/app.ts index 15e5025414..6a52214b7d 100644 --- a/lib/gui/app/app.ts +++ b/lib/gui/app/app.ts @@ -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) { 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 => { diff --git a/lib/gui/app/modules/api.ts b/lib/gui/app/modules/api.ts index efea3fc6eb..a5514fdf89 100644 --- a/lib/gui/app/modules/api.ts +++ b/lib/gui/app/modules/api.ts @@ -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 { let entryPoint = await window.etcher.getEtcherUtilPath(); @@ -45,15 +42,17 @@ async function writerArgv(): Promise { } } -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,133 +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, + env, }); } else { - const process = await spawn(argv[0], argv.slice(1), { + 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, process }; + 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(); -} +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 { + return new Promise((resolve, reject) => { + // TODO: default to IPC connections https://github.com/websockets/ws/blob/master/doc/ws.md#ipc-connections + // TOOD: use the path as cheap authentication + console.log(etcherServerId); -// TODO: replace the custom ipc events by one generic "message" for all communication with the backend -function startApiAndSpawnChild({ - withPrivileges, -}: { - withPrivileges: boolean; -}): Promise { - // 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, - ); + const url = `ws://${etcherServerAddress}:${etcherServerPort}`; - ipc.config.id = IPC_SERVER_ID; - ipc.config.socketRoot = IPC_SOCKET_ROOT; + const ws = new WebSocket(url); - return new Promise((resolve, reject) => { - ipc.serve(); - - // parse and route messages - const messagesHandler: any = { - log: (message: any) => { - console.log(message); - }, - - error: (error: any) => { - terminateServer(ipc.server); - const errorObject = errors.fromJSON(error); - reject(errorObject); - }, - - // once api is ready (means child process is connected) we pass the emit and terminate function to the caller - ready: (_: any, socket: any) => { - const emit = (type: string, payload: any) => { - ipc.server.emit(socket, 'message', { type, payload }); - }; - resolve({ - emit, - terminateServer: () => terminateServer(ipc.server), - registerHandler, - }); - }, - }; + let heartbeat: any; - ipc.server.on('message', (data: any, socket: any) => { - const message = messagesHandler[data.type]; - if (message) { - message(data.payload, socket); - } else { - throw new Error(`Unknown message type: ${data.type}`); - } - }); + const startHeartbeat = (emit: any) => { + console.log('start heartbeat'); + heartbeat = setInterval(() => { + emit('heartbeat', {}); + }, 1000); + }; - // api to register more handlers with callbacks - const registerHandler = (event: string, handler: any) => { - messagesHandler[event] = handler; + const stopHeartbeat = () => { + console.log('stop heartbeat'); + clearInterval(heartbeat); }; - // 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, + ws.on('error', (error: any) => { + if (error.code === 'ECONNREFUSED') { + resolve({ + failed: true, + }); + } else { + stopHeartbeat(); + reject({ + failed: true, }); - // 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(); + 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) => { + messagesHandler[event] = handler; + }; + }); }); } -export { startApiAndSpawnChild }; +async function spawnChildAndConnect({ + withPrivileges, +}: { + withPrivileges: boolean; +}): Promise { + 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 }; diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts index d014f0ad3d..71804f8f5c 100644 --- a/lib/gui/app/modules/image-writer.ts +++ b/lib/gui/app/modules/image-writer.ts @@ -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({ - withPrivileges: true, - }); + 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 & { 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, diff --git a/lib/shared/permissions.ts b/lib/shared/permissions.ts index 699e7fb3b4..acd671920a 100755 --- a/lib/shared/permissions.ts +++ b/lib/shared/permissions.ts @@ -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; + env: _.Dictionary; 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 diff --git a/lib/shared/catalina-sudo/sudo.ts b/lib/shared/sudo/darwin.ts similarity index 62% rename from lib/shared/catalina-sudo/sudo.ts rename to lib/shared/sudo/darwin.ts index b2c044ce71..3bc13b4568 100644 --- a/lib/shared/catalina-sudo/sudo.ts +++ b/lib/shared/sudo/darwin.ts @@ -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)) { diff --git a/lib/shared/sudo/linux.ts b/lib/shared/sudo/linux.ts new file mode 100644 index 0000000000..18d717b307 --- /dev/null +++ b/lib/shared/sudo/linux.ts @@ -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); + }); +} diff --git a/lib/shared/catalina-sudo/sudo-askpass.osascript-en.js b/lib/shared/sudo/sudo-askpass.osascript-en.js similarity index 100% rename from lib/shared/catalina-sudo/sudo-askpass.osascript-en.js rename to lib/shared/sudo/sudo-askpass.osascript-en.js diff --git a/lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js b/lib/shared/sudo/sudo-askpass.osascript-zh.js similarity index 100% rename from lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js rename to lib/shared/sudo/sudo-askpass.osascript-zh.js diff --git a/lib/shared/sudo/windows.ts b/lib/shared/sudo/windows.ts new file mode 100644 index 0000000000..0fad1ee2d8 --- /dev/null +++ b/lib/shared/sudo/windows.ts @@ -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; +} diff --git a/lib/util/api.ts b/lib/util/api.ts index eba6fb1dc1..4b0e1abeb8 100644 --- a/lib/util/api.ts +++ b/lib/util/api.ts @@ -14,205 +14,278 @@ * 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 }); -console.log('starting '); -if (!IPC_SERVER_ID) { - console.log('IPC_SERVER_ID is not defined, exiting'); -} - -/** - * @summary Send a message to the IPC server - */ -function emit(type: string, payload?: any) { - ipc.of[IPC_SERVER_ID].emit('message', { type, payload }); -} +// 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) => void | undefined; +let emitSourceMetadata: ( + sourceMetadata: SourceMetadata | Record, +) => void | undefined; // Record 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 - */ -const onAbort = async (exitCode: number) => { - log('Abort'); - emit('abort'); - await delay(DISCONNECT_DELAY); - await terminate(exitCode); -}; - -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. - - process.once('uncaughtException', handleError); - - process.once('SIGINT', async () => { - await terminate(SUCCESS); - }); - - process.once('SIGTERM', async () => { - await terminate(SUCCESS); - }); - - // The IPC server failed. Abort. - ipc.of[IPC_SERVER_ID].on('error', async () => { - await terminate(SUCCESS); - }); - - // The IPC server was disconnected. Abort. - ipc.of[IPC_SERVER_ID].on('disconnect', async () => { - await terminate(SUCCESS); - }); - - const messagesHandler: any = { - scan: () => { - startScanning(); - }, - - write: async (options: WriteOptions) => { - // Remove leftover tmp files older than 1 hour - cleanup(Date.now() - 60 * 60 * 1000); +// terminate the process cleanly on SIGINT +process.once('SIGINT', async () => { + await terminate(SUCCESS); +}); - let exitCode = SUCCESS; +// terminate the process cleanly on SIGTERM +process.once('SIGTERM', async () => { + await terminate(SUCCESS); +}); - ipc.of[IPC_SERVER_ID].on('cancel', () => onAbort(exitCode)); +let terminateInterval = setTerminateTimeout(); - ipc.of[IPC_SERVER_ID].on('skip', () => onSkip(exitCode)); +interface EmitLog { + emit: (channel: string, message: object | string) => void; + log: (message: string) => void; +} - const results = await write(options); +function setup(): Promise { + return new Promise((resolve, reject) => { + wss.on('connection', (ws) => { + console.log('connection established... setting up'); + + /** + * @summary Send a message to the IPC server + */ + function emit(type: string, payload?: object | string) { + ws.send(JSON.stringify({ type, payload })); + // ipc.of[IPC_SERVER_ID].emit("message", { type, payload }); + } - if (results.errors.length > 0) { - results.errors = results.errors.map((error: any) => { - return toJSON(error); - }); - exitCode = GENERAL_ERROR; + /** + * @summary Print logs and send them back to client + */ + function log(message: string) { + console.log(message); + emit('log', message); } - emit('done', { results }); - await delay(DISCONNECT_DELAY); - await terminate(exitCode); - }, - - sourceMetadata: async (params: any) => { - const { selected, SourceType, auth } = JSON.parse(params); - try { - const sourceMatadata = await getSourceMetadata( - selected, - SourceType, - auth, - ); - emitSourceMetadata(sourceMatadata); - } catch (error: any) { - emitFail(error); + /** + * @summary Handle `errors` + */ + async function handleError(error: Error) { + emit('error', toJSON(error)); + await terminate(GENERAL_ERROR); } - }, - }; - ipc.of[IPC_SERVER_ID].on('message', async (data: any) => { - const message = messagesHandler[data.type]; - if (message) { - await message(data.payload); - } else { - throw new Error(`Unknown message type: ${data.type}`); - } + /** + * @summary Handle `abort` from client + */ + const onAbort = async (exitCode: number) => { + log('Abort'); + emit('abort'); + await terminate(exitCode); + }; + + /** + * @summary Handle `skip` from client; skip validation + */ + const onSkip = async (exitCode: number) => { + log('Skip validation'); + emit('skip'); + await terminate(exitCode); + }; + + /** + * @summary Handle `write` from client; start writing to the drives + */ + const onWrite = async (options: WriteOptions) => { + log('write requested'); + + // Remove leftover tmp files older than 1 hour + cleanup(Date.now() - 60 * 60 * 1000); + + let exitCode = SUCCESS; + + // Write to the drives + const results = await write(options); + + // handle potential errors from the write process + if (results.errors.length > 0) { + results.errors = results.errors.map(toJSON); + exitCode = GENERAL_ERROR; + } + + // send the results back to the client + emit('done', { results }); + + // 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( + selected, + SourceType, + auth, + ); + emitSourceMetadata(sourceMatadata); + } 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); + }); + + // 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}`); + } + }); + + // inform the client that the server is ready to receive messages + emit('ready', {}); + + ws.on('error', (error) => { + reject(error); + }); + }); }); +} - ipc.of[IPC_SERVER_ID].on('connect', () => { - log( - `Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`, - ); - emit('ready', {}); - }); -}); +// setTimeout(() => console.log('wss', wss.address()), 1000); +console.log('waiting for connection...'); -function emitLog(message: string) { - log(message); -} +setup().then(({ emit, log }: EmitLog) => { + // connection is established, clear initial terminate timeout + if (terminateInterval) { + clearInterval(terminateInterval); + } -function emitState(state: MultiDestinationProgress) { - emit('state', state); -} + console.log('waiting for instruction...'); -function emitFail(data: any) { - emit('fail', data); -} + // set the exportable emit functions + emitLog = (message) => { + log(message); + }; -function emitDrives(drives: Dictionary) { - emit('drives', JSON.stringify(values(drives))); -} + emitState = (state) => { + emit('state', state); + }; -function emitSourceMetadata(sourceMetadata: any) { - emit('sourceMetadata', JSON.stringify(sourceMetadata)); -} + emitFail = (data) => { + emit('fail', data); + }; + + emitDrives = (drives) => { + emit('drives', JSON.stringify(values(drives))); + }; + + emitSourceMetadata = (sourceMetadata) => { + emit('sourceMetadata', JSON.stringify(sourceMetadata)); + }; +}); export { emitLog, emitState, emitFail, emitDrives, emitSourceMetadata }; diff --git a/lib/util/child-writer.ts b/lib/util/child-writer.ts index c63de30f96..ddc860fc13 100644 --- a/lib/util/child-writer.ts +++ b/lib/util/child-writer.ts @@ -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, diff --git a/lib/util/source-metadata.ts b/lib/util/source-metadata.ts index 90514af627..64b9447f53 100644 --- a/lib/util/source-metadata.ts +++ b/lib/util/source-metadata.ts @@ -68,7 +68,8 @@ async function getSourceMetadata( selected: string | DrivelistDrive, SourceType: Source, auth?: Authentication, -) { +): Promise> { + // `Record` 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 - } + await source.close(); } + } else { + return {}; } } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index e955ac095e..265cf1f265 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -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", @@ -84,6 +84,10 @@ }, "engines": { "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": { diff --git a/package.json b/package.json index 260b646152..13961dad02 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,17 @@ "author": "Balena Ltd. ", "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", @@ -56,7 +57,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", @@ -82,6 +84,7 @@ "@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 +96,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", @@ -147,6 +149,10 @@ "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" } } diff --git a/webpack.config.ts b/webpack.config.ts index c3fc12ff2d..6db09b0520 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -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'), + }, }, };