Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
6bc89d73dThis file is pinned to the selected release. Download the original to open it in your design tool.
/*
* Copyright (C) 2021 Sienci Labs Inc.
*
* This file is part of gSender.
*
* gSender is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, under version 3 of the License.
*
* gSender is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with gSender. If not, see <https://www.gnu.org/licenses/>.
*
* Contact for information regarding this program and its license
* can be sent through gSender@sienci.com or mailed to the main office
* of Sienci Labs Inc. in Waterloo, Ontario, Canada.
*
*/
import * as Sentry from '@sentry/electron/main';
import chalk from 'chalk';
import {
app,
clipboard,
dialog,
ipcMain,
powerMonitor,
powerSaveBlocker,
screen,
session,
} from 'electron';
import log from 'electron-log';
import Store from 'electron-store';
import { autoUpdater } from 'electron-updater';
import fs from 'fs';
import isOnline from 'is-online';
import mkdirp from 'mkdirp';
import path from 'path';
import WinReg from 'winreg';
import { asyncCallWithTimeout } from './electron-app/AsyncTimeout';
import { getGRBLLog } from './electron-app/grblLogs';
import { createPendantWindow } from './electron-app/pendant-window';
import { parseAndReturnGCode } from './electron-app/RecentFiles';
import WindowManager from './electron-app/WindowManager';
import pkg from './package.json';
import launchServer from './server-cli';
// Reads the renderer's persisted settings directly, since the main process
// needs to know "use pendant view as default UI" before any window exists.
const readUsePendantViewSetting = () => {
try {
const configPath = path.join(
app.getPath('userData'),
'gsender-0.5.6.json',
);
if (!fs.existsSync(configPath)) return false;
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8') || '{}');
return !!raw?.state?.workspace?.usePendantViewAsDefault;
} catch (err) {
log.error(`Failed to read pendant-view setting: ${err}`);
return false;
}
};
// Hot reload in development
if (process.env.NODE_ENV === 'development') {
try {
require('electron-reloader')(module, {
debug: false,
watchRenderer: false, // Vite handles frontend
ignore: [/node_modules/, /output\/app/, /\.map$/],
});
} catch (err) {
// electron-reloader not available, continue without it
}
}
let windowManager = null;
let hostInformation = {};
const grblLog = log.create('grbl');
let logPath;
let pluginPath;
let powerBlockerNum = 0;
const externalRendererUrl =
process.env.NODE_ENV === 'development'
? process.env.ELECTRON_RENDERER_URL
: '';
if (process.env.NODE_ENV === 'production') {
Sentry.init({
dsn: 'https://eeb4899f0415aa6bc9de477a7faeb720@o558751.ingest.us.sentry.io/4509479105986560',
release: pkg.version,
});
}
const main = () => {
// https://github.com/electron/electron/blob/master/docs/api/app.md#apprequestsingleinstancelock
const gotSingleInstanceLock = app.requestSingleInstanceLock();
const shouldQuitImmediately = !gotSingleInstanceLock;
// Initialize remote main
require('@electron/remote/main').initialize();
let prevDirectory = '';
let pendingFileToOpen = null;
let isRendererReady = false;
if (shouldQuitImmediately) {
app.quit();
return;
}
app.on('second-instance', (event, commandLine, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (!windowManager) {
return;
}
const window = windowManager.getWindow();
if (window) {
if (window.isMinimized()) {
window.restore();
}
window.focus();
const filePath = commandLine.find((arg) =>
/\.(gcode|gc|nc|tap|cnc)$/i.test(arg),
);
if (filePath) {
loadFileAssociation(filePath, window);
}
}
});
app.on('open-file', (event, filePath) => {
event.preventDefault();
const window = windowManager?.getWindow();
if (window && isRendererReady) {
loadFileAssociation(filePath, window);
} else {
pendingFileToOpen = filePath;
}
});
const store = new Store();
// Increase V8 heap size of the main process
if (process.arch === 'x64') {
const memoryLimit = 1024 * 8; // 8GB
app.commandLine.appendSwitch(
'--js-flags',
`--max-old-space-size=${memoryLimit}`,
);
}
if (process.platform === 'linux') {
app.commandLine.appendSwitch('--no-sandbox');
}
// Create the user data directory if it does not exist
const userData = app.getPath('userData');
mkdirp.sync(userData);
// Extra logging
logPath = path.join(app.getPath('userData'), 'logs/grbl.log');
grblLog.transports.file.resolvePath = () => logPath;
pluginPath = path.join(app.getPath('userData'), 'plugins');
const loadFileAssociation = async (filePath, window) => {
try {
const fileMetadata = await parseAndReturnGCode({ filePath });
window.webContents.send('returned-upload-dialog-data', {
data: fileMetadata.result,
size: fileMetadata.size,
name: fileMetadata.name,
path: fileMetadata.fullPath,
});
} catch (err) {
log.error(`Error loading file association: ${err}`);
}
};
const openDirectoryDialog = async () => {
try {
const gSenderWindow = windowManager.getWindow();
const directory = await dialog.showOpenDialog(gSenderWindow, {
properties: ['openDirectory'],
});
if (!directory) {
return;
}
if (directory.canceled) {
return;
}
const FULL_PATH = directory.filePaths[0];
return FULL_PATH;
} catch (e) {
log.error(`Caught error in listener - ${e}`);
}
};
app.whenReady().then(async () => {
try {
await session.defaultSession.clearCache();
// Plugin iframes are same-origin with the app, so a single app-wide
// grant is enough — per-plugin scoping happens at the iframe's `allow`
// attribute (see PluginPanel.tsx), which only delegates a permission to
// a given plugin's iframe when that plugin declared it in its manifest
// and the user approved it during install.
//
// There is deliberately no fixed list of permission names here: any
// browser permission (camera, microphone, geolocation, midi, etc.) a
// plugin declares and the user approves is allowed at the session
// level, same as we already did for "local-fonts". Adding support for
// a new browser feature to a plugin never requires a gSender code
// change — only the manifest and the install-review approval.
//
// Clipboard write permission is required for navigator.clipboard.writeText()
// call sites throughout the renderer (Console copy history, gcode editor, etc),
// independent of any plugin, so it's always allowed.
const CORE_SESSION_PERMISSIONS = ['clipboard-sanitized-write'];
const isSessionPermissionAllowed = (permission) => {
if (CORE_SESSION_PERMISSIONS.includes(permission)) {
return true;
}
try {
// Lazily required: this module reads app.getPath()-derived config
// paths, so it must not load until Electron (and the rest of the
// app's startup sequence) is actually ready.
const pluginRegistry =
require('./server/services/pluginregistry').default;
return pluginRegistry
.getEnabledPlugins()
.some((plugin) =>
(plugin.permissions || []).includes(permission),
);
} catch (err) {
log.error(
`Failed to resolve plugin permission "${permission}": ${err}`,
);
return false;
}
};
session.defaultSession.setPermissionCheckHandler(
(_webContents, permission) =>
isSessionPermissionAllowed(permission),
);
session.defaultSession.setPermissionRequestHandler(
(_webContents, permission, callback) => {
callback(isSessionPermissionAllowed(permission));
},
);
windowManager = new WindowManager();
// Create and show splash before server starts
const splashScreen = windowManager.createSplashScreen({
width: 600,
height: 400,
show: false,
frame: false,
transparent: true,
backgroundColor: '#00000000',
});
splashScreen.loadFile(
path.join(__dirname, 'app/assets/Splashscreen.webp'),
);
splashScreen.webContents.on('did-finish-load', () => {
splashScreen.show();
});
splashScreen.on('show', () => {
splashScreen.focus();
});
let url = '';
let kiosk = false;
let usePendantView = readUsePendantViewSetting();
if (usePendantView && externalRendererUrl) {
log.warn(
'Pendant-as-default-UI is not supported under electron:dev; falling back to the normal dev window.',
);
usePendantView = false;
}
if (externalRendererUrl) {
url = externalRendererUrl;
try {
const parsedUrl = new URL(url);
hostInformation = {
address: parsedUrl.hostname,
port:
Number(parsedUrl.port) ||
(parsedUrl.protocol === 'https:' ? 443 : 80),
};
} catch (error) {
hostInformation = {};
}
log.info(`Using external renderer URL in development: ${url}`);
} else if (process.env.NODE_ENV === 'development') {
const errorMessage =
'ELECTRON_RENDERER_URL is required in development mode';
log.error(errorMessage);
await dialog.showMessageBox({
type: 'error',
title: 'Development Startup Error',
message: errorMessage,
});
app.exit(1);
return;
} else {
let res;
try {
res = await launchServer();
} catch (error) {
const isBindingError =
error.errData?.bindingErr ||
/EADDR|address not available|address already in use/i.test(
error.message,
);
if (isBindingError) {
log.warn(
'Remote mode binding failed — remote config has been reset.',
);
dialog.showMessageBoxSync(null, {
title: 'Remote Mode Configuration Error',
message:
'gSender could not connect to the configured remote address.',
detail: 'Remote mode has been disabled and the configuration has been reset. Please restart gSender.',
});
} else {
log.error('Unexpected server startup error:', error);
dialog.showMessageBoxSync(null, {
title: 'Server Startup Error',
message:
'gSender encountered an unexpected error while starting.',
detail: String(error.message),
});
}
app.exit(-1);
return;
}
const { address, port, kiosk: resolvedKiosk } = { ...res };
kiosk = resolvedKiosk;
if (res.configRestored) {
log.warn(
`Corrupt settings file recovered — backup at ${res.configBackupPath}`,
);
dialog.showMessageBoxSync(null, {
title: 'Settings File Recovered',
message:
'Your gSender settings file was corrupted and has been reset to defaults.',
detail: `A backup of the corrupted file was saved to:\n${res.configBackupPath}`,
});
}
log.info(`Returned - http://${address}:${port}`);
hostInformation = {
address,
port,
};
if (!(address && port)) {
log.error(
'Unable to start the server at ' +
chalk.cyan(`http://${address}:${port}`),
);
return;
}
url = `http://${address}:${port}`;
}
// The bounds is a rectangle object with the following properties:
// * `x` Number - The x coordinate of the origin of the rectangle.
// * `y` Number - The y coordinate of the origin of the rectangle.
// * `width` Number - The width of the rectangle.
// * `height` Number - The height of the rectangle.
// resolution used to be 1024x768
const bounds = {
minWidth: 1044,
minHeight: 768,
width: 1280,
height: 800,
...store.get('bounds'),
};
if (usePendantView) {
const pendantAssetsPath = path.join(__dirname, 'pendant');
if (!fs.existsSync(pendantAssetsPath)) {
log.error(
`Pendant view was requested but no pendant assets were found at ${pendantAssetsPath}; falling back to the standard UI.`,
);
dialog.showMessageBoxSync(null, {
type: 'warning',
title: 'Pendant View Unavailable',
message:
'gSender could not find the pendant interface in this build.',
detail: 'Falling back to the standard desktop UI.',
});
usePendantView = false;
}
}
let window;
if (usePendantView) {
// Registered before loadURL below: the pendant SPA (src/pendant/)
// invokes "pendant:get-host" as soon as its bootstrap script runs,
// which can happen before loadURL's promise resolves. Mirrors the
// standalone pendant binary's handlers in src/pendant-main.js.
ipcMain.handle('pendant:get-host', () => {
if (!hostInformation.address || !hostInformation.port) {
return undefined;
}
return `${hostInformation.address}:${hostInformation.port}`;
});
ipcMain.handle('pendant:pick-gcode-file', async () => {
const result = await dialog.showOpenDialog(
window ?? undefined,
{
properties: ['openFile'],
filters: [
{
name: 'G-Code Files',
extensions: [
'gcode',
'gc',
'nc',
'tap',
'cnc',
'g',
],
},
{ name: 'All Files', extensions: ['*'] },
],
},
);
if (result.canceled || !result.filePaths.length)
return undefined;
const filePath = result.filePaths[0];
const content = await fs.promises.readFile(
filePath,
'utf8',
);
const { size } = await fs.promises.stat(filePath);
return {
path: filePath,
name: path.basename(filePath),
size,
content,
};
});
ipcMain.handle(
'pendant:read-gcode-file',
async (_event, filePath) => {
if (typeof filePath !== 'string' || !filePath)
return undefined;
const content = await fs.promises.readFile(
filePath,
'utf8',
);
const { size } = await fs.promises.stat(filePath);
return {
path: filePath,
name: path.basename(filePath),
size,
content,
};
},
);
kiosk = true;
const pendantUrl = `${url}/pendant`;
window = createPendantWindow(
false,
path.join(__dirname, 'preload-pendant.js'),
);
window.once('ready-to-show', () => {
splashScreen.close();
splashScreen.destroy();
});
await window.loadURL(pendantUrl);
} else {
const options = {
...bounds,
title: `gSender ${pkg.version}`,
kiosk,
};
// Don't force-maximize on launch (annoying on large monitors).
// Open at the saved bounds, or the sensible default above on first
// run; the close handler persists the last size/position.
window = await windowManager.openWindow(
url,
options,
splashScreen,
false,
);
}
window.on('ready-to-show', () => {
const savedScaleFactor = Number(
store.get('displayScaleFactor', 1.0),
);
window.webContents.setZoomFactor(savedScaleFactor);
});
// Check argv for file path on Windows/Linux cold start
if (process.platform !== 'darwin') {
const filePath = process.argv.find((arg) =>
/\.(gcode|gc|nc|tap|cnc)$/i.test(arg),
);
if (filePath) {
pendingFileToOpen = filePath;
}
}
ipcMain.on('file-association-ready', () => {
isRendererReady = true;
if (pendingFileToOpen) {
loadFileAssociation(pendingFileToOpen, window);
pendingFileToOpen = null;
}
});
ipcMain.on('change-power-saving', (_msg, enabled) => {
if (!enabled) {
// Power saver - display sleep higher precedence over app suspension
powerBlockerNum = powerSaveBlocker.start(
'prevent-display-sleep',
);
powerMonitor.on('lock-screen', () => {
powerSaveBlocker.start('prevent-display-sleep');
}),
powerMonitor.on('suspend', () => {
powerSaveBlocker.start('prevent-app-suspension');
log.info('Prevented suspension');
});
} else {
if (powerSaveBlocker.isStarted(powerBlockerNum)) {
powerSaveBlocker.stop(powerBlockerNum);
powerMonitor.removeAllListeners();
}
}
});
// Save window size and position
window.on('close', () => {
store.set('bounds', window.getBounds());
});
// Include release notes
//autoUpdater.fullChangelog = true;
if (process.platform === 'win32') {
autoUpdater.on('update-available', (info) => {
setTimeout(() => {
window.webContents.send('update_available', info);
}, 8000);
});
autoUpdater.on('error', (err) => {
window.webContents.send('updated_error', err);
log.error(err);
});
autoUpdater.on('download-progress', (info) => {
window.webContents.send(
'update_download_progress',
info.percent,
);
});
ipcMain.once('restart_app', async () => {
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall(false, false);
});
}
ipcMain.on('load-recent-file', async (msg, recentFile) => {
try {
const fileMetadata = await parseAndReturnGCode(recentFile);
window.webContents.send('loaded-recent-file', fileMetadata);
} catch (err) {
log.error(err);
window.webContents.send('remove-recent-file', {
err: err.message,
path: recentFile.filePath,
});
}
});
ipcMain.on('logError:electron', (channel, error) => {
if ('type' in error) {
log.transports.file.level = 'error';
}
if (error.type.includes('GRBL_HAL')) {
error.type === 'GRBL_HAL_ERROR'
? grblLog.error(
`GRBL_HAL_ERROR:Error ${error.code} - ${error.description} Line ${error.lineNumber}: "${error.line.trim()}" Origin- ${error.origin.trim()}`,
)
: grblLog.error(
`GRBL_HAL_ALARM:Alarm ${error.code} - ${error.description}`,
);
} else {
error.type === 'GRBL_ERROR'
? grblLog.error(
`GRBL_ERROR:Error ${error.code} - ${error.description} Line ${error.lineNumber}: "${error.line.trim()}" Origin- ${error.origin.trim()}`,
)
: grblLog.error(
`GRBL_ALARM:Alarm ${error.code} - ${error.description}`,
);
}
});
ipcMain.handle('copy-to-clipboard', (_channel, text) => {
if (!text) {
return { success: false, error: 'No text to copy' };
}
clipboard.writeText(text);
return { success: true };
});
ipcMain.handle('grblLog:fetch', async (channel) => {
const data = await getGRBLLog(logPath);
return data;
});
ipcMain.handle('check-remote-status', (channel) => {
log.debug(hostInformation);
return hostInformation;
});
ipcMain.handle('get-windows-registry', async (channel) => {
if (process.platform !== 'win32') {
return false;
}
try {
const registry = new WinReg({
hive: WinReg.HKLM,
key: '\\Software\\SienciLabs\\gSender',
});
const isBundledValue = await new Promise(
(resolve, reject) => {
registry.get('IsBundled', (err, item) => {
if (err) {
reject(err);
return;
}
resolve(item.value);
});
},
);
const isBundled = isBundledValue === '0x1';
return isBundled;
} catch (error) {
console.error(error);
return false;
}
});
/**
* gSender config events - move electron store changes out of renderer process
*/
ipcMain.on('open-upload-dialog', async () => {
try {
const additionalOptions = {};
const gSenderWindow = windowManager.getWindow();
if (prevDirectory) {
additionalOptions.defaultPath = prevDirectory;
}
const file = await dialog.showOpenDialog(gSenderWindow, {
properties: ['openFile'],
filters: [
{
name: 'G-Code Files',
extensions: ['gcode', 'gc', 'nc', 'tap', 'cnc'],
},
{ name: 'All Files', extensions: ['*'] },
],
});
if (!file) {
return;
}
if (file.canceled) {
return;
}
const FULL_FILE_PATH = file.filePaths[0];
const getFileInformation = (file) => {
const { base, dir } = path.parse(file);
return [dir, base];
};
const [filePath, fileName] =
getFileInformation(FULL_FILE_PATH);
prevDirectory = filePath; // set previous directory
fs.readFile(FULL_FILE_PATH, 'latin1', (err, data) => {
if (err) {
log.error(`Error in readFile: ${err}`);
return;
}
const { size } = fs.statSync(FULL_FILE_PATH);
window.webContents.send('returned-upload-dialog-data', {
data,
size,
name: fileName,
path: FULL_FILE_PATH,
});
});
} catch (e) {
log.error(`Caught error in listener - ${e}`);
}
});
ipcMain.on('open-directory-dialog', async () => {
try {
const FULL_PATH = await openDirectoryDialog();
window.webContents.send(
'returned-directory-dialog-data',
FULL_PATH,
);
} catch (e) {
log.error(`Caught error in open-directory-dialog - ${e}`);
}
});
// Pick a plugin to install: either a folder or a .zip. Windows and
// Linux cannot offer both in one native dialog, hence the mode.
// Always replies, so a cancel or failure never leaves the install
// wizard waiting on a message that never comes.
ipcMain.on('open-plugin-source-dialog', async (_event, mode) => {
const reply = (payload) =>
window.webContents.send('returned-plugin-source', payload);
try {
const gSenderWindow = windowManager.getWindow();
const isZip = mode === 'zip';
const result = await dialog.showOpenDialog(gSenderWindow, {
title: isZip
? 'Select a plugin .zip file'
: 'Select a plugin folder',
properties: [isZip ? 'openFile' : 'openDirectory'],
filters: isZip
? [{ name: 'Plugin Archives', extensions: ['zip'] }]
: undefined,
});
if (
!result ||
result.canceled ||
result.filePaths.length === 0
) {
reply({ canceled: true });
return;
}
reply({ path: result.filePaths[0] });
} catch (e) {
log.error(
`Caught error in open-plugin-source-dialog - ${e}`,
);
reply({ error: String(e?.message || e) });
}
});
ipcMain.on('open-new-window', (msg, route) => {
const factor = screen.getPrimaryDisplay().scaleFactor;
const childOptions = {
width: 550 / factor,
height: 460 / factor,
minWidth: 550 / factor,
minHeight: 460 / factor,
useContentSize: true,
title: 'gSender',
parent: window,
};
// Hash router URL should look like '{url}/#/widget/:id'
const address = `${url}/#${route}`;
const shouldMaximize = false;
const isChild = true;
windowManager.openWindow(
address,
childOptions,
null,
shouldMaximize,
isChild,
);
});
ipcMain.on('reconnect-main', (event, options) => {
let shouldReconnect = false;
try {
if (
event &&
event.sender &&
event.sender.browserWindowOptions
) {
shouldReconnect =
!event.sender.browserWindowOptions.parent &&
windowManager.childWindows.length > 0;
}
} catch (err) {
log.error(err);
}
if (shouldReconnect) {
windowManager.childWindows.forEach((window) => {
window.webContents.send('reconnect', options);
});
}
});
ipcMain.on('get-data', (event, widget) => {
window.webContents.send('get-data-' + widget);
});
ipcMain.on('receive-data', (event, msg) => {
const { widget, data } = msg;
windowManager.childWindows.forEach((window) => {
window.webContents.send('recieve-data-' + widget, data);
});
});
ipcMain.on('save-display-scale', (_event, scaleFactor) => {
const value = Number(scaleFactor) || 1.0;
store.set('displayScaleFactor', value);
window.webContents.setZoomFactor(value);
});
// Relaunch the app. Shared by remote-mode changes and by the plugin
// install wizard, which needs a restart for the new mount routes.
const restartApp = () => {
let didRestart = false;
const finishRestart = () => {
if (didRestart) return;
didRestart = true;
app.relaunch(); // flags are handled in server/index.js
app.exit(0);
};
// The pendant view runs in native kiosk/fullscreen mode, which can
// block the process from exiting cleanly (macOS in particular) unless
// we leave fullscreen first. Schedule the fallback timeout before
// touching kiosk/fullscreen state so a native exception there can't
// prevent the restart from ever happening.
if (
window &&
!window.isDestroyed() &&
(window.isKiosk() || window.isFullScreen())
) {
window.once('leave-full-screen', finishRestart);
setTimeout(finishRestart, 1000);
try {
window.setKiosk(false);
window.setFullScreen(false);
} catch (err) {
log.error(
`Failed to leave kiosk/fullscreen before restart: ${err}`,
);
}
} else {
finishRestart();
}
};
//Handle app restart with remote settings
ipcMain.on('remoteMode-restart', () => {
restartApp();
});
ipcMain.on('app-restart', () => {
restartApp();
});
} catch (err) {
log.error(err);
log.err(err.name);
await dialog.showMessageBox({
message: err,
});
}
//Check for available updates at end to avoid try-catch failing to load events
if (process.platform === 'win32') {
const internetConnectivity = await isOnline();
if (internetConnectivity) {
autoUpdater.autoDownload = false; // We don't want to force update but will prompt until it is updated
// There may be situations where something is blocking the update check outside of internet connectivity
// This sets a 4 second timeout on the await.
try {
asyncCallWithTimeout(autoUpdater.checkForUpdates(), 5000);
} catch (e) {
log.info(
'Unable to check for app updates, likely no internet connection.',
);
}
}
}
});
};
main();